Add Auth0 login gate for admin actions

Show login status in header, guard manage actions behind allowed emails, and add Auth0 SPA SDK with CDN fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-02-07 17:53:59 +09:00
parent 02082eb16d
commit 97c8fe8069
5 changed files with 339 additions and 5 deletions

View File

@@ -16,6 +16,22 @@ python3 -m http.server 8000
그 후 브라우저에서 `http://localhost: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` - 기본 링크: `links.json`

View File

@@ -7,6 +7,26 @@
<title>NCue | 개인 링크 홈</title> <title>NCue | 개인 링크 홈</title>
<meta name="description" content="개인 서비스 링크를 모아 관리하는 홈 화면" /> <meta name="description" content="개인 서비스 링크를 모아 관리하는 홈 화면" />
<link rel="stylesheet" href="./styles.css" /> <link rel="stylesheet" href="./styles.css" />
<!-- Auth0 SPA SDK (정적 사이트용) -->
<!-- jsDelivr 차단/실패 시 unpkg로 자동 대체 -->
<script
src="https://cdn.jsdelivr.net/npm/@auth0/auth0-spa-js@2/dist/auth0-spa-js.production.js"
onerror="this.onerror=null;this.src='https://unpkg.com/@auth0/auth0-spa-js@2/dist/auth0-spa-js.production.js';"
></script>
<script>
// 로그인 설정 (관리 기능 잠금용)
// 1) Auth0에서 Application(SPA) 생성 후 domain/clientId를 입력하세요.
// 2) Allowed Callback URLs / Allowed Logout URLs에 현재 사이트 주소를 등록하세요.
// 예: https://drive.daewoongai.com/apps/dashboard/
window.AUTH_CONFIG = {
auth0: {
domain: "",
clientId: "",
},
// 관리 허용 이메일(대소문자 무시)
allowedEmails: [],
};
</script>
<script defer src="./script.js"></script> <script defer src="./script.js"></script>
</head> </head>
<body> <body>
@@ -47,6 +67,12 @@
<button class="btn" id="btnTheme" type="button" aria-pressed="false" title="테마 전환"> <button class="btn" id="btnTheme" type="button" aria-pressed="false" title="테마 전환">
테마 테마
</button> </button>
<div class="user" id="user" hidden>
<span class="user-dot" aria-hidden="true"></span>
<span class="user-text" id="userText">로그인 필요</span>
</div>
<button class="btn" id="btnLogin" type="button">로그인</button>
<button class="btn" id="btnLogout" type="button" hidden>로그아웃</button>
</div> </div>
</div> </div>
</header> </header>
@@ -201,6 +227,16 @@
"createdAt": "2026-02-07T00:00:00.000Z", "createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "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", "id": "meeting-ncue-net",
"title": "Meeting", "title": "Meeting",

View File

@@ -39,6 +39,17 @@
"createdAt": "2026-02-07T00:00:00.000Z", "createdAt": "2026-02-07T00:00:00.000Z",
"updatedAt": "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", "id": "meeting-ncue-net",
"title": "Meeting", "title": "Meeting",

221
script.js
View File

@@ -3,6 +3,7 @@
const STORAGE_KEY = "links_home_v1"; const STORAGE_KEY = "links_home_v1";
const THEME_KEY = "links_home_theme_v1"; const THEME_KEY = "links_home_theme_v1";
const AUTH_TOAST_ONCE_KEY = "links_home_auth_toast_once_v1";
const el = { const el = {
subtitle: document.getElementById("subtitle"), subtitle: document.getElementById("subtitle"),
@@ -16,6 +17,10 @@
btnImport: document.getElementById("btnImport"), btnImport: document.getElementById("btnImport"),
btnExport: document.getElementById("btnExport"), btnExport: document.getElementById("btnExport"),
btnTheme: document.getElementById("btnTheme"), btnTheme: document.getElementById("btnTheme"),
user: document.getElementById("user"),
userText: document.getElementById("userText"),
btnLogin: document.getElementById("btnLogin"),
btnLogout: document.getElementById("btnLogout"),
modal: document.getElementById("modal"), modal: document.getElementById("modal"),
btnClose: document.getElementById("btnClose"), btnClose: document.getElementById("btnClose"),
btnCancel: document.getElementById("btnCancel"), btnCancel: document.getElementById("btnCancel"),
@@ -43,6 +48,15 @@
query: "", query: "",
sortKey: "json", sortKey: "json",
onlyFav: false, onlyFav: false,
canManage: false,
};
const auth = {
client: null,
user: null,
authorized: false,
ready: false,
mode: "disabled", // enabled | misconfigured | sdk_missing | disabled
}; };
function nowIso() { function nowIso() {
@@ -233,6 +247,8 @@
.join(""); .join("");
const letter = escapeHtml((link.title || domain || "L").trim().slice(0, 1).toUpperCase()); 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 ` return `
<article class="card" data-id="${escapeHtml(link.id)}"> <article class="card" data-id="${escapeHtml(link.id)}">
@@ -246,7 +262,7 @@
<div class="domain" title="${domain}">${domain}</div> <div class="domain" title="${domain}">${domain}</div>
</div> </div>
</div> </div>
<button class="icon-btn" type="button" data-act="fav" title="즐겨찾기"> <button class="icon-btn" type="button" data-act="fav"${lockAttr}${lockTitle}>
<span class="${starClass}" aria-hidden="true">★</span> <span class="${starClass}" aria-hidden="true">★</span>
</button> </button>
</div> </div>
@@ -257,8 +273,8 @@
<div class="card-actions"> <div class="card-actions">
<a class="btn mini" href="${url}" target="_blank" rel="noopener noreferrer" data-act="open">열기</a> <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="copy">URL 복사</button>
<button class="btn mini" type="button" data-act="edit">편집</button> <button class="btn mini" type="button" data-act="edit"${lockAttr}${lockTitle}>편집</button>
<button class="btn mini mini-danger" type="button" data-act="del">삭제</button> <button class="btn mini mini-danger" type="button" data-act="del"${lockAttr}${lockTitle}>삭제</button>
</div> </div>
</article> </article>
`; `;
@@ -316,6 +332,184 @@
}, 2400); }, 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) { async function copyText(text) {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@@ -476,6 +670,10 @@
if (!id) return; if (!id) return;
const act = btn.getAttribute("data-act"); const act = btn.getAttribute("data-act");
if (auth.mode === "enabled" && !state.canManage && (act === "fav" || act === "edit" || act === "del")) {
toast("관리 기능은 로그인(허용 이메일) 후 사용 가능합니다.");
return;
}
if (act === "fav") { if (act === "fav") {
toggleFavorite(id); toggleFavorite(id);
return; return;
@@ -579,7 +777,11 @@
el.grid.addEventListener("click", onGridClick); 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.btnClose.addEventListener("click", closeModal);
el.btnCancel.addEventListener("click", closeModal); el.btnCancel.addEventListener("click", closeModal);
el.modal.addEventListener("click", (e) => { el.modal.addEventListener("click", (e) => {
@@ -593,7 +795,11 @@
el.form.addEventListener("submit", onFormSubmit); el.form.addEventListener("submit", onFormSubmit);
el.btnExport.addEventListener("click", exportJson); 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 () => { el.file.addEventListener("change", async () => {
const f = el.file.files && el.file.files[0]; const f = el.file.files && el.file.files[0];
el.file.value = ""; el.file.value = "";
@@ -611,14 +817,19 @@
const cur = document.documentElement.getAttribute("data-theme") || "dark"; const cur = document.documentElement.getAttribute("data-theme") || "dark";
applyTheme(cur === "dark" ? "light" : "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() { async function main() {
initTheme(); initTheme();
wire(); wire();
await initAuth();
state.baseLinks = await loadBaseLinks(); state.baseLinks = await loadBaseLinks();
state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i])); state.baseOrder = new Map(state.baseLinks.map((l, i) => [l.id, i]));
el.sort.value = state.sortKey; el.sort.value = state.sortKey;
applyManageLock();
render(); render();
} }

View File

@@ -138,6 +138,54 @@ html[data-theme="light"] .topbar {
justify-content: flex-end; 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 { .btn {
appearance: none; appearance: none;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -155,6 +203,18 @@ html[data-theme="light"] .topbar {
user-select: none; 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 { .btn:hover {
background: var(--panel2); background: var(--panel2);
transform: translateY(-1px); transform: translateY(-1px);