From 899cdf14d02eeea0d34a1f608e359a432459b89e Mon Sep 17 00:00:00 2001 From: dosangyoon Date: Mon, 23 Mar 2026 10:31:15 +0900 Subject: [PATCH] feat(auth): expose ADMIN_EMAILS via /api/config/auth and grant SPA admin when email matches Made-with: Cursor --- flask_app.py | 19 ++++++++++++++++--- index.html | 19 ++++++++++++++++++- script.js | 26 ++++++++++++++++++++++++-- server.js | 17 ++++++++++++----- 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/flask_app.py b/flask_app.py index a6a3f50..1fb6558 100644 --- a/flask_app.py +++ b/flask_app.py @@ -515,8 +515,8 @@ def api_config_auth_get() -> Response: "value": { "auth0": {"domain": AUTH0_DOMAIN, "clientId": AUTH0_CLIENT_ID}, "connections": {"google": AUTH0_GOOGLE_CONNECTION}, - # Deprecated: admin is stored per-user in DB (ncue_user.is_admin) - "adminEmails": [], + # Mirrors .env ADMIN_EMAILS for SPA: unlock UI when /api/auth/sync lags or misreads .env + "adminEmails": sorted(ADMIN_EMAILS), }, "updated_at": None, "source": "env", @@ -534,9 +534,22 @@ def api_config_auth_get() -> Response: if isinstance(value, str): value = json.loads(value) - if isinstance(value, dict) and "adminEmails" not in value and isinstance(value.get("allowedEmails"), list): + if not isinstance(value, dict): + return jsonify({"ok": False, "error": "not_set"}), 404 + + if "adminEmails" not in value and isinstance(value.get("allowedEmails"), list): value["adminEmails"] = value.get("allowedEmails") + merged: set[str] = set() + ae = value.get("adminEmails") + if isinstance(ae, list): + for x in ae: + if isinstance(x, str) and x.strip(): + merged.add(x.strip().lower()) + merged |= ADMIN_EMAILS + value = dict(value) + value["adminEmails"] = sorted(merged) + return jsonify({"ok": True, "value": value, "updated_at": row[1], "source": "db"}) except Exception: return jsonify({"ok": False, "error": "server_error"}), 500 diff --git a/index.html b/index.html index 31716ea..e378ca8 100644 --- a/index.html +++ b/index.html @@ -865,6 +865,18 @@ }; } + function isConfigListedAdmin(emailRaw) { + const e = String(emailRaw || "").trim().toLowerCase(); + if (!e) return false; + const cfg = getAuthConfig(); + const list = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : []; + return list.some((x) => String(x).trim().toLowerCase() === e); + } + + function resolveAuthorizedAfterSync(canManageFromServer) { + return Boolean(canManageFromServer) || isConfigListedAdmin(sessionEmail); + } + function currentUrlNoQuery() { const u = new URL(location.href); u.searchParams.delete("code"); @@ -932,12 +944,17 @@ }).catch(() => null); if (r && r.ok) { const data = await r.json().catch(() => null); - if (data && data.ok) auth.authorized = Boolean(data.canManage); + if (data && data.ok) auth.authorized = resolveAuthorizedAfterSync(data.canManage); + } else if (auth.user) { + auth.authorized = isConfigListedAdmin(sessionEmail); } } } catch { // ignore } + if (auth.user && !auth.authorized) { + auth.authorized = isConfigListedAdmin(sessionEmail); + } } applyManageLock(); diff --git a/script.js b/script.js index 164f712..55f9dbe 100644 --- a/script.js +++ b/script.js @@ -77,6 +77,21 @@ return e.trim().toLowerCase(); } + /** 서버 /api/config/auth 의 adminEmails(.env ADMIN_EMAILS와 동기)에 포함된 로그인 사용자 */ + function isConfigListedAdmin(emailRaw) { + const e = String(emailRaw || "").trim().toLowerCase(); + if (!e) return false; + const cfg = getAuthConfig(); + const list = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : []; + return list.some((x) => String(x).trim().toLowerCase() === e); + } + + function resolveAuthorizedAfterSync(canManageFromServer) { + const fromApi = Boolean(canManageFromServer); + const fromList = isConfigListedAdmin(getUserEmail()); + return fromApi || fromList; + } + function canAccessLink(link) { // Admin (DB: ncue_user.is_admin) can access all links. if (auth && auth.authorized) return true; @@ -977,7 +992,9 @@ const can = await syncUserToServerWithIdToken(t.id_token); if (typeof can === "boolean") { auth.serverCanManage = can; - auth.authorized = can; + auth.authorized = resolveAuthorizedAfterSync(can); + } else if (auth.user) { + auth.authorized = isConfigListedAdmin(getUserEmail()); } } updateAuthUi(); @@ -1046,13 +1063,18 @@ const data = await r.json(); if (data && data.ok) { auth.serverCanManage = Boolean(data.canManage); - auth.authorized = auth.serverCanManage; + auth.authorized = resolveAuthorizedAfterSync(data.canManage); } + } else if (auth.user) { + auth.authorized = isConfigListedAdmin(getUserEmail()); } } } catch { // ignore: server not running or blocked } + if (auth.user && !auth.authorized) { + auth.authorized = isConfigListedAdmin(getUserEmail()); + } } if (auth.user && !auth.authorized) { diff --git a/server.js b/server.js index 5cf6122..2546cd6 100644 --- a/server.js +++ b/server.js @@ -238,8 +238,7 @@ app.get("/api/config/auth", async (_req, res) => { value: { auth0: { domain: AUTH0_DOMAIN, clientId: AUTH0_CLIENT_ID }, connections: { google: AUTH0_GOOGLE_CONNECTION }, - // Deprecated: admin is stored per-user in DB (ncue_user.is_admin) - adminEmails: [], + adminEmails: Array.from(ADMIN_EMAILS).sort(), }, updated_at: null, source: "env", @@ -249,11 +248,19 @@ app.get("/api/config/auth", async (_req, res) => { await ensureConfigTable(); const r = await pool.query(`select value, updated_at from public.${CONFIG_TABLE} where key = $1`, ["auth"]); if (!r.rows?.length) return res.status(404).json({ ok: false, error: "not_set" }); - const v = r.rows[0].value || {}; + let v = r.rows[0].value || {}; + if (typeof v !== "object" || v === null) v = {}; // legacy: allowedEmails -> adminEmails - if (v && typeof v === "object" && !v.adminEmails && Array.isArray(v.allowedEmails)) { - v.adminEmails = v.allowedEmails; + if (!v.adminEmails && Array.isArray(v.allowedEmails)) v = { ...v, adminEmails: v.allowedEmails }; + const merged = new Set(ADMIN_EMAILS); + const ae = v.adminEmails; + if (Array.isArray(ae)) { + for (const x of ae) { + const s = String(x || "").trim().toLowerCase(); + if (s) merged.add(s); + } } + v = { ...v, adminEmails: Array.from(merged).sort() }; res.json({ ok: true, value: v, updated_at: r.rows[0].updated_at, source: "db" }); } catch (e) { res.status(500).json({ ok: false, error: "server_error" });