Persist shared auth config in DB

Add ncue_app_config and /api/config/auth endpoints, and hydrate Auth0 config from the server so other browsers don't see the setup modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-02-07 21:16:37 +09:00
parent a1e37759cc
commit c2ba78db5c
4 changed files with 114 additions and 0 deletions

View File

@@ -31,3 +31,11 @@ END $$;
ALTER TABLE public.ncue_user ADD COLUMN IF NOT EXISTS first_login_at timestamptz;
ALTER TABLE public.ncue_user ADD COLUMN IF NOT EXISTS last_logout_at timestamptz;
-- App config (shared across browsers)
CREATE TABLE IF NOT EXISTS public.ncue_app_config (
key text PRIMARY KEY,
value jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);

View File

@@ -24,6 +24,8 @@
window.AUTH_CONFIG = {
// end-user가 설정 모달을 사용하는지 여부(기본: false)
allowEndUserConfig: false,
// (선택) API 서버가 다른 도메인이면 지정. 예: https://api.ncue.net
apiBase: "",
auth0: {
domain: "",
clientId: "",

View File

@@ -493,6 +493,34 @@
}
}
async function hydrateAuthConfigFromServerIfNeeded() {
const cfg = getAuthConfig();
const hasLocal = Boolean(cfg.auth0.domain && cfg.auth0.clientId && cfg.connections.google);
if (hasLocal) return true;
try {
const r = await fetch(apiUrl("/api/config/auth"), { cache: "no-store" });
if (!r.ok) return false;
const data = await r.json();
if (!data || !data.ok || !data.value) return false;
const v = data.value;
const auth0 = v.auth0 || {};
const connections = v.connections || {};
const allowedEmails = Array.isArray(v.allowedEmails) ? v.allowedEmails : [];
const domain = String(auth0.domain || "").trim();
const clientId = String(auth0.clientId || "").trim();
const google = String(connections.google || "").trim();
if (!domain || !clientId || !google) return false;
saveAuthOverride({
auth0: { domain, clientId },
connections: { google },
allowedEmails,
});
return true;
} catch {
return false;
}
}
function currentUrlNoQuery() {
// Auth0 callback 후 URL 정리용
const u = new URL(location.href);
@@ -1352,6 +1380,7 @@
async function main() {
initTheme();
wire();
await hydrateAuthConfigFromServerIfNeeded();
await initAuth();
state.baseLinks = await loadBaseLinks();
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));

View File

@@ -32,6 +32,9 @@ const DB_NAME = must("DB_NAME");
const DB_USER = must("DB_USER");
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(["dosangyoon@gmail.com", "dsyoon@ncue.net"]);
const pool = new pg.Pool({
host: DB_HOST,
@@ -98,6 +101,21 @@ async function ensureUserTable() {
await pool.query(`alter table public.${TABLE} add column if not exists last_logout_at timestamptz`);
}
async function ensureConfigTable() {
await pool.query(`
create table if not exists public.${CONFIG_TABLE} (
key text primary key,
value jsonb not null,
updated_at timestamptz not null default now()
)
`);
}
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();
@@ -170,6 +188,63 @@ app.post("/api/auth/logout", async (req, res) => {
}
});
// Shared auth config for all browsers (read-only public)
app.get("/api/config/auth", async (_req, res) => {
try {
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" });
res.json({ ok: true, value: r.rows[0].value, updated_at: r.rows[0].updated_at });
} catch (e) {
res.status(500).json({ ok: false, error: "server_error" });
}
});
// Write auth config (protected by CONFIG_TOKEN)
app.post("/api/config/auth", async (req, res) => {
try {
await ensureConfigTable();
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 auth0 = body.auth0 && typeof body.auth0 === "object" ? body.auth0 : {};
const connections = body.connections && typeof body.connections === "object" ? body.connections : {};
const allowedEmails = Array.isArray(body.allowedEmails) ? body.allowedEmails : [];
const domain = String(auth0.domain || "").trim();
const clientId = String(auth0.clientId || "").trim();
const googleConn = String(connections.google || "").trim();
const emails = allowedEmails.map((x) => String(x).trim().toLowerCase()).filter(Boolean);
if (!domain || !clientId || !googleConn) {
return res.status(400).json({ ok: false, error: "missing_fields" });
}
// Optional safety: ensure at least one admin is present in allowedEmails
if (emails.length && !emails.some(isAdminEmail)) {
return res.status(400).json({ ok: false, error: "admin_email_missing" });
}
const value = {
auth0: { domain, clientId },
connections: { google: googleConn },
allowedEmails: emails,
};
await pool.query(
`insert into public.${CONFIG_TABLE} (key, value, updated_at)
values ($1, $2::jsonb, now())
on conflict (key) do update set value = excluded.value, updated_at = now()`,
["auth", JSON.stringify(value)]
);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: "server_error" });
}
});
// Serve static site
app.use(express.static(__dirname, { extensions: ["html"] }));