Files
home/script.js
dsyoon 21834aa728 Support Auth0 login without SPA SDK (PKCE)
If createAuth0Client is unavailable on static hosting, use manual OAuth2 PKCE flow for Google login, token storage, and logout, while keeping email allowlist and optional server sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 20:48:20 +09:00

1257 lines
41 KiB
JavaScript

(() => {
"use strict";
// If index.html inline fallback already booted, do nothing.
if (globalThis.__LINKS_APP_BOOTED__) return;
// Mark boot so index.html fallback won't run
globalThis.__LINKS_APP_BOOTED__ = true;
const STORAGE_KEY = "links_home_v1";
const THEME_KEY = "links_home_theme_v1";
const AUTH_TOAST_ONCE_KEY = "links_home_auth_toast_once_v1";
const AUTH_OVERRIDE_KEY = "links_home_auth_override_v1";
const AUTH_SETUP_SHOWN_KEY = "links_home_auth_setup_shown_v1";
const AUTH_PKCE_KEY = "links_home_auth_pkce_v1";
const AUTH_TOKEN_KEY = "links_home_auth_tokens_v1";
const el = {
subtitle: document.getElementById("subtitle"),
q: document.getElementById("q"),
sort: document.getElementById("sort"),
onlyFav: document.getElementById("onlyFav"),
meta: document.getElementById("meta"),
grid: document.getElementById("grid"),
empty: document.getElementById("empty"),
btnAdd: document.getElementById("btnAdd"),
btnImport: document.getElementById("btnImport"),
btnExport: document.getElementById("btnExport"),
btnTheme: document.getElementById("btnTheme"),
user: document.getElementById("user"),
userText: document.getElementById("userText"),
btnLogout: document.getElementById("btnLogout"),
snsLogin: document.getElementById("snsLogin"),
btnGoogle: document.getElementById("btnGoogle"),
authModal: document.getElementById("authModal"),
btnAuthClose: document.getElementById("btnAuthClose"),
authForm: document.getElementById("authForm"),
authDomain: document.getElementById("authDomain"),
authClientId: document.getElementById("authClientId"),
authAllowedEmails: document.getElementById("authAllowedEmails"),
authConnGoogle: document.getElementById("authConnGoogle"),
btnAuthReset: document.getElementById("btnAuthReset"),
modal: document.getElementById("modal"),
btnClose: document.getElementById("btnClose"),
btnCancel: document.getElementById("btnCancel"),
form: document.getElementById("form"),
id: document.getElementById("id"),
title: document.getElementById("title"),
url: document.getElementById("url"),
description: document.getElementById("description"),
tags: document.getElementById("tags"),
favorite: document.getElementById("favorite"),
file: document.getElementById("file"),
toast: document.getElementById("toast"),
};
// NOTE:
// 예전에는 links.json을 못 읽는 환경(file:// 등)에서 "내장 기본 목록"으로 조용히 대체했는데,
// 그러면 links.json의 순서/내용 변경이 반영되지 않아 혼란이 생깁니다.
// 이제는 links.json 로드를 우선하며, 실패 시 경고를 띄우고 빈 목록(또는 localStorage 커스텀)으로 동작합니다.
const DEFAULT_LINKS_INLINE = [];
const state = {
baseLinks: [],
baseOrder: new Map(),
store: loadStore(),
query: "",
sortKey: "json",
onlyFav: false,
canManage: false,
};
const auth = {
client: null,
user: null,
authorized: false,
ready: false,
mode: "disabled", // enabled | misconfigured | sdk_missing | disabled
serverCanManage: null,
};
function nowIso() {
return new Date().toISOString();
}
function safeJsonParse(s, fallback) {
try {
return JSON.parse(s);
} catch {
return fallback;
}
}
function loadStore() {
const raw = localStorage.getItem(STORAGE_KEY);
const data = raw ? safeJsonParse(raw, null) : null;
const store = {
overridesById: {},
tombstones: [],
custom: [],
};
if (!data || typeof data !== "object") return store;
if (data.overridesById && typeof data.overridesById === "object") store.overridesById = data.overridesById;
if (Array.isArray(data.tombstones)) store.tombstones = data.tombstones;
if (Array.isArray(data.custom)) store.custom = data.custom;
return store;
}
function saveStore() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.store));
}
function normalizeUrl(url) {
const u = String(url || "").trim();
if (!u) return "";
if (/^https?:\/\//i.test(u)) return u;
return "https://" + u;
}
function normalizeTags(tagsText) {
if (!tagsText) return [];
return String(tagsText)
.split(",")
.map((t) => t.trim())
.filter(Boolean)
.slice(0, 12);
}
function getDomain(url) {
try {
return new URL(url).host;
} catch {
return url.replace(/^https?:\/\//i, "").split("/")[0] || "";
}
}
function faviconUrl(url) {
try {
const u = new URL(url);
return `${u.origin}/favicon.ico`;
} catch {
return "";
}
}
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function idFromUrl(url) {
const d = getDomain(url).toLowerCase();
const cleaned = d.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return cleaned || "link";
}
function newId(prefix = "custom") {
if (globalThis.crypto && crypto.randomUUID) return `${prefix}-${crypto.randomUUID()}`;
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function normalizeLink(link) {
const url = normalizeUrl(link.url);
const id = String(link.id || "").trim() || idFromUrl(url) || newId("link");
const title = String(link.title || "").trim() || getDomain(url) || "Link";
const description = String(link.description || "").trim();
const tags = Array.isArray(link.tags) ? link.tags.map((t) => String(t).trim()).filter(Boolean) : [];
const favorite = Boolean(link.favorite);
const createdAt = String(link.createdAt || nowIso());
const updatedAt = String(link.updatedAt || createdAt);
return { id, title, url, description, tags, favorite, createdAt, updatedAt };
}
function getMergedLinks() {
const tomb = new Set(state.store.tombstones || []);
const overrides = state.store.overridesById || {};
const byId = new Map();
for (const base of state.baseLinks) {
if (!base || !base.id) continue;
if (tomb.has(base.id)) continue;
const o = overrides[base.id];
byId.set(base.id, { ...base, ...(o || {}) });
}
for (const c of state.store.custom || []) {
const n = normalizeLink(c);
byId.set(n.id, n);
}
return [...byId.values()];
}
function matchesQuery(link, q) {
if (!q) return true;
const hay = [
link.title,
link.url,
getDomain(link.url),
link.description || "",
(link.tags || []).join(" "),
]
.join(" ")
.toLowerCase();
return hay.includes(q);
}
function toTime(s) {
const t = Date.parse(String(s || ""));
return Number.isFinite(t) ? t : 0;
}
function orderKey(link) {
const idx = state.baseOrder.get(link.id);
if (typeof idx === "number") return idx;
// custom/imported links go after base list, in creation order
return 1_000_000 + toTime(link.createdAt);
}
function compareLinks(a, b) {
const key = state.sortKey;
if (key === "json") {
const oa = orderKey(a);
const ob = orderKey(b);
if (oa !== ob) return oa - ob;
return a.title.localeCompare(b.title, "ko");
}
if (key === "favorite") {
if (a.favorite !== b.favorite) return a.favorite ? -1 : 1;
// tie-breaker: keep json order
const oa = orderKey(a);
const ob = orderKey(b);
if (oa !== ob) return oa - ob;
}
if (key === "name") return a.title.localeCompare(b.title, "ko");
if (key === "domain") return getDomain(a.url).localeCompare(getDomain(b.url), "en");
// recent (default)
return String(b.updatedAt).localeCompare(String(a.updatedAt));
}
function render() {
const q = state.query.trim().toLowerCase();
const all = getMergedLinks();
const filtered = all
.filter((l) => (state.onlyFav ? l.favorite : true))
.filter((l) => matchesQuery(l, q))
.sort(compareLinks);
el.grid.innerHTML = filtered.map(cardHtml).join("");
el.empty.hidden = filtered.length !== 0;
const favCount = all.filter((l) => l.favorite).length;
el.meta.textContent = `표시 ${filtered.length}개 · 전체 ${all.length}개 · 즐겨찾기 ${favCount}`;
el.subtitle.textContent = all.length ? `링크 ${all.length}` : "개인 링크 관리";
}
function cardHtml(link) {
const domain = escapeHtml(getDomain(link.url));
const title = escapeHtml(link.title);
const desc = escapeHtml(link.description || "");
const url = escapeHtml(link.url);
const starClass = link.favorite ? "star on" : "star";
const tags = (link.tags || []).slice(0, 8);
const tagHtml = [
link.favorite ? `<span class="tag fav">★ 즐겨찾기</span>` : "",
...tags.map((t) => `<span class="tag">#${escapeHtml(t)}</span>`),
]
.filter(Boolean)
.join("");
const letter = escapeHtml((link.title || domain || "L").trim().slice(0, 1).toUpperCase());
const lockAttr = state.canManage ? "" : ' disabled aria-disabled="true"';
const lockTitle = state.canManage ? "" : ' title="관리 기능은 로그인 후 사용 가능합니다."';
const fav = faviconUrl(link.url);
return `
<article class="card" data-id="${escapeHtml(link.id)}">
<div class="card-head">
<div class="card-title">
<div class="favicon" aria-hidden="true">
${
fav
? `<img src="${escapeHtml(fav)}" alt="" loading="lazy" decoding="async" referrerpolicy="no-referrer" onerror="const p=this.parentNode; this.remove(); if(p) p.insertAdjacentHTML('beforeend','<div class=&quot;letter&quot;>${letter}</div>');" />`
: `<div class="letter">${letter}</div>`
}
</div>
<div class="title-wrap">
<div class="title" title="${title}">${title}</div>
<div class="domain" title="${domain}">${domain}</div>
</div>
</div>
<button class="icon-btn" type="button" data-act="fav"${lockAttr}${lockTitle}>
<span class="${starClass}" aria-hidden="true">★</span>
</button>
</div>
<div class="card-desc">${desc || "&nbsp;"}</div>
<div class="tags">${tagHtml || ""}</div>
<div class="card-actions">
<a class="btn mini" href="${url}" target="_blank" rel="noopener noreferrer" data-act="open">열기</a>
<button class="btn mini" type="button" data-act="copy">URL 복사</button>
<button class="btn mini" type="button" data-act="edit"${lockAttr}${lockTitle}>편집</button>
<button class="btn mini mini-danger" type="button" data-act="del"${lockAttr}${lockTitle}>삭제</button>
</div>
</article>
`;
}
function openModal(mode, link) {
el.modal.hidden = false;
document.body.style.overflow = "hidden";
const isEdit = mode === "edit";
document.getElementById("modalTitle").textContent = isEdit ? "링크 편집" : "링크 추가";
el.id.value = isEdit ? link.id : "";
el.title.value = isEdit ? link.title : "";
el.url.value = isEdit ? link.url : "";
el.description.value = isEdit ? link.description || "" : "";
el.tags.value = isEdit ? (link.tags || []).join(", ") : "";
el.favorite.checked = isEdit ? Boolean(link.favorite) : false;
setTimeout(() => el.title.focus(), 0);
}
function closeModal() {
el.modal.hidden = true;
document.body.style.overflow = "";
el.form.reset();
el.id.value = "";
}
function getLinkById(id) {
return getMergedLinks().find((l) => l.id === id) || null;
}
function isBaseId(id) {
return state.baseLinks.some((l) => l.id === id);
}
function setOverride(id, patch) {
state.store.overridesById[id] = { ...(state.store.overridesById[id] || {}), ...patch };
saveStore();
}
function removeOverride(id) {
if (state.store.overridesById && state.store.overridesById[id]) {
delete state.store.overridesById[id];
}
saveStore();
}
function toast(msg) {
el.toast.textContent = msg;
el.toast.hidden = false;
clearTimeout(toast._t);
toast._t = setTimeout(() => {
el.toast.hidden = true;
}, 2400);
}
function toastOnce(key, msg) {
const k = `${AUTH_TOAST_ONCE_KEY}:${key}`;
if (localStorage.getItem(k)) return;
localStorage.setItem(k, "1");
toast(msg);
}
function loadAuthOverride() {
const raw = localStorage.getItem(AUTH_OVERRIDE_KEY);
const data = raw ? safeJsonParse(raw, null) : null;
if (!data || typeof data !== "object") return null;
const auth0 = data.auth0 && typeof data.auth0 === "object" ? data.auth0 : {};
const allowedEmails = Array.isArray(data.allowedEmails) ? data.allowedEmails : [];
return {
auth0: {
domain: String(auth0.domain || "").trim(),
clientId: String(auth0.clientId || "").trim(),
},
connections:
data.connections && typeof data.connections === "object"
? {
google: String(data.connections.google || "").trim(),
// legacy keys kept for backward compatibility
kakao: String(data.connections.kakao || "").trim(),
naver: String(data.connections.naver || "").trim(),
}
: { google: "", kakao: "", naver: "" },
allowedEmails: allowedEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
};
}
function saveAuthOverride(cfg) {
localStorage.setItem(AUTH_OVERRIDE_KEY, JSON.stringify(cfg));
}
function clearAuthOverride() {
localStorage.removeItem(AUTH_OVERRIDE_KEY);
}
function getAuthConfig() {
const cfg = globalThis.AUTH_CONFIG && typeof globalThis.AUTH_CONFIG === "object" ? globalThis.AUTH_CONFIG : {};
const allowEndUserConfig = Boolean(cfg.allowEndUserConfig);
const auth0 = cfg.auth0 && typeof cfg.auth0 === "object" ? cfg.auth0 : {};
const allowedEmails = Array.isArray(cfg.allowedEmails) ? cfg.allowedEmails : [];
const base = {
allowEndUserConfig,
auth0: {
domain: String(auth0.domain || "").trim(),
clientId: String(auth0.clientId || "").trim(),
},
connections: {
google: "",
kakao: "",
naver: "",
},
allowedEmails: allowedEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean),
};
const override = loadAuthOverride();
if (!override) return base;
// override가 있으면 우선 적용 (서버 재배포 없이 테스트 가능)
return {
allowEndUserConfig,
auth0: {
domain: override.auth0.domain || base.auth0.domain,
clientId: override.auth0.clientId || base.auth0.clientId,
},
connections: {
google: override.connections?.google || "",
kakao: override.connections?.kakao || "",
naver: override.connections?.naver || "",
},
allowedEmails: override.allowedEmails.length ? override.allowedEmails : base.allowedEmails,
};
}
function currentUrlNoQuery() {
// Auth0 callback 후 URL 정리용
const u = new URL(location.href);
u.searchParams.delete("code");
u.searchParams.delete("state");
return u.toString();
}
function redirectUri() {
return location.origin === "null" ? location.href : location.origin + location.pathname;
}
function b64urlFromBytes(bytes) {
let bin = "";
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
const b64 = btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
return b64;
}
async function sha256Bytes(input) {
const data = new TextEncoder().encode(String(input));
const hash = await crypto.subtle.digest("SHA-256", data);
return new Uint8Array(hash);
}
function randomString(len = 43) {
const bytes = new Uint8Array(len);
crypto.getRandomValues(bytes);
return b64urlFromBytes(bytes);
}
function decodeJwtPayload(token) {
try {
const parts = String(token || "").split(".");
if (parts.length < 2) return null;
const b64 = parts[1].replaceAll("-", "+").replaceAll("_", "/");
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
const json = atob(b64 + pad);
return safeJsonParse(json, null);
} catch {
return null;
}
}
function loadTokens() {
const raw = localStorage.getItem(AUTH_TOKEN_KEY);
const data = raw ? safeJsonParse(raw, null) : null;
if (!data || typeof data !== "object") return null;
return {
id_token: typeof data.id_token === "string" ? data.id_token : "",
access_token: typeof data.access_token === "string" ? data.access_token : "",
received_at: typeof data.received_at === "string" ? data.received_at : "",
expires_in: typeof data.expires_in === "number" ? data.expires_in : 0,
};
}
function saveTokens(t) {
localStorage.setItem(
AUTH_TOKEN_KEY,
JSON.stringify({
id_token: t.id_token || "",
access_token: t.access_token || "",
expires_in: t.expires_in || 0,
received_at: nowIso(),
})
);
}
function clearTokens() {
localStorage.removeItem(AUTH_TOKEN_KEY);
}
async function manualAuthorize(connection) {
const cfg = getAuthConfig();
if (!cfg.auth0.domain || !cfg.auth0.clientId) {
openAuthModal();
return;
}
if (!globalThis.crypto || !crypto.subtle) {
toast("이 브라우저는 보안 로그인(PKCE)을 지원하지 않습니다.");
return;
}
const verifier = randomString(64);
const challenge = b64urlFromBytes(await sha256Bytes(verifier));
const state = randomString(24);
sessionStorage.setItem(
AUTH_PKCE_KEY,
JSON.stringify({
verifier,
state,
redirect_uri: redirectUri(),
created_at: nowIso(),
})
);
const u = new URL(`https://${cfg.auth0.domain}/authorize`);
u.searchParams.set("response_type", "code");
u.searchParams.set("client_id", cfg.auth0.clientId);
u.searchParams.set("redirect_uri", redirectUri());
u.searchParams.set("scope", "openid profile email");
u.searchParams.set("state", state);
u.searchParams.set("code_challenge", challenge);
u.searchParams.set("code_challenge_method", "S256");
if (connection) u.searchParams.set("connection", connection);
location.assign(u.toString());
}
async function manualHandleCallbackIfNeeded() {
const cfg = getAuthConfig();
const u = new URL(location.href);
const code = u.searchParams.get("code") || "";
const stateParam = u.searchParams.get("state") || "";
if (!code || !stateParam) return null;
const raw = sessionStorage.getItem(AUTH_PKCE_KEY);
const pkce = raw ? safeJsonParse(raw, null) : null;
if (!pkce || pkce.state !== stateParam) {
toast("로그인 상태값(state)이 일치하지 않습니다. 다시 시도하세요.");
return null;
}
const body = new URLSearchParams();
body.set("grant_type", "authorization_code");
body.set("client_id", cfg.auth0.clientId);
body.set("code_verifier", pkce.verifier);
body.set("code", code);
body.set("redirect_uri", pkce.redirect_uri || redirectUri());
const tokenRes = await fetch(`https://${cfg.auth0.domain}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!tokenRes.ok) {
toast("토큰 발급에 실패했습니다. Auth0 Callback URL 설정을 확인하세요.");
return null;
}
const tokenJson = await tokenRes.json();
const idToken = String(tokenJson.id_token || "");
const accessToken = String(tokenJson.access_token || "");
const expiresIn = Number(tokenJson.expires_in || 0) || 0;
if (!idToken) {
toast("ID 토큰이 없습니다. 로그인 설정을 확인하세요.");
return null;
}
saveTokens({ id_token: idToken, access_token: accessToken, expires_in: expiresIn });
sessionStorage.removeItem(AUTH_PKCE_KEY);
history.replaceState({}, document.title, currentUrlNoQuery());
return { idToken, accessToken };
}
async function manualLoadUser() {
const t = loadTokens();
if (!t || !t.id_token) return null;
const payload = decodeJwtPayload(t.id_token);
if (!payload) return null;
// 최소 표시/권한 확인용 user shape
return {
sub: payload.sub,
email: payload.email,
name: payload.name,
picture: payload.picture,
};
}
async function syncUserToServerWithIdToken(idToken) {
try {
const cfg = getAuthConfig();
const r = await fetch("/api/auth/sync", {
method: "POST",
headers: {
Authorization: `Bearer ${idToken}`,
"X-Auth0-Issuer": `https://${cfg.auth0.domain}/`,
"X-Auth0-ClientId": cfg.auth0.clientId,
},
});
if (!r.ok) return null;
const data = await r.json();
if (data && data.ok) return Boolean(data.canManage);
return null;
} catch {
return null;
}
}
function isAllowedEmail(email) {
const { allowedEmails } = getAuthConfig();
if (!allowedEmails.length) return true; // 설정이 비어있으면 로그인만으로 허용
const e = String(email || "").trim().toLowerCase();
return allowedEmails.includes(e);
}
function updateAuthUi() {
// 로그인 전에는 사용자 배지를 숨김(요청: "로그인 설정 필요" 영역 제거)
if (!auth.user) {
el.user.hidden = true;
} else {
el.user.hidden = false;
const email = auth.user && auth.user.email ? String(auth.user.email) : "";
const name = auth.user && auth.user.name ? String(auth.user.name) : "";
const label = email ? email : name ? name : "로그인됨";
el.userText.textContent = label;
if (auth.authorized) el.user.setAttribute("data-auth", "ok");
else el.user.removeAttribute("data-auth");
}
// 로그아웃은 로그인 된 이후에만 노출
el.btnLogout.hidden = !auth.user;
el.btnLogout.disabled = false;
// 간편 로그인 버튼 노출
// - 설정 전(로그인 설정 필요)에도 디자인이 보이도록 영역은 항상 노출
// - connection이 없으면 클릭 시 설정 모달을 띄움(핸들러에서 처리)
const cfg = getAuthConfig();
const showQuick = !auth.user;
if (el.snsLogin) el.snsLogin.hidden = !showQuick;
if (el.btnGoogle) {
el.btnGoogle.hidden = !showQuick;
el.btnGoogle.classList.toggle("is-disabled", !cfg.connections.google);
}
}
function applyManageLock() {
// AUTH_CONFIG가 없는 상태에서는 기존처럼 자유롭게 관리 가능.
// 로그인 기능이 "enabled"일 때만 관리 잠금을 적용합니다.
state.canManage = auth.mode === "enabled" ? Boolean(auth.user && auth.authorized) : true;
const lockMsg = "관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.";
el.btnAdd.disabled = !state.canManage;
el.btnImport.disabled = !state.canManage;
if (!state.canManage) {
el.btnAdd.title = lockMsg;
el.btnImport.title = lockMsg;
} else {
el.btnAdd.title = "";
el.btnImport.title = "";
}
}
async function initAuth() {
auth.ready = true;
const cfg = getAuthConfig();
const hasAuth0 = cfg.auth0.domain && cfg.auth0.clientId;
const hasSdk = typeof globalThis.createAuth0Client === "function";
if (!hasAuth0) {
// 설정이 없으면: 로그인 비활성(운영자 설정 필요), 관리 기능은 잠그지 않음
auth.client = null;
auth.user = null;
auth.authorized = false;
auth.mode = "misconfigured";
updateAuthUi();
applyManageLock();
// 브라우저당 1회: 설정 모달 자동 오픈
if (!localStorage.getItem(AUTH_SETUP_SHOWN_KEY)) {
localStorage.setItem(AUTH_SETUP_SHOWN_KEY, "1");
setTimeout(() => openAuthModal(), 80);
}
return;
}
if (!hasSdk) {
// SDK가 없어도 PKCE 수동 로그인으로 동작 가능
auth.client = null;
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) && isAllowedEmail(email);
auth.serverCanManage = null;
const t = loadTokens();
if (auth.user && t && t.id_token) {
const can = await syncUserToServerWithIdToken(t.id_token);
if (typeof can === "boolean") {
auth.serverCanManage = can;
auth.authorized = can;
}
}
updateAuthUi();
applyManageLock();
return;
}
if (location.protocol === "file:") {
toastOnce("file", "소셜 로그인은 보통 HTTPS 사이트에서만 동작합니다. (file://에서는 제한될 수 있어요)");
}
try {
auth.client = await createAuth0Client({
domain: cfg.auth0.domain,
clientId: cfg.auth0.clientId,
authorizationParams: {
redirect_uri: redirectUri(),
},
cacheLocation: "localstorage",
useRefreshTokens: true,
});
auth.mode = "enabled";
} catch {
auth.client = null;
auth.user = null;
auth.authorized = false;
auth.mode = "sdk_missing";
toastOnce("authinit", "로그인 초기화에 실패했습니다. AUTH_CONFIG 값과 Auth0 설정(Callback URL)을 확인하세요.");
updateAuthUi();
applyManageLock();
return;
}
const u = new URL(location.href);
const isCallback = u.searchParams.has("code") && u.searchParams.has("state");
if (isCallback) {
try {
await auth.client.handleRedirectCallback();
} finally {
history.replaceState({}, document.title, currentUrlNoQuery());
}
}
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) && isAllowedEmail(email);
auth.serverCanManage = null;
// 로그인되었으면 서버에 사용자 upsert 및 can_manage 동기화(서버가 있을 때만)
if (auth.user) {
try {
const claims = await auth.client.getIdTokenClaims();
const raw = claims && claims.__raw ? String(claims.__raw) : "";
if (raw) {
const cfg = getAuthConfig();
const r = await fetch("/api/auth/sync", {
method: "POST",
headers: {
Authorization: `Bearer ${raw}`,
"X-Auth0-Issuer": `https://${cfg.auth0.domain}/`,
"X-Auth0-ClientId": cfg.auth0.clientId,
},
});
if (r.ok) {
const data = await r.json();
if (data && data.ok) {
auth.serverCanManage = Boolean(data.canManage);
auth.authorized = auth.serverCanManage;
}
}
}
} catch {
// ignore: server not running or blocked
}
}
if (auth.user && !auth.authorized) {
toastOnce("deny", "로그인은 되었지만 허용 이메일이 아니라서 관리 기능이 잠금 상태입니다.");
} else if (auth.user && getAuthConfig().allowedEmails.length === 0) {
toastOnce("allowall", "주의: 허용 이메일 목록이 비어있어서 로그인한 모든 계정이 관리 가능 상태입니다.");
}
updateAuthUi();
applyManageLock();
}
async function login() {
if (auth.mode !== "enabled" || !auth.client) {
openAuthModal();
return;
}
await auth.client.loginWithRedirect();
}
async function loginWithConnection(connection) {
if (auth.mode !== "enabled") {
openAuthModal();
return;
}
if (auth.client) {
await auth.client.loginWithRedirect({
authorizationParams: { connection },
});
return;
}
await manualAuthorize(connection);
}
async function logout() {
if (auth.mode !== "enabled") return;
// SDK가 있으면 SDK로, 없으면 수동 로그아웃
if (auth.client) {
auth.user = null;
auth.authorized = false;
updateAuthUi();
applyManageLock();
await auth.client.logout({
logoutParams: {
returnTo: redirectUri(),
},
});
return;
}
clearTokens();
auth.user = null;
auth.authorized = false;
updateAuthUi();
applyManageLock();
const cfg = getAuthConfig();
const u = new URL(`https://${cfg.auth0.domain}/v2/logout`);
u.searchParams.set("client_id", cfg.auth0.clientId);
u.searchParams.set("returnTo", redirectUri());
location.assign(u.toString());
}
function openAuthModal() {
if (!el.authModal || !el.authForm) {
toast("로그인 설정 UI를 찾지 못했습니다. 새로고침 후 다시 시도하세요.");
return;
}
const cfg = getAuthConfig();
el.authDomain.value = cfg.auth0.domain || "";
el.authClientId.value = cfg.auth0.clientId || "";
el.authAllowedEmails.value = (cfg.allowedEmails || []).join(", ");
if (el.authConnGoogle) el.authConnGoogle.value = cfg.connections.google || "";
el.authModal.hidden = false;
document.body.style.overflow = "hidden";
setTimeout(() => el.authDomain.focus(), 0);
}
function closeAuthModal() {
if (!el.authModal) return;
el.authModal.hidden = true;
document.body.style.overflow = "";
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
toast("복사했습니다.");
} catch {
// fallback
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "readonly");
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
ta.remove();
toast("복사했습니다.");
}
}
function upsertCustom(link) {
const n = normalizeLink(link);
const idx = state.store.custom.findIndex((c) => c && c.id === n.id);
if (idx >= 0) state.store.custom[idx] = n;
else state.store.custom.push(n);
saveStore();
}
function deleteLink(id) {
if (isBaseId(id)) {
const s = new Set(state.store.tombstones || []);
s.add(id);
state.store.tombstones = [...s];
removeOverride(id);
saveStore();
toast("기본 링크를 숨겼습니다.");
return;
}
const before = state.store.custom.length;
state.store.custom = (state.store.custom || []).filter((c) => c && c.id !== id);
saveStore();
if (state.store.custom.length !== before) toast("삭제했습니다.");
}
function toggleFavorite(id) {
const link = getLinkById(id);
if (!link) return;
const next = !link.favorite;
if (isBaseId(id)) {
setOverride(id, { favorite: next, updatedAt: nowIso() });
} else {
upsertCustom({ ...link, favorite: next, updatedAt: nowIso() });
}
render();
}
function editLink(id) {
const link = getLinkById(id);
if (!link) return;
openModal("edit", link);
}
async function loadBaseLinks() {
// 1) index.html 내부 내장 데이터(서버 없이도 동작)
const dataEl = document.getElementById("linksData");
if (dataEl && dataEl.textContent) {
const parsed = safeJsonParse(dataEl.textContent, null);
if (Array.isArray(parsed)) return parsed.map(normalizeLink);
}
// 2) 동일 디렉토리의 links.json (서버 환경에서 권장)
const candidates = [
new URL("./links.json", document.baseURI).toString(),
new URL("links.json", document.baseURI).toString(),
];
for (const url of candidates) {
try {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) continue;
const data = await res.json();
if (!Array.isArray(data)) continue;
return data.map(normalizeLink);
} catch {
// try next
}
}
const hint =
location.protocol === "file:"
? "기본 링크 데이터가 없습니다. index.html의 linksData를 확인하세요."
: "links.json을 불러오지 못했습니다. 배포 경로에 links.json이 있는지 확인하세요.";
toast(hint);
return DEFAULT_LINKS_INLINE.map(normalizeLink);
}
function applyTheme(theme) {
const t = theme === "light" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", t);
el.btnTheme.setAttribute("aria-pressed", t === "dark" ? "true" : "false");
localStorage.setItem(THEME_KEY, t);
}
function initTheme() {
const saved = localStorage.getItem(THEME_KEY);
if (saved === "light" || saved === "dark") return applyTheme(saved);
const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
applyTheme(prefersLight ? "light" : "dark");
}
function exportJson() {
const data = getMergedLinks().sort((a, b) => a.title.localeCompare(b.title, "ko"));
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `links-export-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 0);
toast("내보내기 파일을 생성했습니다.");
}
function importJsonText(text) {
const parsed = safeJsonParse(text, null);
if (!parsed) throw new Error("JSON 파싱 실패");
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.links) ? parsed.links : null;
if (!list) throw new Error("JSON 형식이 올바르지 않습니다. (배열 또는 {links:[...]} )");
const merged = getMergedLinks();
const used = new Set(merged.map((l) => l.id));
let added = 0;
for (const item of list) {
if (!item) continue;
const n0 = normalizeLink(item);
let n = n0;
if (used.has(n.id)) {
n = { ...n, id: newId("import"), createdAt: nowIso(), updatedAt: nowIso() };
}
used.add(n.id);
// 가져오기는 custom로 추가(기본과 충돌 방지)
state.store.custom.push(n);
added++;
}
saveStore();
toast(`가져오기 완료: ${added}`);
}
function onGridClick(e) {
const btn = e.target.closest("[data-act]");
if (!btn) return;
const card = e.target.closest(".card");
if (!card) return;
const id = card.getAttribute("data-id");
if (!id) return;
const act = btn.getAttribute("data-act");
if (auth.mode === "enabled" && !state.canManage && (act === "fav" || act === "edit" || act === "del")) {
toast("관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.");
return;
}
if (act === "fav") {
toggleFavorite(id);
return;
}
if (act === "copy") {
const link = getLinkById(id);
if (link) copyText(link.url);
return;
}
if (act === "edit") {
editLink(id);
return;
}
if (act === "del") {
const link = getLinkById(id);
const name = link ? link.title : id;
if (confirm(`삭제할까요?\n\n- ${name}`)) {
deleteLink(id);
render();
}
return;
}
}
function onFormSubmit(e) {
e.preventDefault();
const isEdit = Boolean(el.id.value);
const title = String(el.title.value || "").trim();
const url = normalizeUrl(el.url.value);
if (!title) return toast("제목을 입력하세요.");
if (!url) return toast("URL을 입력하세요.");
let parsed;
try {
parsed = new URL(url);
} catch {
return toast("URL 형식이 올바르지 않습니다.");
}
if (!/^https?:$/.test(parsed.protocol)) return toast("http/https URL만 지원합니다.");
const description = String(el.description.value || "").trim();
const tags = normalizeTags(el.tags.value);
const favorite = Boolean(el.favorite.checked);
if (isEdit) {
const id = el.id.value;
const current = getLinkById(id);
if (!current) {
closeModal();
toast("편집 대상이 없습니다.");
return;
}
const patch = {
title,
url,
description,
tags,
favorite,
updatedAt: nowIso(),
};
if (isBaseId(id)) setOverride(id, patch);
else upsertCustom({ ...current, ...patch });
closeModal();
render();
toast("저장했습니다.");
return;
}
const id = newId("custom");
upsertCustom({
id,
title,
url,
description,
tags,
favorite,
createdAt: nowIso(),
updatedAt: nowIso(),
});
closeModal();
render();
toast("추가했습니다.");
}
function wire() {
el.q.addEventListener("input", () => {
state.query = el.q.value || "";
render();
});
el.sort.addEventListener("change", () => {
state.sortKey = el.sort.value || "json";
render();
});
el.onlyFav.addEventListener("change", () => {
state.onlyFav = Boolean(el.onlyFav.checked);
render();
});
el.grid.addEventListener("click", onGridClick);
el.btnAdd.addEventListener("click", () => {
if (auth.mode === "enabled" && !state.canManage)
return toast("관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.");
openModal("add", null);
});
el.btnClose.addEventListener("click", closeModal);
el.btnCancel.addEventListener("click", closeModal);
el.modal.addEventListener("click", (e) => {
const close = e.target && e.target.getAttribute && e.target.getAttribute("data-close");
if (close) closeModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !el.modal.hidden) closeModal();
});
el.form.addEventListener("submit", onFormSubmit);
el.btnExport.addEventListener("click", exportJson);
el.btnImport.addEventListener("click", () => {
if (auth.mode === "enabled" && !state.canManage)
return toast("관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.");
el.file.click();
});
el.file.addEventListener("change", async () => {
const f = el.file.files && el.file.files[0];
el.file.value = "";
if (!f) return;
try {
const text = await f.text();
importJsonText(text);
render();
} catch (err) {
toast(String(err && err.message ? err.message : "가져오기 실패"));
}
});
el.btnTheme.addEventListener("click", () => {
const cur = document.documentElement.getAttribute("data-theme") || "dark";
applyTheme(cur === "dark" ? "light" : "dark");
});
if (el.btnLogout) el.btnLogout.addEventListener("click", () => logout().catch(() => toast("로그아웃에 실패했습니다.")));
if (el.btnGoogle)
el.btnGoogle.addEventListener("click", () => {
const c = getAuthConfig().connections.google;
if (!c) return openAuthModal();
return loginWithConnection(c).catch(() => toast("로그인에 실패했습니다."));
});
if (el.btnAuthClose) el.btnAuthClose.addEventListener("click", closeAuthModal);
if (el.authModal) {
el.authModal.addEventListener("click", (e) => {
const close = e.target && e.target.getAttribute && e.target.getAttribute("data-auth-close");
if (close) closeAuthModal();
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && el.authModal && !el.authModal.hidden) closeAuthModal();
});
if (el.btnAuthReset) {
el.btnAuthReset.addEventListener("click", () => {
clearAuthOverride();
toast("로그인 설정을 초기화했습니다.");
closeAuthModal();
// 초기화 후 재로딩(상태 정리)
setTimeout(() => location.reload(), 200);
});
}
if (el.authForm) {
el.authForm.addEventListener("submit", (e) => {
e.preventDefault();
const domain = String(el.authDomain.value || "").trim();
const clientId = String(el.authClientId.value || "").trim();
const emails = String(el.authAllowedEmails.value || "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
const connGoogle = el.authConnGoogle ? String(el.authConnGoogle.value || "").trim() : "";
if (!domain || !clientId) {
toast("Domain과 Client ID를 입력하세요.");
return;
}
saveAuthOverride({
auth0: { domain, clientId },
connections: { google: connGoogle },
allowedEmails: emails,
});
toast("저장했습니다. 페이지를 새로고침합니다.");
closeAuthModal();
setTimeout(() => location.reload(), 200);
});
}
}
async function main() {
initTheme();
wire();
await initAuth();
state.baseLinks = await loadBaseLinks();
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
el.sort.value = state.sortKey;
applyManageLock();
render();
}
main().catch(() => {
toast("초기화에 실패했습니다.");
});
})();