diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..62aa291 --- /dev/null +++ b/biome.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", + "files": { + "includes": ["**", "!src/client/ds/components.css", "!node_modules", "!data"] + } +} diff --git a/bun.lock b/bun.lock index 3d79581..a0f4878 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "indiko", "dependencies": { + "@elenajs/core": "^1.0.0", "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.2", "bun-sqlite-migrations": "^1.0.2", @@ -22,6 +23,8 @@ }, }, "packages": { + "@elenajs/core": ["@elenajs/core@1.0.0", "", {}, "sha512-YOlSv7qpvVVrzlVYoyx7s1c8cBML0b5Q8WhxXaNeO3Awd2iNcf9HcJKDWuOi9KT53F7L8K9uQNLCzyb7EVxlpw=="], + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], diff --git a/package.json b/package.json index 363824a..dca606e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "typescript": "^5" }, "dependencies": { + "@elenajs/core": "^1.0.0", "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.2", "bun-sqlite-migrations": "^1.0.2", diff --git a/src/client/admin-clients.ts b/src/client/admin-clients.ts index aa9c956..24b5306 100644 --- a/src/client/admin-clients.ts +++ b/src/client/admin-clients.ts @@ -15,15 +15,13 @@ const addRedirectUriBtn = document.getElementById( const redirectUrisList = document.getElementById( "redirectUrisList", ) as HTMLElement; -const toast = document.getElementById("toast") as HTMLElement; +import "./ds"; +import type IToast from "./ds/toast"; -function showToast(message: string, type: "success" | "error" = "success") { - toast.textContent = message; - toast.className = `toast ${type} show`; +const toast = document.getElementById("toast") as unknown as IToast; - setTimeout(() => { - toast.classList.remove("show"); - }, 3000); +function showToast(message: string, type: "success" | "error" = "success") { + toast.show(message, type); } async function checkAuth() { diff --git a/src/client/apps.ts b/src/client/apps.ts index 84d3c41..25debb6 100644 --- a/src/client/apps.ts +++ b/src/client/apps.ts @@ -1,5 +1,7 @@ +import "./ds"; +import type IToast from "./ds/toast"; + const token = localStorage.getItem("indiko_session"); -const appsList = document.getElementById("appsList") as HTMLElement; if (!token) { window.location.href = "/login"; @@ -13,12 +15,27 @@ interface App { lastUsed: number; } +function $(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) throw new Error(`#${id} missing from page`); + return el; +} + +let appsList: HTMLElement; +let toast: IToast; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + async function loadApps() { try { const response = await fetch("/api/apps", { - headers: { - Authorization: `Bearer ${token}`, - }, + headers: { Authorization: `Bearer ${token}` }, }); if (response.status === 401 || response.status === 403) { @@ -27,9 +44,7 @@ async function loadApps() { return; } - if (!response.ok) { - throw new Error("Failed to load apps"); - } + if (!response.ok) throw new Error("Failed to load apps"); const data = await response.json(); displayApps(data.apps); @@ -51,88 +66,89 @@ function displayApps(apps: App[]) { .map((app) => { const lastUsedDate = new Date(app.lastUsed * 1000).toLocaleDateString(); const grantedDate = new Date(app.grantedAt * 1000).toLocaleDateString(); + const badges = app.scopes + .map((s) => `${escapeHtml(s)}`) + .join(""); return ` -
-
+ +
-
${app.name}
-
Granted ${grantedDate} • Last used ${lastUsedDate}
+
${escapeHtml(app.name)}
+
granted ${grantedDate} • last used ${lastUsedDate}
- +
permissions
-
- ${app.scopes.map((scope) => `${scope}`).join("")} -
+
${badges}
-
+ `; }) .join(""); } -(window as any).revokeApp = async (clientId: string, event?: Event) => { - const btn = event?.target as HTMLButtonElement | undefined; - - // Double-click confirmation pattern - if (btn?.dataset.confirmState === "pending") { - // Second click - execute revoke - delete btn.dataset.confirmState; - btn.disabled = true; - btn.textContent = "revoking..."; - - const card = document.querySelector(`[data-client-id="${clientId}"]`); - - try { - const response = await fetch( - `/api/apps/${encodeURIComponent(clientId)}`, - { - method: "DELETE", - headers: { - Authorization: `Bearer ${token}`, - }, - }, - ); - - if (!response.ok) { - throw new Error("Failed to revoke app"); +async function handleRevoke(btn: HTMLButtonElement) { + const clientId = btn.dataset.clientId; + if (!clientId) return; + + // Two-step confirm: first click arms, second click revokes + if (btn.dataset.confirmState !== "pending") { + btn.dataset.confirmState = "pending"; + btn.textContent = "you sure?"; + setTimeout(() => { + if (btn.dataset.confirmState === "pending") { + delete btn.dataset.confirmState; + btn.textContent = "revoke"; } + }, 3000); + return; + } - // Remove from UI - card?.remove(); + delete btn.dataset.confirmState; + btn.disabled = true; + btn.textContent = "revoking..."; - // Check if list is now empty - const remaining = document.querySelectorAll(".app-card"); - if (remaining.length === 0) { - appsList.innerHTML = - '
No authorized apps yet. Apps will appear here after you grant them access.
'; - } - } catch (error) { - console.error("Failed to revoke app:", error); - alert("Failed to revoke app access. Please try again."); - if (btn) { - btn.disabled = false; - btn.textContent = "revoke"; - } - } - } else { - // First click - set pending state - if (btn) { - const originalText = btn.textContent; - btn.dataset.confirmState = "pending"; - btn.textContent = "you sure?"; - - // Reset after 3 seconds if not confirmed - setTimeout(() => { - if (btn.dataset.confirmState === "pending") { - delete btn.dataset.confirmState; - btn.textContent = originalText; - } - }, 3000); + try { + const response = await fetch(`/api/apps/${encodeURIComponent(clientId)}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) throw new Error("Failed to revoke app"); + + document.querySelector(`[data-client-id="${clientId}"]`)?.remove(); + toast.show("App access revoked", "success"); + + if (document.querySelectorAll(".app-card").length === 0) { + appsList.innerHTML = + '
No authorized apps yet. Apps will appear here after you grant them access.
'; } + } catch (error) { + console.error("Failed to revoke app:", error); + toast.show("Failed to revoke app access. Please try again.", "error"); + btn.disabled = false; + btn.textContent = "revoke"; } -}; +} + +function init() { + appsList = $("appsList"); + toast = $("toast") as unknown as IToast; + + appsList.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest( + ".revoke-btn", + ) as HTMLButtonElement | null; + if (btn) handleRevoke(btn); + }); -loadApps(); + loadApps(); +} + +if (document.readyState === "complete") { + init(); +} else { + document.addEventListener("DOMContentLoaded", init); +} diff --git a/src/client/ds/button.ts b/src/client/ds/button.ts new file mode 100644 index 0000000..00bbcc3 --- /dev/null +++ b/src/client/ds/button.ts @@ -0,0 +1,23 @@ +import { Elena } from "@elenajs/core"; + +/** + * — brutalist action button. Composite: put a native + */ +export default class IButton extends Elena(HTMLElement) { + static override tagName = "i-button"; + static override props = ["variant", "size", "disabled"]; + + variant = "primary"; + size = "md"; + disabled = false; + + override updated() { + const btn = this.querySelector("button"); + if (btn) btn.disabled = this.disabled; + } +} +IButton.define(); diff --git a/src/client/ds/card.ts b/src/client/ds/card.ts new file mode 100644 index 0000000..3f695e3 --- /dev/null +++ b/src/client/ds/card.ts @@ -0,0 +1,17 @@ +import { Elena } from "@elenajs/core"; + +/** + * — quiet surface. Composite: wraps and styles the content + * composed inside it, does NOT replace it. + * + * Usage: + * + *

passkeys

+ * …content stays intact… + *
+ */ +export default class ICard extends Elena(HTMLElement) { + static override tagName = "i-card"; + // No render(): composite component, content is preserved. +} +ICard.define(); diff --git a/src/client/ds/components.css b/src/client/ds/components.css new file mode 100644 index 0000000..ca42727 --- /dev/null +++ b/src/client/ds/components.css @@ -0,0 +1,315 @@ +/* Indiko design system — component styles, scoped with native @scope. + Tokens come from tokens.css; pages load both. */ + +/* i-button — the loud brutalist action */ +@scope (i-button) { + :scope { + display: inline-block; + } + + button { + position: relative; + padding: 1rem 2rem; + background: var(--accent); + color: var(--paper); + border: 4px solid var(--ink-sunken); + border-radius: 0; + font-family: var(--font); + font-size: var(--text-md); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1rem; + cursor: pointer; + box-shadow: var(--shadow-hard); + transition: + transform 0.15s ease, + box-shadow 0.15s ease; + width: 100%; + } + + button::before { + content: ""; + position: absolute; + inset: -4px; + border: 4px solid var(--accent-deep); + pointer-events: none; + transition: inset 0.15s ease; + } + + button:hover:not(:disabled) { + transform: translate(3px, 3px); + box-shadow: var(--shadow-hard-hover); + } + + button:hover:not(:disabled)::before { + inset: -7px; + } + + button:active:not(:disabled) { + transform: translate(6px, 6px); + box-shadow: 0 0 0 var(--ink-sunken); + } + + button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + :scope([variant="ghost"]) button { + background: transparent; + color: var(--paper-dim); + } + :scope([variant="ghost"]) button::before { + border-color: var(--paper-dim); + } + :scope([variant="ghost"]) button:hover:not(:disabled) { + background: rgba(188, 141, 160, 0.1); + } + + :scope([variant="danger"]) button { + background: transparent; + color: var(--danger); + } + :scope([variant="danger"]) button::before { + border-color: var(--danger); + } + + :scope([size="sm"]) button { + padding: 0.5rem 1rem; + font-size: var(--text-sm); + text-transform: none; + letter-spacing: normal; + box-shadow: none; + border-width: 2px; + } + :scope([size="sm"]) button::before { + display: none; + } + :scope([size="sm"]) button:hover:not(:disabled) { + transform: none; + box-shadow: none; + } +} + +/* i-card — the quiet surface (composite, content preserved) */ +@scope (i-card) { + :scope { + display: block; + background: rgba(188, 141, 160, 0.05); + border: 1px solid var(--paper-dim); + padding: var(--space-6); + } + + :scope([pad="lg"]) { + padding: var(--space-8); + } + + .card-title { + font-size: var(--text-xl); + font-weight: 600; + color: var(--paper); + margin: 0 0 var(--space-6); + letter-spacing: -0.05rem; + } +} + +/* i-toast */ +@scope (i-toast) { + .toast { + position: fixed; + bottom: var(--space-8); + right: var(--space-8); + background: var(--ink); + border: 2px solid var(--accent); + padding: var(--space-4) var(--space-6); + color: var(--paper); + font-size: var(--text-sm); + font-weight: 500; + z-index: 2000; + max-width: 25rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.5); + animation: toast-in 0.25s ease; + cursor: default; + user-select: text; + } + + .toast:hover { + border-color: var(--paper-dim); + } + + .toast .msg { + display: block; + } + + .toast .bar { + display: block; + height: 6px; + width: calc(100% + var(--space-6) * 2); + margin: var(--space-3) calc(var(--space-6) * -1) calc(var(--space-4) * -1); + background: var(--paper-dim); + opacity: 0.4; + transform-origin: left; + animation: toast-countdown 6000ms linear forwards; + } + + .toast.error .bar { + background: var(--accent-deep); + opacity: 0.8; + } + + @keyframes toast-countdown { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } + } + + .toast[hidden] { + display: none; + } + + .toast.error { + border-color: var(--accent-deep); + } + + @keyframes toast-in { + from { + opacity: 0; + transform: translateY(1rem); + } + } +} + +/* i-passkey-row */ +@scope (i-passkey-row) { + .row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-3); + background: rgba(12, 23, 19, 0.6); + border: 1px solid var(--accent-deep); + } + + .name { + font-weight: 500; + color: var(--paper); + } + + .date { + font-size: var(--text-xs); + color: var(--paper-dim); + margin-top: var(--space-1); + } + + .actions { + display: flex; + gap: var(--space-2); + } + + .mini { + padding: 0.375rem 0.75rem; + font-family: var(--font); + font-size: var(--text-xs); + font-weight: 600; + cursor: pointer; + background: rgba(188, 141, 160, 0.2); + color: var(--paper); + border: 2px solid var(--paper-dim); + } + + .mini.danger { + background: rgba(160, 70, 104, 0.2); + border-color: var(--accent-deep); + } + + .mini.ghost { + background: transparent; + } + + .rename-input { + flex: 1; + padding: 0.375rem 0.75rem; + font-size: var(--text-sm); + font-family: var(--font); + background: var(--ink-sunken); + border: 1px solid var(--paper-dim); + color: var(--paper); + } +} + +/* i-scope-list — consent screen */ +@scope (i-scope-list) { + .scope-list { + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-2); + margin: 0; + padding: 0; + } + + li { + color: var(--paper); + font-size: var(--text-sm); + line-height: 1.5; + } + + label { + display: flex; + align-items: center; + gap: var(--space-3); + cursor: pointer; + padding: var(--space-3); + border: 1px solid transparent; + transition: background 0.2s; + } + + label:hover { + background: rgba(188, 141, 160, 0.1); + border-color: var(--paper-dim); + } + + input[type="checkbox"] { + appearance: none; + width: 1.5rem; + height: 1.5rem; + border: 2px solid var(--paper-dim); + background: rgba(12, 23, 19, 0.6); + cursor: pointer; + flex-shrink: 0; + position: relative; + margin: 0; + } + + input[type="checkbox"]:checked { + background: var(--accent); + border-color: var(--accent); + } + + input[type="checkbox"]:checked::after { + content: "✓"; + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--paper); + font-size: var(--text-md); + font-weight: 700; + } + + input[type="checkbox"]:disabled { + cursor: not-allowed; + opacity: 0.7; + } + + .req { + font-style: normal; + color: var(--paper-dim); + font-size: var(--text-xs); + margin-left: var(--space-2); + } +} diff --git a/src/client/ds/index.ts b/src/client/ds/index.ts new file mode 100644 index 0000000..038534c --- /dev/null +++ b/src/client/ds/index.ts @@ -0,0 +1,12 @@ +// Indiko design system — import once per page to register all components. +import "./button"; +import "./card"; +import "./toast"; +import "./passkey-row"; +import "./scope-list"; + +export { default as IButton } from "./button"; +export { default as ICard } from "./card"; +export { default as IPasskeyRow } from "./passkey-row"; +export { default as IScopeList } from "./scope-list"; +export { default as IToast } from "./toast"; diff --git a/src/client/ds/passkey-row.ts b/src/client/ds/passkey-row.ts new file mode 100644 index 0000000..cf4e9e1 --- /dev/null +++ b/src/client/ds/passkey-row.ts @@ -0,0 +1,92 @@ +import { Elena, html } from "@elenajs/core"; + +/** + * — one passkey with rename/delete actions. + * + * Attributes: pid, name, created (unix seconds) + * Emits: "rename" {id, name}, "remove" {id} + */ +export default class IPasskeyRow extends Elena(HTMLElement) { + static override tagName = "i-passkey-row"; + static override props = ["pid", "name", "created"]; + + pid = ""; + name = ""; + created = ""; + + private editing = false; + + private date(): string { + const ts = Number(this.created); + if (!ts) return ""; + return new Date(ts * 1000).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); + } + + override render() { + if (this.editing) { + return html` +
+ +
+ + +
+
+ `; + } + return html` +
+
+
${this.name}
+
added ${this.date()}
+
+
+ + +
+
+ `; + } + + override firstUpdated() { + this.addEventListener("click", this.onClick); + } + + private onClick = (e: Event) => { + const btn = (e.target as HTMLElement).closest("button"); + if (!btn) return; + const act = (btn as HTMLButtonElement).dataset.act; + + if (act === "edit") { + this.editing = true; + this.requestUpdate(); + } else if (act === "cancel") { + this.editing = false; + this.requestUpdate(); + } else if (act === "save") { + const input = this.querySelector(".rename-input"); + const name = input?.value.trim() ?? ""; + this.editing = false; + this.dispatchEvent( + new CustomEvent("rename", { + detail: { id: this.pid, name }, + bubbles: true, + }), + ); + if (name) this.name = name; + this.requestUpdate(); + } else if (act === "delete") { + this.dispatchEvent( + new CustomEvent("remove", { + detail: { id: this.pid }, + bubbles: true, + }), + ); + } + }; +} +IPasskeyRow.define(); diff --git a/src/client/ds/scope-list.ts b/src/client/ds/scope-list.ts new file mode 100644 index 0000000..f0a831f --- /dev/null +++ b/src/client/ds/scope-list.ts @@ -0,0 +1,50 @@ +import { Elena, html } from "@elenajs/core"; + +/** + * — checkbox list of OAuth scopes for the consent screen. + * + * Attributes: scopes (space-separated), required (space-separated, forced on) + * Usage: + */ +export default class IScopeList extends Elena(HTMLElement) { + static override tagName = "i-scope-list"; + static override props = ["scopes", "required"]; + + scopes = ""; + required = ""; + + private descriptions: Record = { + profile: "Your profile (name, photo, URL)", + email: "Your email address", + openid: "Authenticate with OpenID Connect (issues an id_token)", + offline_access: "Stay signed in to this app long-term", + }; + + override render() { + const required = this.required.split(" ").filter(Boolean); + const items = this.scopes + .split(" ") + .filter(Boolean) + .map((scope) => { + const isRequired = required.includes(scope); + const description = this.descriptions[scope] ?? scope; + return html` +
  • + +
  • + `; + }); + return html`
      ${items}
    `; + } +} +IScopeList.define(); diff --git a/src/client/ds/toast.ts b/src/client/ds/toast.ts new file mode 100644 index 0000000..5b2f009 --- /dev/null +++ b/src/client/ds/toast.ts @@ -0,0 +1,85 @@ +import { Elena, html } from "@elenajs/core"; + +const DURATION = 6000; // ms a toast stays visible +const REMAINING_ON_HOVER = 2000; // min time left when you hover away + +/** + * — fixed bottom-right notification with a countdown progress bar. + * + * Method: show(message, kind) + * Usage: , then el.show("saved", "success") + * Dismisses after a delay; hovering pauses the countdown and the bar. + */ +export default class IToast extends Elena(HTMLElement) { + static override tagName = "i-toast"; + static override props = ["kind", "open", "message"]; + + kind: "success" | "error" = "success"; + open = false; + message = ""; + + private timer: number | undefined; + private shownAt = 0; + private remaining = DURATION; + private duration = DURATION; + + show(message: string, kind: "success" | "error" = "success") { + this.message = message; + this.kind = kind; + this.open = true; + this.startCountdown(DURATION); + } + + private startCountdown(ms: number) { + window.clearTimeout(this.timer); + this.remaining = ms; + this.duration = ms; + this.shownAt = Date.now(); + this.timer = window.setTimeout(() => { + this.open = false; + }, ms); + this.startBar(); + } + + private startBar() { + const bar = this.querySelector(".bar"); + if (!bar) return; + // Restart the CSS animation for the current remaining time + bar.style.animation = "none"; + // Force reflow so the animation restarts + void bar.offsetWidth; + bar.style.animation = `toast-countdown ${this.remaining}ms linear forwards`; + } + + private pauseCountdown() { + window.clearTimeout(this.timer); + this.remaining = Math.max( + this.remaining - (Date.now() - this.shownAt), + REMAINING_ON_HOVER, + ); + const bar = this.querySelector(".bar"); + if (bar) bar.style.animationPlayState = "paused"; + } + + private resumeCountdown() { + if (!this.open) return; + const bar = this.querySelector(".bar"); + if (bar) bar.style.animationPlayState = "running"; + this.startCountdown(this.remaining); + } + + override firstUpdated() { + this.addEventListener("mouseenter", () => this.pauseCountdown()); + this.addEventListener("mouseleave", () => this.resumeCountdown()); + } + + override render() { + return this.open + ? html`
    + ${this.message} + +
    ` + : html``; + } +} +IToast.define(); diff --git a/src/client/ds/tokens.css b/src/client/ds/tokens.css new file mode 100644 index 0000000..41f9fe0 --- /dev/null +++ b/src/client/ds/tokens.css @@ -0,0 +1,33 @@ +/* Indiko design tokens */ +:root { + /* Palette */ + --ink: #26242b; + --ink-raised: #2e2c33; + --ink-sunken: #1c1b20; + --paper: #d9d0de; + --paper-dim: #bc8da0; + --accent: #ab4967; + --accent-deep: #a04668; + --danger: #e57373; + + /* Typography */ + --font: "Space Grotesk", sans-serif; + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-md: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.5rem; + --text-2xl: 2rem; + + /* Spacing */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + /* Brutalist action shadow */ + --shadow-hard: 6px 6px 0 var(--ink-sunken); + --shadow-hard-hover: 3px 3px 0 var(--ink-sunken); +} diff --git a/src/client/index.ts b/src/client/index.ts index 3f82863..3fb224c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,29 +1,28 @@ import { startRegistration } from "@simplewebauthn/browser"; +import "./ds"; +import type IButton from "./ds/button"; +import type IToast from "./ds/toast"; const token = localStorage.getItem("indiko_session"); -const footer = document.getElementById("footer") as HTMLElement; -const welcome = document.getElementById("welcome") as HTMLElement; -const subtitle = document.getElementById("subtitle") as HTMLElement; -const recentApps = document.getElementById("recentApps") as HTMLElement; -const passkeysList = document.getElementById("passkeysList") as HTMLElement; -const addPasskeyBtn = document.getElementById( - "addPasskeyBtn", -) as HTMLButtonElement; -const toast = document.getElementById("toast") as HTMLElement; - -// Profile form elements -const profileForm = document.getElementById("profileForm") as HTMLFormElement; -const avatarPreview = document.getElementById("avatarPreview") as HTMLElement; -const usernameInput = document.getElementById("username") as HTMLInputElement; -const nameInput = document.getElementById("name") as HTMLInputElement; -const emailInput = document.getElementById("email") as HTMLInputElement; -const photoInput = document.getElementById("photo") as HTMLInputElement; -const urlInput = document.getElementById("url") as HTMLInputElement; -const saveBtn = document.getElementById("saveBtn") as HTMLButtonElement; -const deleteAccountBtn = document.getElementById( - "deleteAccountBtn", -) as HTMLButtonElement; -const dangerZone = document.getElementById("dangerZone") as HTMLElement; + +let footer!: HTMLElement; +let welcome!: HTMLElement; +let subtitle!: HTMLElement; +let recentApps!: HTMLElement; +let passkeysList!: HTMLElement; +let addPasskeyBtn!: IButton; +let toast!: IToast; +let profileForm!: HTMLFormElement; +let avatarPreview!: HTMLElement; +let usernameInput!: HTMLInputElement; +let nameInput!: HTMLInputElement; +let emailInput!: HTMLInputElement; +let photoInput!: HTMLInputElement; +let urlInput!: HTMLInputElement; +let saveBtn!: HTMLButtonElement; +let deleteAccountBtn!: HTMLButtonElement; +let addPasskeyNativeBtn!: HTMLButtonElement; +let dangerZone!: HTMLElement; let isAdmin = false; @@ -31,6 +30,52 @@ if (!token) { window.location.href = "/login"; } +function $(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) { + console.error( + `[indiko] #${id} missing from page. ` + + `readyState=${document.readyState} url=${location.href} ` + + `html-has-id=${document.documentElement.innerHTML.includes(`id="${id}"`)}`, + ); + throw new Error(`#${id} missing from page`); + } + return el; +} + +function init() { + footer = $("footer"); + welcome = $("welcome"); + subtitle = $("subtitle"); + recentApps = $("recentApps"); + passkeysList = $("passkeysList"); + addPasskeyBtn = $("addPasskeyBtn") as IButton; + toast = $("toast") as unknown as IToast; + profileForm = $("profileForm") as HTMLFormElement; + avatarPreview = $("avatarPreview"); + usernameInput = $("username") as HTMLInputElement; + nameInput = $("name") as HTMLInputElement; + emailInput = $("email") as HTMLInputElement; + photoInput = $("photo") as HTMLInputElement; + urlInput = $("url") as HTMLInputElement; + saveBtn = $("saveBtn").querySelector("button") as HTMLButtonElement; + deleteAccountBtn = $("deleteAccountBtn").querySelector( + "button", + ) as HTMLButtonElement; + addPasskeyNativeBtn = $("addPasskeyBtn").querySelector( + "button", + ) as HTMLButtonElement; + dangerZone = $("dangerZone"); + + profileForm.addEventListener("submit", onProfileSubmit); + deleteAccountBtn.addEventListener("click", onDeleteAccount); + passkeysList.addEventListener("rename", onPasskeyRename as unknown as EventListener); + passkeysList.addEventListener("remove", onPasskeyRemove as unknown as EventListener); + addPasskeyBtn.addEventListener("click", onAddPasskey); + + checkAuth(); +} + interface App { clientId: string; name: string; @@ -55,12 +100,7 @@ interface Passkey { } function showToast(message: string, type: "success" | "error" = "success") { - toast.textContent = message; - toast.className = `toast ${type} show`; - - setTimeout(() => { - toast.classList.remove("show"); - }, 3000); + toast.show(message, type); } function updateAvatarPreview(photo: string | null, username: string) { @@ -217,7 +257,7 @@ async function loadRecentApps() { } // Profile form submission -profileForm.addEventListener("submit", async (e) => { +async function onProfileSubmit(e: SubmitEvent) { e.preventDefault(); saveBtn.disabled = true; @@ -250,10 +290,10 @@ profileForm.addEventListener("submit", async (e) => { saveBtn.disabled = false; saveBtn.textContent = "save changes"; } -}); +} // Delete account handler -deleteAccountBtn.addEventListener("click", async () => { +async function onDeleteAccount() { const confirmMessage = "Are you absolutely sure you want to delete your account?\n\n" + "This will permanently delete:\n" + @@ -302,7 +342,7 @@ deleteAccountBtn.addEventListener("click", async () => { deleteAccountBtn.disabled = false; deleteAccountBtn.textContent = "delete my account"; } -}); +} async function loadPasskeys() { try { @@ -325,118 +365,36 @@ async function loadPasskeys() { return; } - passkeysList.innerHTML = passkeys - .map((passkey) => { - const createdDate = new Date( - passkey.created_at * 1000, - ).toLocaleDateString(); - - return ` -
    -
    -
    ${passkey.name}
    -
    added ${createdDate}
    -
    -
    - - ${passkeys.length > 1 ? `` : ""} -
    -
    - `; - }) - .join(""); - - // Add event listeners for rename buttons - document.querySelectorAll(".rename-passkey-btn").forEach((btn) => { - btn.addEventListener("click", () => { - const passkeyId = btn.getAttribute("data-passkey-id"); - showRenameForm(Number(passkeyId)); - }); - }); - - // Add event listeners for delete buttons - document.querySelectorAll(".delete-passkey-btn").forEach((btn) => { - btn.addEventListener("click", async () => { - const passkeyId = btn.getAttribute("data-passkey-id"); - await deletePasskeyHandler(Number(passkeyId)); - }); - }); + passkeysList.replaceChildren(); + for (const passkey of passkeys) { + const row = document.createElement("i-passkey-row"); + row.setAttribute("pid", String(passkey.id)); + row.setAttribute("name", passkey.name); + row.setAttribute("created", String(passkey.created_at)); + passkeysList.appendChild(row); + } } catch (error) { console.error("Failed to load passkeys:", error); passkeysList.innerHTML = '
    Failed to load passkeys
    '; } } -function showRenameForm(passkeyId: number) { - const passkeyItem = document.querySelector( - `[data-passkey-id="${passkeyId}"]`, - ); - if (!passkeyItem) return; - - const infoDiv = passkeyItem.querySelector(".passkey-info"); - const nameDiv = infoDiv?.querySelector(".passkey-name"); - if (!nameDiv) return; - - const currentName = nameDiv.textContent || ""; - - // Replace the info div with a rename form - if (infoDiv) { - infoDiv.innerHTML = ` -
    - - - -
    - `; - - const input = infoDiv.querySelector(".rename-input") as HTMLInputElement; - input.focus(); - input.select(); - - // Save button - infoDiv - .querySelector(".save-rename-btn") - ?.addEventListener("click", async () => { - await renamePasskeyHandler(passkeyId, input.value); - }); - - // Cancel button - infoDiv - .querySelector(".cancel-rename-btn") - ?.addEventListener("click", () => { - loadPasskeys(); - }); - - // Enter to save - input.addEventListener("keypress", async (e) => { - if (e.key === "Enter") { - await renamePasskeyHandler(passkeyId, input.value); - } - }); - - // Escape to cancel - input.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - loadPasskeys(); - } - }); - } -} - -async function renamePasskeyHandler(passkeyId: number, newName: string) { - if (!newName.trim()) { +// Component events bubble up from +async function onPasskeyRename(e: CustomEvent<{ id: string; name: string }>) { + const { id, name } = e.detail; + if (!name.trim()) { showToast("Passkey name cannot be empty", "error"); return; } try { - const response = await fetch(`/api/passkeys/${passkeyId}`, { + const response = await fetch(`/api/passkeys/${id}`, { method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, - body: JSON.stringify({ name: newName }), + body: JSON.stringify({ name }), }); if (!response.ok) { @@ -445,13 +403,15 @@ async function renamePasskeyHandler(passkeyId: number, newName: string) { } showToast("Passkey renamed successfully!", "success"); - loadPasskeys(); } catch (error) { showToast((error as Error).message || "Failed to rename passkey", "error"); + loadPasskeys(); } } -async function deletePasskeyHandler(passkeyId: number) { +async function onPasskeyRemove(e: CustomEvent<{ id: string }>) { + const { id } = e.detail; + if ( !confirm( "Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.", @@ -461,7 +421,7 @@ async function deletePasskeyHandler(passkeyId: number) { } try { - const response = await fetch(`/api/passkeys/${passkeyId}`, { + const response = await fetch(`/api/passkeys/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}`, @@ -481,9 +441,9 @@ async function deletePasskeyHandler(passkeyId: number) { } // Add passkey button handler -addPasskeyBtn.addEventListener("click", async () => { - addPasskeyBtn.disabled = true; - addPasskeyBtn.textContent = "preparing..."; +async function onAddPasskey() { + addPasskeyNativeBtn.disabled = true; + addPasskeyNativeBtn.textContent = "preparing..."; try { // Get registration options @@ -501,12 +461,12 @@ addPasskeyBtn.addEventListener("click", async () => { const options = await optionsRes.json(); - addPasskeyBtn.textContent = "create your passkey..."; + addPasskeyNativeBtn.textContent = "create your passkey..."; // Start registration const regResponse = await startRegistration(options); - addPasskeyBtn.textContent = "verifying..."; + addPasskeyNativeBtn.textContent = "verifying..."; // Ask for a name const name = prompt( @@ -537,9 +497,13 @@ addPasskeyBtn.addEventListener("click", async () => { } catch (error) { showToast((error as Error).message || "Failed to add passkey", "error"); } finally { - addPasskeyBtn.disabled = false; - addPasskeyBtn.textContent = "add new passkey"; + addPasskeyNativeBtn.disabled = false; + addPasskeyNativeBtn.textContent = "add new passkey"; } -}); +} -checkAuth(); +if (document.readyState === "complete") { + init(); +} else { + document.addEventListener("DOMContentLoaded", init); +} diff --git a/src/client/login.ts b/src/client/login.ts index 6d9831b..1e534ad 100644 --- a/src/client/login.ts +++ b/src/client/login.ts @@ -60,7 +60,9 @@ async function checkRegistrationAllowed() { } ( - document.getElementById("registerBtn") as HTMLButtonElement + document + .getElementById("registerBtn") + ?.querySelector("button") as HTMLButtonElement ).textContent = "create account"; loginForm.style.display = "none"; registerForm.style.display = "block"; @@ -80,7 +82,9 @@ async function checkRegistrationAllowed() { document.getElementById("registerUsername") as HTMLInputElement ).placeholder = "admin username"; ( - document.getElementById("registerBtn") as HTMLButtonElement + document + .getElementById("registerBtn") + ?.querySelector("button") as HTMLButtonElement ).textContent = "create admin account"; // Hide login form for first setup loginForm.style.display = "none"; @@ -110,7 +114,9 @@ loginForm.addEventListener("submit", async (e) => { e.preventDefault(); const username = (document.getElementById("username") as HTMLInputElement) .value; - const loginBtn = document.getElementById("loginBtn") as HTMLButtonElement; + const loginBtn = document + .getElementById("loginBtn") + ?.querySelector("button") as HTMLButtonElement; try { loginBtn.disabled = true; @@ -183,9 +189,9 @@ registerForm.addEventListener("submit", async (e) => { const username = ( document.getElementById("registerUsername") as HTMLInputElement ).value; - const registerBtn = document.getElementById( - "registerBtn", - ) as HTMLButtonElement; + const registerBtn = document + .getElementById("registerBtn") + ?.querySelector("button") as HTMLButtonElement; try { registerBtn.disabled = true; @@ -296,7 +302,9 @@ ldapForm.addEventListener("submit", async (e) => { const password = (document.getElementById("ldapPassword") as HTMLInputElement) .value; - const ldapBtn = document.getElementById("ldapBtn") as HTMLButtonElement; + const ldapBtn = document + .getElementById("ldapBtn") + ?.querySelector("button") as HTMLButtonElement; try { ldapBtn.disabled = true; diff --git a/src/html/admin-clients.html b/src/html/admin-clients.html index 2329284..b040ff0 100644 --- a/src/html/admin-clients.html +++ b/src/html/admin-clients.html @@ -20,6 +20,10 @@ + + + + + -
    -
    - indiko -
    -
    +
    + +
    +
    -
    -
    -

    users

    -
    -
    loading users...
    -
    +
    +

    users

    +
    +
    loading users...
    -
    - loading... + + + - \ No newline at end of file + diff --git a/src/html/apps.html b/src/html/apps.html index a732a3f..81c6c17 100644 --- a/src/html/apps.html +++ b/src/html/apps.html @@ -7,24 +7,43 @@ authorized apps • indiko - - + - - + + + @@ -135,6 +167,8 @@
    + + diff --git a/src/html/docs.html b/src/html/docs.html index 94a2e69..7a80ab6 100644 --- a/src/html/docs.html +++ b/src/html/docs.html @@ -20,6 +20,7 @@ + @@ -261,38 +169,32 @@
    -
    -

    recent apps

    + +

    recent apps

    -
    loading...
    +
    loading...
    -
    + -
    -

    passkeys

    -

    - Manage your passkeys for secure, password-free authentication + +

    passkeys

    +

    + Manage your passkeys for secure, password-free authentication.

    -
    -
    loading...
    +
    +
    loading...
    - -
    - -
    -

    profile settings

    + + + +

    profile settings

    -
    - -
    +
    - Enter a URL to an image
    @@ -308,18 +210,18 @@ - +
    -
    - -
    @@ -327,9 +229,9 @@ loading... -
    + - \ No newline at end of file + diff --git a/src/html/login.html b/src/html/login.html index ca16f45..963bf38 100644 --- a/src/html/login.html +++ b/src/html/login.html @@ -8,47 +8,55 @@ - - - + + + -
    - indiko -

    sign in with passkey

    +
    +
    + indiko +

    sign in with passkey

    +
    -
    +
    + - +
    + - + -
    +
    What's a passkey?
    @@ -139,4 +171,4 @@ - \ No newline at end of file + diff --git a/src/lib/oauth/client-metadata.ts b/src/lib/oauth/client-metadata.ts index 80ecd2d..ef4fb3b 100644 --- a/src/lib/oauth/client-metadata.ts +++ b/src/lib/oauth/client-metadata.ts @@ -92,13 +92,13 @@ export async function fetchClientMetadata(clientId: string): Promise<{ for (const tagMatch of html.matchAll(linkTagRegex)) { const tag = tagMatch[0]; - const relMatch = tag.match(/rel=["']?([^"'\s>]+)["']?/i); - if (!relMatch) continue; - if (!relMatch[1].split(/\s+/).includes("redirect_uri")) continue; + const rel = tag.match(/rel=["']?([^"'\s>]+)["']?/i)?.[1]; + if (!rel) continue; + if (!rel.split(/\s+/).includes("redirect_uri")) continue; - const hrefMatch = tag.match(/href=["']?([^"'\s>]+)["']?/i); - if (hrefMatch && !redirectUris.includes(hrefMatch[1])) { - redirectUris.push(hrefMatch[1]); + const href = tag.match(/href=["']?([^"'\s>]+)["']?/i)?.[1]; + if (href && !redirectUris.includes(href)) { + redirectUris.push(href); } } @@ -181,13 +181,13 @@ export async function verifyDomain( for (const tagMatch of html.matchAll(tagRegex)) { const tag = tagMatch[0]; - const relMatch = tag.match(/rel=["']?([^"'\s>]+)["']?/i); - if (!relMatch) continue; - if (!relMatch[1].split(/\s+/).includes("me")) continue; + const rel = tag.match(/rel=["']?([^"'\s>]+)["']?/i)?.[1]; + if (!rel) continue; + if (!rel.split(/\s+/).includes("me")) continue; - const hrefMatch = tag.match(/href=["']?([^"'\s>]+)["']?/i); - if (hrefMatch && !relMeLinks.includes(hrefMatch[1])) { - relMeLinks.push(hrefMatch[1]); + const href = tag.match(/href=["']?([^"'\s>]+)["']?/i)?.[1]; + if (href && !relMeLinks.includes(href)) { + relMeLinks.push(href); } } @@ -210,7 +210,7 @@ export async function verifyDomain( ); return { success: false, - error: `Domain must have or ... to verify ownership`, + error: `Your site must link back to ${indikoProfileUrl} with rel="me" to verify you own it. Add a link tag or anchor with rel="me" pointing to that URL, then try again.`, }; } diff --git a/src/lib/oauth/pages.ts b/src/lib/oauth/pages.ts index 33cedf1..ff9b10c 100644 --- a/src/lib/oauth/pages.ts +++ b/src/lib/oauth/pages.ts @@ -167,8 +167,8 @@ export function errorPage(opts: ErrorPageOptions): Response { const CONSENT_STYLES = ` body { padding: 2rem 1rem; } - .consent-box { - max-width: 32rem; + .consent { + max-width: 30rem; width: 100%; background: rgba(188, 141, 160, 0.05); border: 1px solid var(--old-rose); @@ -176,15 +176,13 @@ const CONSENT_STYLES = ` } .app-header { display: flex; - gap: 1.5rem; - align-items: flex-start; - margin-bottom: 2rem; - padding-bottom: 2rem; - border-bottom: 1px solid var(--old-rose); + gap: 1.25rem; + align-items: center; + margin-bottom: 1.25rem; } .app-logo { - width: 5rem; - height: 5rem; + width: 4rem; + height: 4rem; border-radius: 0.5rem; background: rgba(188, 141, 160, 0.2); display: flex; @@ -192,50 +190,41 @@ const CONSENT_STYLES = ` justify-content: center; flex-shrink: 0; overflow: hidden; - font-size: 2rem; + font-size: 1.75rem; } .app-logo img { width: 100%; height: 100%; object-fit: cover; } - .app-info { flex: 1; } + .app-info { flex: 1; min-width: 0; } .app-name { - font-size: 1.5rem; + font-size: 1.375rem; font-weight: 700; color: var(--lavender); - margin-bottom: 0.5rem; + line-height: 1.2; } .app-url { font-size: 0.875rem; color: var(--old-rose); font-family: monospace; - margin-bottom: 0.75rem; + margin-top: 0.25rem; } .app-description { font-size: 0.9375rem; color: var(--old-rose); line-height: 1.6; + margin-top: 0.5rem; } - .user-badge { - display: inline-block; - background: rgba(188, 141, 160, 0.1); - border-left: 3px solid var(--berry-crush); - padding: 0.75rem 1rem; - font-size: 0.875rem; - color: var(--old-rose); - margin-bottom: 2rem; - } - .user-badge strong { color: var(--lavender); } .request-text { - font-size: 1.125rem; - color: var(--lavender); + font-size: 1rem; + color: var(--old-rose); margin-bottom: 1.5rem; line-height: 1.6; } .scopes { - margin-bottom: 2rem; - padding: 1.5rem; + margin-bottom: 1.75rem; + padding: 1.25rem; background: rgba(12, 23, 19, 0.4); border: 1px solid var(--old-rose); } @@ -244,13 +233,13 @@ const CONSENT_STYLES = ` color: var(--old-rose); text-transform: uppercase; letter-spacing: 0.1rem; - margin-bottom: 1rem; + margin-bottom: 0.75rem; } .scope-list { list-style: none; display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.25rem; } .scope-list li { color: var(--lavender); @@ -262,7 +251,7 @@ const CONSENT_STYLES = ` align-items: center; gap: 0.75rem; cursor: pointer; - padding: 0.75rem; + padding: 0.625rem; transition: background 0.2s; border: 1px solid transparent; } @@ -272,14 +261,15 @@ const CONSENT_STYLES = ` } .scope-list input[type="checkbox"] { appearance: none; - width: 1.5rem; - height: 1.5rem; + width: 1.25rem; + height: 1.25rem; border: 2px solid var(--old-rose); background: rgba(12, 23, 19, 0.6); cursor: pointer; flex-shrink: 0; position: relative; transition: all 0.2s; + margin: 0; } .scope-list input[type="checkbox"]:checked { background: var(--berry-crush); @@ -288,17 +278,24 @@ const CONSENT_STYLES = ` .scope-list input[type="checkbox"]:checked::after { content: "✓"; position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + inset: 0; + display: grid; + place-items: center; color: var(--lavender); - font-size: 1rem; + font-size: 0.875rem; font-weight: 700; } - .scope-list input[type="checkbox"]:disabled { cursor: not-allowed; } + .scope-list input[type="checkbox"]:disabled { cursor: not-allowed; opacity: 0.7; } + .req { + font-style: normal; + color: var(--old-rose); + font-size: 0.75rem; + margin-left: 0.5rem; + } .buttons { display: flex; gap: 1rem; + margin-top: 1.75rem; } .buttons button { flex: 1; } .allow { @@ -311,6 +308,13 @@ const CONSENT_STYLES = ` color: var(--old-rose); } .deny::before { border-color: var(--old-rose); } + .who { + margin-top: 1.5rem; + text-align: center; + font-size: 0.8125rem; + color: var(--old-rose); + } + .who strong { color: var(--lavender); font-weight: 600; } `; export interface ConsentPageOptions { @@ -329,9 +333,10 @@ export interface ConsentPageOptions { } const SCOPE_DESCRIPTIONS: Record = { - profile: "Your profile (name, photo, URL)", - email: "Your email address", - openid: "Authenticate with OpenID Connect (issues an id_token)", + profile: "See your profile (name, photo, URL)", + email: "See your email address", + openid: "Sign you in with OpenID Connect", + offline_access: "Keep you signed in long-term", }; export function consentPage(opts: ConsentPageOptions): Response { @@ -339,9 +344,7 @@ export function consentPage(opts: ConsentPageOptions): Response { .map((scope) => { const isProfile = scope === "profile"; const description = escapeHtml(SCOPE_DESCRIPTIONS[scope] ?? scope); - const required = isProfile - ? ' (required)' - : ""; + const required = isProfile ? ' required' : ""; return `