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:
16
README.md
16
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`
|
||||
|
||||
36
index.html
36
index.html
@@ -7,6 +7,26 @@
|
||||
<title>NCue | 개인 링크 홈</title>
|
||||
<meta name="description" content="개인 서비스 링크를 모아 관리하는 홈 화면" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
@@ -47,6 +67,12 @@
|
||||
<button class="btn" id="btnTheme" type="button" aria-pressed="false" title="테마 전환">
|
||||
테마
|
||||
</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>
|
||||
</header>
|
||||
@@ -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",
|
||||
|
||||
11
links.json
11
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",
|
||||
|
||||
221
script.js
221
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 `
|
||||
<article class="card" data-id="${escapeHtml(link.id)}">
|
||||
@@ -246,7 +262,7 @@
|
||||
<div class="domain" title="${domain}">${domain}</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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -257,8 +273,8 @@
|
||||
<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">편집</button>
|
||||
<button class="btn mini mini-danger" type="button" data-act="del">삭제</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>
|
||||
`;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
60
styles.css
60
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);
|
||||
|
||||
Reference in New Issue
Block a user