diff --git a/README.md b/README.md index 12e1204..5f8cdae 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,22 @@ python3 -m http.server 8000 그 후 브라우저에서 `http://localhost:8000`으로 접속합니다. +## 로그인(관리 기능 잠금) + +이 프로젝트는 **정적 사이트**에서 동작하도록, 관리 기능(추가/편집/삭제/가져오기)을 **로그인 후(허용 이메일)** 에만 활성화할 수 있습니다. + +- **지원 방식**: Auth0 SPA SDK + Auth0 Universal Login +- **구글/카카오/네이버**: Auth0 대시보드에서 Social/Custom OAuth 연결로 구성합니다. + +설정 방법: + +1. Auth0에서 **Single Page Application** 생성 +2. `index.html`의 `window.AUTH_CONFIG`에 `domain`, `clientId` 입력 +3. Auth0 Application 설정에서 아래 URL들을 등록 + - Allowed Callback URLs: 사이트 주소 (예: `https://example.com/`) + - Allowed Logout URLs: 사이트 주소 (예: `https://example.com/`) +4. `allowedEmails`에 관리 허용 이메일 목록을 입력 + ## 데이터 저장 - 기본 링크: `links.json` diff --git a/index.html b/index.html index 6ef22ed..1b2d379 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,26 @@ NCue | 개인 링크 홈 + + + + @@ -47,6 +67,12 @@ + + + @@ -201,6 +227,16 @@ "createdAt": "2026-02-07T00:00:00.000Z", "updatedAt": "2026-02-07T00:00:00.000Z" }, + { + "id": "tts-ncue-net", + "title": "TTS", + "url": "https://tts.ncue.net/", + "description": "입력한 text를 mp3로 변환", + "tags": ["text", "mp3", "ncue"], + "favorite": false, + "createdAt": "2026-02-07T00:00:00.000Z", + "updatedAt": "2026-02-07T00:00:00.000Z" + }, { "id": "meeting-ncue-net", "title": "Meeting", diff --git a/links.json b/links.json index 524fa50..2c64f00 100644 --- a/links.json +++ b/links.json @@ -39,6 +39,17 @@ "createdAt": "2026-02-07T00:00:00.000Z", "updatedAt": "2026-02-07T00:00:00.000Z" }, + { + "id": "tts-ncue-net", + "title": "TTS", + "url": "https://tts.ncue.net/", + "description": "입력한 text를 mp3로 변환", + "tags": ["text", "mp3", "ncue"], + "favorite": false, + "createdAt": "2026-02-07T00:00:00.000Z", + "updatedAt": "2026-02-07T00:00:00.000Z" + }, + { "id": "meeting-ncue-net", "title": "Meeting", diff --git a/script.js b/script.js index 41b715d..fb96560 100644 --- a/script.js +++ b/script.js @@ -3,6 +3,7 @@ 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 el = { subtitle: document.getElementById("subtitle"), @@ -16,6 +17,10 @@ btnImport: document.getElementById("btnImport"), btnExport: document.getElementById("btnExport"), btnTheme: document.getElementById("btnTheme"), + user: document.getElementById("user"), + userText: document.getElementById("userText"), + btnLogin: document.getElementById("btnLogin"), + btnLogout: document.getElementById("btnLogout"), modal: document.getElementById("modal"), btnClose: document.getElementById("btnClose"), btnCancel: document.getElementById("btnCancel"), @@ -43,6 +48,15 @@ query: "", sortKey: "json", onlyFav: false, + canManage: false, + }; + + const auth = { + client: null, + user: null, + authorized: false, + ready: false, + mode: "disabled", // enabled | misconfigured | sdk_missing | disabled }; function nowIso() { @@ -233,6 +247,8 @@ .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="관리 기능은 로그인 후 사용 가능합니다."'; return `
@@ -246,7 +262,7 @@
${domain}
- @@ -257,8 +273,8 @@
열기 - - + +
`; @@ -316,6 +332,184 @@ }, 2400); } + function toastOnce(key, msg) { + const k = `${AUTH_TOAST_ONCE_KEY}:${key}`; + if (localStorage.getItem(k)) return; + localStorage.setItem(k, "1"); + toast(msg); + } + + function getAuthConfig() { + const cfg = globalThis.AUTH_CONFIG && typeof globalThis.AUTH_CONFIG === "object" ? globalThis.AUTH_CONFIG : {}; + const auth0 = cfg.auth0 && typeof cfg.auth0 === "object" ? cfg.auth0 : {}; + const allowedEmails = Array.isArray(cfg.allowedEmails) ? cfg.allowedEmails : []; + return { + auth0: { + domain: String(auth0.domain || "").trim(), + clientId: String(auth0.clientId || "").trim(), + }, + allowedEmails: allowedEmails.map((e) => String(e).trim().toLowerCase()).filter(Boolean), + }; + } + + function currentUrlNoQuery() { + // Auth0 callback 후 URL 정리용 + const u = new URL(location.href); + u.searchParams.delete("code"); + u.searchParams.delete("state"); + return u.toString(); + } + + function isAllowedEmail(email) { + const { allowedEmails } = getAuthConfig(); + if (!allowedEmails.length) return true; // 설정이 비어있으면 로그인만으로 허용 + const e = String(email || "").trim().toLowerCase(); + return allowedEmails.includes(e); + } + + function updateAuthUi() { + // 항상 상태를 보여주고, 버튼은 상태에 따라 비활성/숨김 처리 + 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) : ""; + let label = email ? email : name ? name : auth.user ? "로그인됨" : "로그인 필요"; + if (auth.mode === "misconfigured") label = "로그인 설정 필요"; + if (auth.mode === "sdk_missing") label = "로그인 SDK 로드 실패"; + el.userText.textContent = label; + if (auth.authorized) el.user.setAttribute("data-auth", "ok"); + else el.user.removeAttribute("data-auth"); + + // 로그인 기능이 활성(enabled)일 때만 로그인/로그아웃 버튼을 의미 있게 노출 + const enabled = auth.mode === "enabled"; + el.btnLogin.hidden = enabled ? Boolean(auth.user) : false; + el.btnLogout.hidden = !(enabled && auth.user); + // 설정/SDK 문제 상태에서도 버튼은 "클릭 가능"하게 두고, 클릭 시 토스트로 안내합니다. + el.btnLogin.disabled = false; + el.btnLogout.disabled = false; + } + + 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(); + toastOnce( + "authcfg", + "로그인 설정이 비어있습니다. index.html의 AUTH_CONFIG(auth0.domain/clientId, allowedEmails)를 채워주세요." + ); + return; + } + + if (!hasSdk) { + toastOnce("auth0sdk", "로그인 SDK 로드에 실패했습니다. 네트워크/차단 설정을 확인하세요."); + auth.client = null; + auth.user = null; + auth.authorized = false; + auth.mode = "sdk_missing"; + 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: location.origin === "null" ? location.href : location.origin + location.pathname, + }, + 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); + + 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) { + toast("로그인 설정이 필요합니다. index.html의 AUTH_CONFIG(auth0.domain/clientId, allowedEmails)를 확인하세요."); + return; + } + await auth.client.loginWithRedirect(); + } + + async function logout() { + if (auth.mode !== "enabled" || !auth.client) return; + auth.user = null; + auth.authorized = false; + updateAuthUi(); + applyManageLock(); + await auth.client.logout({ + logoutParams: { + returnTo: location.origin === "null" ? location.href : location.origin + location.pathname, + }, + }); + } + async function copyText(text) { try { await navigator.clipboard.writeText(text); @@ -476,6 +670,10 @@ 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; @@ -579,7 +777,11 @@ el.grid.addEventListener("click", onGridClick); - el.btnAdd.addEventListener("click", () => openModal("add", null)); + 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) => { @@ -593,7 +795,11 @@ el.form.addEventListener("submit", onFormSubmit); el.btnExport.addEventListener("click", exportJson); - el.btnImport.addEventListener("click", () => el.file.click()); + 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 = ""; @@ -611,14 +817,19 @@ const cur = document.documentElement.getAttribute("data-theme") || "dark"; applyTheme(cur === "dark" ? "light" : "dark"); }); + + if (el.btnLogin) el.btnLogin.addEventListener("click", () => login().catch(() => toast("로그인에 실패했습니다."))); + if (el.btnLogout) el.btnLogout.addEventListener("click", () => logout().catch(() => toast("로그아웃에 실패했습니다."))); } 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(); } diff --git a/styles.css b/styles.css index e5e1356..f594daf 100644 --- a/styles.css +++ b/styles.css @@ -138,6 +138,54 @@ html[data-theme="light"] .topbar { justify-content: flex-end; } +.user { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.03); + color: var(--muted); + font-size: 12px; + user-select: none; + max-width: 280px; +} + +.user-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.35); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.08); +} + +html[data-theme="light"] .user-dot { + background: rgba(0, 0, 0, 0.38); + box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.06); +} + +.user[data-auth="ok"] { + color: rgba(180, 255, 210, 0.9); + border-color: rgba(34, 197, 94, 0.28); + background: rgba(34, 197, 94, 0.06); +} + +html[data-theme="light"] .user[data-auth="ok"] { + color: rgba(0, 120, 70, 0.92); +} + +.user[data-auth="ok"] .user-dot { + background: rgba(34, 197, 94, 0.9); + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); +} + +.user-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .btn { appearance: none; border: 1px solid var(--border); @@ -155,6 +203,18 @@ html[data-theme="light"] .topbar { user-select: none; } +.btn[disabled], +.icon-btn[disabled] { + opacity: 0.55; + cursor: not-allowed; + transform: none !important; +} + +.btn[disabled]:hover, +.icon-btn[disabled]:hover { + background: var(--panel); +} + .btn:hover { background: var(--panel2); transform: translateY(-1px);