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}
-
`;
@@ -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);