From c21a7b3739195e3429e7301279ad97b006f8fbc0 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Wed, 25 Feb 2026 21:09:02 +0900 Subject: [PATCH] =?UTF-8?q?DB=20=EA=B8=B0=EB=B0=98=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EA=B6=8C=ED=95=9C=EC=9C=BC=EB=A1=9C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ncue_user에 is_admin 추가 및 can_manage 호환 유지 - /api/auth/sync 및 관리 API를 DB is_admin 기반으로 변경 - index.html 폴백에서 관리자 이메일 하드코딩 제거 - .env로 관리자 부트스트랩(ADMIN_EMAILS) 지원 Co-authored-by: Cursor --- .env.example | 24 --------- .gitignore | 1 - README.md | 23 +++++++-- flask_app.py | 134 +++++++++++++++++++++++++++++++++++++++++---------- index.html | 30 +++++------- script.js | 32 ++---------- server.js | 60 +++++++++++++++++------ 7 files changed, 190 insertions(+), 114 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index f5f11af..0000000 --- a/.env.example +++ /dev/null @@ -1,24 +0,0 @@ -## Database -DB_HOST=ncue.net -DB_PORT=5432 -DB_NAME=ncue -DB_USER=ncue -DB_PASSWORD=REPLACE_ME -TABLE=ncue_user - -## Auth0 (server-side) -# Auth0 Domain (without https://) -AUTH0_DOMAIN=ncue.net -# Auth0 SPA Application Client ID -AUTH0_CLIENT_ID=g5RDfax7FZkKzXYvXOgku3Ll8CxuA4IM -# Google connection name (usually google-oauth2) -AUTH0_GOOGLE_CONNECTION=google-oauth2 -# Admin emails (comma-separated) -ADMIN_EMAILS=dsyoon@ncue.net,dosangyoon@gmail.com,dosangyoon2@gmail.com,dosangyoon3@gmail.com - -## Optional -# Server port -PORT=8000 -# Optional: allow writing config via API (not required if using env) -CONFIG_TOKEN= - diff --git a/.gitignore b/.gitignore index 5a8388f..5968e44 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ node_modules/ .env .env.* -!.env.example # Python .venv/ diff --git a/README.md b/README.md index 57a8f0c..c170d7a 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,8 @@ sudo journalctl -u ncue-flask -n 200 --no-pager > 참고: `flask_app.py`는 DB가 일시적으로 죽어도 앱 임포트 단계에서 바로 죽지 않도록(DB pool lazy 생성) 개선되어, > “백엔드 프로세스가 안 떠서 Apache가 503”인 케이스를 줄였습니다. -기본적으로 `.env`의 `ADMIN_EMAILS`에 포함된 이메일은 `can_manage=true`로 자동 승격됩니다. +관리 권한은 DB 사용자 테이블(`ncue_user`)의 `is_admin=true`로 부여됩니다. +(`can_manage`는 호환을 위해 남겨두며, 서버는 `is_admin`을 기준으로 동작합니다.) ### (선택) 역프록시/분리 배포 @@ -189,10 +190,24 @@ sudo journalctl -u ncue-flask -n 200 --no-pager - 정적은 별도 호스팅 + API만 Flask: `index.html`의 `window.AUTH_CONFIG.apiBase`에 API 주소를 넣고, Flask에서는 `CORS_ORIGINS`로 허용 도메인을 지정하세요. -최초 로그인 사용자는 DB에 저장되지만 `can_manage=false`입니다. 관리 권한을 주려면: +최초 로그인 사용자는 DB에 저장되며 기본값은 `is_admin=false`입니다. 관리자 승격 방법: + +- (권장) 서버 `.env`에 `ADMIN_EMAILS`를 설정하면, 해당 이메일 사용자가 로그인할 때 `/api/auth/sync`에서 자동으로 + `is_admin=true`로 승격됩니다. (프론트로 노출되지 않음) + +- API(서버 `.env`에 `CONFIG_TOKEN` 설정 후): + +```bash +curl -sS -X POST http://127.0.0.1:8023/api/admin/users/set_admin \ + -H "Content-Type: application/json" \ + -H "X-Config-Token: $CONFIG_TOKEN" \ + -d '{"email":"me@example.com","isAdmin":true}' +``` + +- 또는 SQL로 직접: ```sql -update ncue_user set can_manage = true where email = 'me@example.com'; +update ncue_user set is_admin = true, can_manage = true where email = 'me@example.com'; ``` ## 로그인(관리 기능 잠금) @@ -209,7 +224,7 @@ update ncue_user set can_manage = true where email = 'me@example.com'; - `AUTH0_DOMAIN` - `AUTH0_CLIENT_ID` - `AUTH0_GOOGLE_CONNECTION` (예: `google-oauth2`) - - `ADMIN_EMAILS` (예: `dosangyoon@gmail.com,dsyoon@ncue.net`) + - (선택) `CONFIG_TOKEN` (관리자 승격 API 보호용) 3. Auth0 Application 설정에서 아래 URL들을 등록 - Allowed Callback URLs: `https://ncue.net/` 와 `https://ncue.net` - Allowed Logout URLs: `https://ncue.net/` 와 `https://ncue.net` diff --git a/flask_app.py b/flask_app.py index b5ce23c..6094e2e 100644 --- a/flask_app.py +++ b/flask_app.py @@ -56,8 +56,7 @@ DB_CONNECT_TIMEOUT = int(env("DB_CONNECT_TIMEOUT", "5") or "5") TABLE = safe_ident(env("TABLE", "ncue_user") or "ncue_user") CONFIG_TABLE = "ncue_app_config" CONFIG_TOKEN = env("CONFIG_TOKEN", "").strip() - -ADMIN_EMAILS = set(parse_email_csv(env("ADMIN_EMAILS", "dosangyoon@gmail.com,dsyoon@ncue.net"))) +ADMIN_EMAILS = set(parse_email_csv(env("ADMIN_EMAILS", ""))) # Auth0 config via .env (preferred) AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").strip() @@ -135,6 +134,7 @@ def ensure_user_table() -> None: first_login_at timestamptz, last_login_at timestamptz, last_logout_at timestamptz, + is_admin boolean not null default false, can_manage boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now() @@ -144,6 +144,11 @@ def ensure_user_table() -> None: db_exec(f"create index if not exists idx_{TABLE}_email on public.{TABLE} (email)") db_exec(f"alter table public.{TABLE} add column if not exists first_login_at timestamptz") db_exec(f"alter table public.{TABLE} add column if not exists last_logout_at timestamptz") + db_exec(f"alter table public.{TABLE} add column if not exists is_admin boolean not null default false") + # Backward-compat: previously can_manage was used as admin flag. Preserve existing admins. + db_exec(f"update public.{TABLE} set is_admin = true where can_manage = true and is_admin = false") + # Keep can_manage consistent with is_admin going forward. + db_exec(f"update public.{TABLE} set can_manage = is_admin where can_manage is distinct from is_admin") def ensure_config_table() -> None: @@ -158,20 +163,20 @@ def ensure_config_table() -> None: ) -def is_admin_email(email: str) -> bool: - e = str(email or "").strip().lower() - return e in ADMIN_EMAILS - - def bearer_token() -> str: h = request.headers.get("Authorization", "") m = re.match(r"^Bearer\s+(.+)$", h, flags=re.IGNORECASE) return m.group(1).strip() if m else "" -def verify_admin_from_request() -> Tuple[bool, str]: +def is_bootstrap_admin(email: Optional[str]) -> bool: + e = str(email or "").strip().lower() + return bool(e and e in ADMIN_EMAILS) + + +def verify_user_from_request() -> Tuple[Optional[str], str]: """ - Returns (is_admin, email_lowercase). + Returns (sub, email_lowercase). Uses the same headers as /api/auth/sync: - Authorization: Bearer - X-Auth0-Issuer @@ -179,16 +184,31 @@ def verify_admin_from_request() -> Tuple[bool, str]: """ id_token = bearer_token() if not id_token: - return (False, "") + return (None, "") issuer = str(request.headers.get("X-Auth0-Issuer", "")).strip() audience = str(request.headers.get("X-Auth0-ClientId", "")).strip() if not issuer or not audience: - return (False, "") + return (None, "") payload = verify_id_token(id_token, issuer=issuer, audience=audience) + sub = str(payload.get("sub") or "").strip() email = (str(payload.get("email")).strip().lower() if payload.get("email") else "") - return (bool(email and is_admin_email(email)), email) + return (sub or None, email) + + +def is_request_admin() -> Tuple[bool, str]: + """ + Returns (is_admin, email_lowercase) using DB-stored flag. + """ + if not db_configured(): + return (False, "") + ensure_user_table() + sub, email = verify_user_from_request() + if not sub: + return (False, email) + row = db_one(f"select is_admin from public.{TABLE} where sub = %s", (sub,)) + return (bool(row and row[0] is True), email) def safe_write_json(path: Path, data: Any) -> None: @@ -388,16 +408,17 @@ def api_auth_sync() -> Response: name = (str(payload.get("name")).strip() if payload.get("name") else None) picture = (str(payload.get("picture")).strip() if payload.get("picture") else None) provider = sub.split("|", 1)[0] if "|" in sub else None - admin = bool(email and is_admin_email(email)) if not sub: return jsonify({"ok": False, "error": "missing_sub"}), 400 + bootstrap_admin = bool(email and is_bootstrap_admin(email)) + sql = f""" insert into public.{TABLE} - (sub, email, name, picture, provider, first_login_at, last_login_at, can_manage, updated_at) + (sub, email, name, picture, provider, first_login_at, last_login_at, is_admin, can_manage, updated_at) values - (%s, %s, %s, %s, %s, now(), now(), %s, now()) + (%s, %s, %s, %s, %s, now(), now(), %s, %s, now()) on conflict (sub) do update set email = excluded.email, name = excluded.name, @@ -405,24 +426,27 @@ def api_auth_sync() -> Response: provider = excluded.provider, first_login_at = coalesce(public.{TABLE}.first_login_at, excluded.first_login_at), last_login_at = now(), - can_manage = (public.{TABLE}.can_manage or %s), + is_admin = (public.{TABLE}.is_admin or public.{TABLE}.can_manage or %s), + can_manage = (public.{TABLE}.is_admin or public.{TABLE}.can_manage or %s), updated_at = now() - returning can_manage, first_login_at, last_login_at, last_logout_at + returning is_admin, can_manage, first_login_at, last_login_at, last_logout_at """ - row = db_one(sql, (sub, email, name, picture, provider, admin, admin)) - can_manage = bool(row[0]) if row else False + row = db_one(sql, (sub, email, name, picture, provider, bootstrap_admin, bootstrap_admin, bootstrap_admin, bootstrap_admin)) + is_admin = bool(row[0]) if row else False + can_manage = bool(row[1]) if row else False user = ( { + "is_admin": is_admin, "can_manage": can_manage, - "first_login_at": row[1], - "last_login_at": row[2], - "last_logout_at": row[3], + "first_login_at": row[2], + "last_login_at": row[3], + "last_logout_at": row[4], } if row else None ) - return jsonify({"ok": True, "canManage": can_manage, "user": user}) + return jsonify({"ok": True, "canManage": is_admin, "user": user}) except Exception: return jsonify({"ok": False, "error": "verify_failed"}), 401 @@ -471,7 +495,8 @@ def api_config_auth_get() -> Response: "value": { "auth0": {"domain": AUTH0_DOMAIN, "clientId": AUTH0_CLIENT_ID}, "connections": {"google": AUTH0_GOOGLE_CONNECTION}, - "adminEmails": sorted(list(ADMIN_EMAILS)), + # Deprecated: admin is stored per-user in DB (ncue_user.is_admin) + "adminEmails": [], }, "updated_at": None, "source": "env", @@ -525,7 +550,7 @@ def api_links_put() -> Response: - {"links":[...]} """ try: - ok_admin, _email = verify_admin_from_request() + ok_admin, _email = is_request_admin() if not ok_admin: return jsonify({"ok": False, "error": "forbidden"}), 403 @@ -585,6 +610,65 @@ def api_config_auth_post() -> Response: return jsonify({"ok": False, "error": "server_error"}), 500 +@app.post("/api/admin/users/set_admin") +def api_admin_users_set_admin() -> Response: + """ + CONFIG_TOKEN-protected API to set per-user admin flag in DB. + Body: + - {"sub":"...", "isAdmin": true} + - or {"email":"user@example.com", "isAdmin": true} + """ + try: + if not db_configured(): + return jsonify({"ok": False, "error": "db_not_configured"}), 500 + ensure_user_table() + if not CONFIG_TOKEN: + return jsonify({"ok": False, "error": "config_token_not_set"}), 403 + token = str(request.headers.get("X-Config-Token", "")).strip() + if token != CONFIG_TOKEN: + return jsonify({"ok": False, "error": "forbidden"}), 403 + + body = request.get_json(silent=True) or {} + sub = str(body.get("sub") or "").strip() + email = str(body.get("email") or "").strip().lower() + is_admin = body.get("isAdmin") + if not isinstance(is_admin, bool): + # accept legacy keys + is_admin = body.get("admin") + if not isinstance(is_admin, bool): + return jsonify({"ok": False, "error": "missing_isAdmin"}), 400 + if not sub and not email: + return jsonify({"ok": False, "error": "missing_identifier"}), 400 + + if sub: + sql = f""" + update public.{TABLE} + set is_admin = %s, + can_manage = %s, + updated_at = now() + where sub = %s + returning sub, email, is_admin + """ + row = db_one(sql, (is_admin, is_admin, sub)) + else: + sql = f""" + update public.{TABLE} + set is_admin = %s, + can_manage = %s, + updated_at = now() + where email = %s + returning sub, email, is_admin + """ + row = db_one(sql, (is_admin, is_admin, email)) + + if not row: + return jsonify({"ok": False, "error": "not_found"}), 404 + + return jsonify({"ok": True, "user": {"sub": row[0], "email": row[1], "is_admin": bool(row[2])}}) + except Exception: + return jsonify({"ok": False, "error": "server_error"}), 500 + + if __name__ == "__main__": # Production should run behind a reverse proxy (nginx) or gunicorn. app.run(host="0.0.0.0", port=PORT, debug=False) diff --git a/index.html b/index.html index 4ccd687..3e8c576 100644 --- a/index.html +++ b/index.html @@ -435,18 +435,11 @@ ]); let sessionEmail = ""; - function isAdminEmail(email) { - const cfg = getAuthConfig(); - const admins = cfg && Array.isArray(cfg.adminEmails) ? cfg.adminEmails : []; - const e = String(email || "").trim().toLowerCase(); - if (admins.length) return admins.includes(e); - // fallback default - return ["dsyoon@ncue.net", "dosangyoon@gmail.com", "dosangyoon2@gmail.com", "dosangyoon3@gmail.com"].includes(e); - } function canAccessLink(link) { + // Admin access is determined by server (DB: ncue_user.is_admin) via /api/auth/sync. + if (auth && auth.authorized) return true; const id = String(link && link.id ? link.id : ""); const email = String(sessionEmail || "").trim().toLowerCase(); - if (email && isAdminEmail(email)) return true; if (email) return ACCESS_USER_IDS.has(id); return ACCESS_ANON_IDS.has(id); } @@ -751,6 +744,7 @@ const auth = { client: null, user: null, + authorized: false, ready: false, }; @@ -771,7 +765,6 @@ const v = data.value; const auth0 = v.auth0 || {}; const connections = v.connections || {}; - const adminEmails = Array.isArray(v.adminEmails) ? v.adminEmails : Array.isArray(v.allowedEmails) ? v.allowedEmails : []; const domain = String(auth0.domain || "").trim(); const clientId = String(auth0.clientId || "").trim(); const google = String(connections.google || "").trim(); @@ -781,7 +774,6 @@ JSON.stringify({ auth0: { domain, clientId }, connections: { google }, - adminEmails, }) ); return true; @@ -791,7 +783,7 @@ } function applyManageLock() { - const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail); + const canManage = Boolean(auth.user) && Boolean(auth.authorized); if (el.btnAdd) el.btnAdd.disabled = !canManage; if (el.btnImport) el.btnImport.disabled = !canManage; // 요청: 로그인 전 내보내기 비활성화 @@ -919,14 +911,18 @@ const raw = claims && claims.__raw ? String(claims.__raw) : ""; if (raw) { const cfg = getAuthConfig(); - await fetch(apiUrl("/api/auth/sync"), { + const r = await fetch(apiUrl("/api/auth/sync"), { method: "POST", headers: { Authorization: `Bearer ${raw}`, "X-Auth0-Issuer": `https://${cfg.auth0.domain}/`, "X-Auth0-ClientId": cfg.auth0.clientId, }, - }).catch(() => {}); + }).catch(() => null); + if (r && r.ok) { + const data = await r.json().catch(() => null); + if (data && data.ok) auth.authorized = Boolean(data.canManage); + } } } catch { // ignore @@ -1036,9 +1032,9 @@ e.preventDefault(); return; } - // block manage actions unless admin + // block manage actions unless admin (server: ncue_user.is_admin) const act0 = btn.getAttribute("data-act"); - const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail); + const canManage = Boolean(auth.user) && Boolean(auth.authorized); if (!canManage && (act0 === "fav" || act0 === "edit" || act0 === "del")) { toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다."); return; @@ -1066,7 +1062,7 @@ if (el.btnExport) el.btnExport.addEventListener("click", exportJson); if (el.btnImport) el.btnImport.addEventListener("click", () => { - const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail); + const canManage = Boolean(auth.user) && Boolean(auth.authorized); if (!canManage) return toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다."); if (el.file) el.file.click(); }); diff --git a/script.js b/script.js index dfa214d..1986985 100644 --- a/script.js +++ b/script.js @@ -71,29 +71,16 @@ "link-ncue-net", "dreamgirl-ncue-net", ]); - const DEFAULT_ADMIN_EMAILS = new Set([ - "dsyoon@ncue.net", - "dosangyoon@gmail.com", - "dosangyoon2@gmail.com", - "dosangyoon3@gmail.com", - ]); function getUserEmail() { const e = auth && auth.user && auth.user.email ? String(auth.user.email) : ""; return e.trim().toLowerCase(); } - function isAdminEmail(email) { - const e = String(email || "").trim().toLowerCase(); - const cfg = getAuthConfig(); - const admins = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : []; - if (admins.length) return admins.includes(e); - return DEFAULT_ADMIN_EMAILS.has(e); - } - function canAccessLink(link) { + // Admin (DB: ncue_user.is_admin) can access all links. + if (auth && auth.authorized) return true; const email = getUserEmail(); - if (email && isAdminEmail(email)) return true; const id = String(link && link.id ? link.id : ""); if (email) return ACCESS_USER_IDS.has(id); return ACCESS_ANON_IDS.has(id); @@ -896,15 +883,6 @@ } } - function isManageAdminEmail(email) { - const cfg = getAuthConfig(); - const admins = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : []; - const e = String(email || "").trim().toLowerCase(); - if (admins.length) return admins.includes(e); - // 안전한 기본값: 설정이 비어있으면 기본 관리자만 관리 가능 - return DEFAULT_ADMIN_EMAILS.has(e); - } - function updateAuthUi() { // 로그인 전에는 사용자 배지를 숨김(요청: "로그인 설정 필요" 영역 제거) if (!auth.user) { @@ -982,8 +960,7 @@ auth.mode = "enabled"; await manualHandleCallbackIfNeeded().catch(() => {}); auth.user = await manualLoadUser(); - const email = auth.user && auth.user.email ? String(auth.user.email) : ""; - auth.authorized = Boolean(auth.user) && isManageAdminEmail(email); + auth.authorized = false; auth.serverCanManage = null; const t = loadTokens(); if (auth.user && t && t.id_token) { @@ -1037,8 +1014,7 @@ const isAuthed = await auth.client.isAuthenticated(); auth.user = isAuthed ? await auth.client.getUser() : null; - const email = auth.user && auth.user.email ? auth.user.email : ""; - auth.authorized = Boolean(auth.user) && isManageAdminEmail(email); + auth.authorized = false; auth.serverCanManage = null; // 로그인되었으면 서버에 사용자 upsert 및 can_manage 동기화(서버가 있을 때만) diff --git a/server.js b/server.js index 85f8efd..7b1f136 100644 --- a/server.js +++ b/server.js @@ -45,7 +45,7 @@ const DB_PASSWORD = must("DB_PASSWORD"); const TABLE = safeIdent(env("TABLE", "ncue_user") || "ncue_user"); const CONFIG_TABLE = "ncue_app_config"; const CONFIG_TOKEN = env("CONFIG_TOKEN", "").trim(); -const ADMIN_EMAILS = new Set(parseEmailCsv(env("ADMIN_EMAILS", "dosangyoon@gmail.com,dsyoon@ncue.net"))); +const ADMIN_EMAILS = new Set(parseEmailCsv(env("ADMIN_EMAILS", ""))); // Auth0 config via .env (preferred) const AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").trim(); @@ -107,6 +107,7 @@ async function ensureUserTable() { first_login_at timestamptz, last_login_at timestamptz, last_logout_at timestamptz, + is_admin boolean not null default false, can_manage boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now() @@ -115,6 +116,11 @@ async function ensureUserTable() { await pool.query(`create index if not exists idx_${TABLE}_email on public.${TABLE} (email)`); await pool.query(`alter table public.${TABLE} add column if not exists first_login_at timestamptz`); await pool.query(`alter table public.${TABLE} add column if not exists last_logout_at timestamptz`); + await pool.query(`alter table public.${TABLE} add column if not exists is_admin boolean not null default false`); + // Backward-compat: previously can_manage was used as admin flag. Preserve existing admins. + await pool.query(`update public.${TABLE} set is_admin = true where can_manage = true and is_admin = false`); + // Keep can_manage consistent with is_admin going forward. + await pool.query(`update public.${TABLE} set can_manage = is_admin where can_manage is distinct from is_admin`); } async function ensureConfigTable() { @@ -127,11 +133,6 @@ async function ensureConfigTable() { `); } -function isAdminEmail(email) { - const e = String(email || "").trim().toLowerCase(); - return ADMIN_EMAILS.has(e); -} - app.post("/api/auth/sync", async (req, res) => { try { await ensureUserTable(); @@ -149,15 +150,16 @@ app.post("/api/auth/sync", async (req, res) => { const name = payload.name ? String(payload.name).trim() : null; const picture = payload.picture ? String(payload.picture).trim() : null; const provider = sub.includes("|") ? sub.split("|", 1)[0] : null; - const isAdmin = email ? isAdminEmail(email) : false; if (!sub) return res.status(400).json({ ok: false, error: "missing_sub" }); + const bootstrapAdmin = Boolean(email && ADMIN_EMAILS.has(email)); + const q = ` insert into public.${TABLE} - (sub, email, name, picture, provider, first_login_at, last_login_at, can_manage, updated_at) + (sub, email, name, picture, provider, first_login_at, last_login_at, is_admin, can_manage, updated_at) values - ($1, $2, $3, $4, $5, now(), now(), $6, now()) + ($1, $2, $3, $4, $5, now(), now(), $6, $6, now()) on conflict (sub) do update set email = excluded.email, name = excluded.name, @@ -165,14 +167,15 @@ app.post("/api/auth/sync", async (req, res) => { provider = excluded.provider, first_login_at = coalesce(public.${TABLE}.first_login_at, excluded.first_login_at), last_login_at = now(), - can_manage = (public.${TABLE}.can_manage or $6), + is_admin = (public.${TABLE}.is_admin or public.${TABLE}.can_manage or $6), + can_manage = (public.${TABLE}.is_admin or public.${TABLE}.can_manage or $6), updated_at = now() - returning can_manage, first_login_at, last_login_at, last_logout_at + returning is_admin, can_manage, first_login_at, last_login_at, last_logout_at `; - const r = await pool.query(q, [sub, email, name, picture, provider, isAdmin]); - const canManage = Boolean(r.rows?.[0]?.can_manage); + const r = await pool.query(q, [sub, email, name, picture, provider, bootstrapAdmin]); + const isAdmin = Boolean(r.rows?.[0]?.is_admin); - res.json({ ok: true, canManage, user: r.rows?.[0] || null }); + res.json({ ok: true, canManage: isAdmin, user: r.rows?.[0] || null }); } catch (e) { res.status(401).json({ ok: false, error: "verify_failed" }); } @@ -216,7 +219,8 @@ app.get("/api/config/auth", async (_req, res) => { value: { auth0: { domain: AUTH0_DOMAIN, clientId: AUTH0_CLIENT_ID }, connections: { google: AUTH0_GOOGLE_CONNECTION }, - adminEmails: [...ADMIN_EMAILS], + // Deprecated: admin is stored per-user in DB (ncue_user.is_admin) + adminEmails: [], }, updated_at: null, source: "env", @@ -282,6 +286,32 @@ app.post("/api/config/auth", async (req, res) => { } }); +// CONFIG_TOKEN-protected: set per-user admin flag in DB +app.post("/api/admin/users/set_admin", async (req, res) => { + try { + await ensureUserTable(); + if (!CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "config_token_not_set" }); + const token = String(req.headers["x-config-token"] || "").trim(); + if (token !== CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "forbidden" }); + + const body = req.body && typeof req.body === "object" ? req.body : {}; + const sub = String(body.sub || "").trim(); + const email = String(body.email || "").trim().toLowerCase(); + const isAdmin = typeof body.isAdmin === "boolean" ? body.isAdmin : typeof body.admin === "boolean" ? body.admin : null; + if (typeof isAdmin !== "boolean") return res.status(400).json({ ok: false, error: "missing_isAdmin" }); + if (!sub && !email) return res.status(400).json({ ok: false, error: "missing_identifier" }); + + const q = sub + ? `update public.${TABLE} set is_admin = $1, can_manage = $1, updated_at = now() where sub = $2 returning sub, email, is_admin` + : `update public.${TABLE} set is_admin = $1, can_manage = $1, updated_at = now() where email = $2 returning sub, email, is_admin`; + const r = await pool.query(q, [isAdmin, sub ? sub : email]); + if (!r.rows?.length) return res.status(404).json({ ok: false, error: "not_found" }); + res.json({ ok: true, user: r.rows[0] }); + } catch { + res.status(500).json({ ok: false, error: "server_error" }); + } +}); + // Serve static site app.use(express.static(__dirname, { extensions: ["html"] }));