feat(arch): enforce 400-line ceiling and subdivide vertical slices

- Update AGENTS.md with 400-line hard ceiling and sub-feature slicing rules
- Update scripts/lint_arch.ts to enforce 400 lines max with anti-formatting heuristics
- Subdivide src/shared/ui/ into layout, navbar, and admin layout fragments
- Subdivide src/features/auth/ into modular login, register, and recovery routes/fragments
- Subdivide src/features/admin/ into users, user details, apps, and audit fragments
- Maintain backwards compatibility via fragment index re-exports
This commit is contained in:
Tyler Gillispie 2026-08-27 19:55:01 -07:00
parent 8b30b17dbe
commit 06cad3d8fe
19 changed files with 2586 additions and 2440 deletions

View File

@ -101,10 +101,18 @@ Management (IAM) fabric and WebAuthn Passkey authority.
## 5. AI-Optimized Code Organization & Engineering Principles ## 5. AI-Optimized Code Organization & Engineering Principles
1. **Bounded Files & Concise Functions:** 1. **Strict 400-Line Ceiling & Concise Functions:**
- Target small, focused functions (4–20 lines) and keep files bounded (under - Target small, focused functions (4–20 lines) and keep all files bounded
300–500 lines) so agents can read, reason, and edit full units in a single under a **strict 400-line hard ceiling** (aim for the 150–250 line sweet
turn without context fragmentation. spot) so agents can read, reason, and edit full units in a single turn
without context fragmentation or output truncation.
- **Subdivide by Sub-Feature, Not Just Type:** When a feature slice grows,
subdivide into focused, SRP-aligned sub-files within the feature directory
(e.g., `login_fragments.tsx`, `register_fragments.tsx`,
`recovery_fragments.tsx` instead of one massive monolithic file).
- **No Anti-Formatting Hacks:** Never compress code onto single lines or use
blind skip annotations to bypass line-count linters. All files must pass
standard `deno fmt` and architectural linters without workarounds.
2. **Strict Single Responsibility Principle (SRP):** 2. **Strict Single Responsibility Principle (SRP):**
- Every module must do exactly one thing well. Independent modules allow - Every module must do exactly one thing well. Independent modules allow
agents to isolate and modify code without loading unrelated context. agents to isolate and modify code without loading unrelated context.

View File

@ -1,6 +1,13 @@
import { walk } from "jsr:@std/fs"; import { walk } from "jsr:@std/fs";
const targetDir = "./src"; const TARGET_DIR = "./src";
const MAX_FILE_LINES = 400;
// Auditable allowlist for exceptional files that legitimately exceed MAX_FILE_LINES.
// Every entry must be explicitly documented and approved.
const ALLOWLISTED_LARGE_FILES = new Set<string>([
// Currently empty: all modules in src/ must strictly conform to <= 400 lines.
]);
let hasErrors = false; let hasErrors = false;
@ -8,8 +15,39 @@ async function checkFile(path: string) {
const content = await Deno.readTextFile(path); const content = await Deno.readTextFile(path);
const lines = content.split("\n"); const lines = content.split("\n");
// 1. Enforce strict 400-line ceiling
if (lines.length > MAX_FILE_LINES && !ALLOWLISTED_LARGE_FILES.has(path)) {
console.error(
`[Arch Lint] ❌ File size ceiling exceeded in ${path}: ${lines.length} lines (Hard Ceiling: ${MAX_FILE_LINES} lines).`,
);
console.error(
` -> Subdivide this module into focused, SRP-aligned component files within the feature directory (e.g. login_fragments.tsx, register_fragments.tsx).`,
);
hasErrors = true;
}
lines.forEach((line, index) => { lines.forEach((line, index) => {
// 1. Block banned DOM APIs // 2. Block anti-formatting minification hacks (lines over 300 chars without SVG/CSS/raw string reasons)
if (
line.length > 300 &&
!line.includes("<svg") &&
!line.includes("data:image") &&
!line.includes("style=") &&
!line.includes("`") &&
!line.includes("/*")
) {
console.error(
`[Arch Lint] ❌ Anti-formatting detected (abnormally long line: ${line.length} chars) in ${path}:${
index + 1
}`,
);
console.error(
` -> Format code with standard 'deno fmt'. Do not minify to bypass line limits.`,
);
hasErrors = true;
}
// 3. Block banned DOM APIs in src/
if ( if (
line.includes("document.getElementById") || line.includes("document.getElementById") ||
line.includes("document.querySelector") || line.includes("document.querySelector") ||
@ -25,7 +63,7 @@ async function checkFile(path: string) {
hasErrors = true; hasErrors = true;
} }
// 2. Block unescaped HTML in raw strings (basic heuristic for dangerouslySetInnerHTML) // 4. Block unescaped HTML in raw strings (basic heuristic for dangerouslySetInnerHTML)
if ( if (
line.includes("dangerouslySetInnerHTML") && line.includes("dangerouslySetInnerHTML") &&
!path.includes("error_fragments.tsx") !path.includes("error_fragments.tsx")
@ -42,7 +80,7 @@ async function checkFile(path: string) {
}); });
} }
for await (const entry of walk(targetDir, { exts: [".ts", ".tsx"] })) { for await (const entry of walk(TARGET_DIR, { exts: [".ts", ".tsx"] })) {
if (entry.isFile) { if (entry.isFile) {
await checkFile(entry.path); await checkFile(entry.path);
} }
@ -51,5 +89,5 @@ for await (const entry of walk(targetDir, { exts: [".ts", ".tsx"] })) {
if (hasErrors) { if (hasErrors) {
Deno.exit(1); Deno.exit(1);
} else { } else {
console.log("✅ Architecture lint passed."); console.log("✅ Architecture lint passed (All files <= 400 lines & clean).");
} }

View File

@ -0,0 +1,157 @@
import { AdminLayoutFragment } from "../../shared/ui/fragments.tsx";
export const AdminAppsPageFragment = ({
apps,
}: {
apps: any[];
}) => {
return (
<AdminLayoutFragment title="Application Registry" currentPath="/admin/apps">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Connected Applications
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Register and manage subsidiary workloads and Zero-Trust SPIFFE
identities.
</p>
</div>
<div style="position: relative; min-width: 240px; max-width: 320px; width: 100%;">
<input
type="text"
id="appSearchInput"
placeholder="Search apps by name, domain..."
oninput="filterAppsList()"
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
</div>
{/* Desktop Table */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table id="appsTable">
<thead>
<tr>
<th>Application Name</th>
<th>SPIFFE Workload ID</th>
<th>Domain</th>
<th>Active Grants</th>
</tr>
</thead>
<tbody>
{apps.length === 0
? (
<tr>
<td
colSpan={4}
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
>
No connected applications registered.
</td>
</tr>
)
: (
apps.map((app) => (
<tr
key={app.id}
class="app-row"
data-search={`${app.name} ${app.domain || ""} ${
app.spiffe_id || ""
}`.toLowerCase()}
>
<td>
<strong style="color: var(--text-primary);">
{app.name}
</strong>
</td>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace; color: var(--primary);">
{app.spiffe_id}
</code>
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{app.domain || "-"}
</td>
<td>
<span class="badge badge-info">
{app.active_grants_count || 0} users
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Mobile Card Deck (< 768px) */}
<div
id="appsMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;"
>
{apps.map((app) => (
<div
class="card app-card"
key={app.id}
data-search={`${app.name} ${app.domain || ""} ${
app.spiffe_id || ""
}`.toLowerCase()}
style="margin-bottom: 0;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
{app.name}
</h3>
<span class="badge badge-info">
{app.active_grants_count || 0} users
</span>
</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.5rem; font-family: monospace;">
{app.spiffe_id}
</div>
<div style="font-size: 0.85rem; color: var(--text-muted);">
Domain: {app.domain || "-"}
</div>
</div>
))}
</div>
<style>
{`
@media (min-width: 768px) {
.desktop-only { display: block !important; }
.mobile-only { display: none !important; }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
`}
</style>
<script src="/public/admin-scripts.js"></script>
</AdminLayoutFragment>
);
};

View File

@ -0,0 +1,172 @@
import { AdminLayoutFragment } from "../../shared/ui/fragments.tsx";
export const AuditLogPageFragment = ({
logs,
}: {
logs: any[];
}) => {
return (
<AdminLayoutFragment title="Audit Logs" currentPath="/admin/audit-logs">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Immutable Audit Ledger
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Cryptographically chained Merkle audit trail for authentication,
authorization, and administrative events.
</p>
</div>
<div style="position: relative; min-width: 240px; max-width: 320px; width: 100%;">
<input
type="text"
id="auditSearchInput"
placeholder="Search action, user, IP..."
oninput="filterAuditLogs()"
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
</div>
{/* Desktop Table (>= 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table id="auditTable">
<thead>
<tr>
<th>Timestamp</th>
<th>Event Action</th>
<th>User / Subject</th>
<th>Resource</th>
<th>IP Address</th>
</tr>
</thead>
<tbody>
{logs.map((log) => {
const isFail = log.action.includes("fail") ||
log.action.includes("denied");
const isSuccess = log.action.includes("success") ||
log.action.includes("create") ||
log.action.includes("activate") ||
log.action.includes("updated");
const badgeClass = isFail
? "badge-danger"
: isSuccess
? "badge-success"
: "badge-info";
return (
<tr
key={log.id}
class="log-row"
data-search={`${log.action} ${log.user || "system"} ${
log.resource || ""
} ${log.ip_address || ""}`.toLowerCase()}
>
<td style="font-size: 0.8rem; color: var(--text-muted); white-space: nowrap;">
{new Date(log.created_at).toLocaleString()}
</td>
<td>
<span class={`badge ${badgeClass}`}>
{log.action}
</span>
</td>
<td>
<strong style="color: var(--text-primary);">
{log.user || "System"}
</strong>
</td>
<td style="color: var(--text-secondary);">
{log.resource || "-"}
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{log.ip_address || "-"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Mobile Card Deck (< 768px) */}
<div
id="auditMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;"
>
{logs.map((log) => {
const isFail = log.action.includes("fail") ||
log.action.includes("denied");
const isSuccess = log.action.includes("success") ||
log.action.includes("create") ||
log.action.includes("activate") ||
log.action.includes("updated");
const badgeClass = isFail
? "badge-danger"
: isSuccess
? "badge-success"
: "badge-info";
return (
<div
class="card log-card"
key={log.id}
data-search={`${log.action} ${log.user || "system"} ${
log.resource || ""
} ${log.ip_address || ""}`.toLowerCase()}
style="margin-bottom: 0;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<span class={`badge ${badgeClass}`}>
{log.action}
</span>
<span style="font-size: 0.75rem; color: var(--text-muted);">
{new Date(log.created_at).toLocaleTimeString()}
</span>
</div>
<div style="font-weight: 600; color: var(--text-primary); margin-bottom: 0.25rem;">
{log.user || "System"}
</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.25rem;">
Resource: {log.resource || "-"}
</div>
<div style="font-size: 0.8rem; font-family: monospace; color: var(--text-muted);">
IP: {log.ip_address || "-"}
</div>
</div>
);
})}
</div>
<style>
{`
@media (min-width: 768px) {
.desktop-only { display: block !important; }
.mobile-only { display: none !important; }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
`}
</style>
<script src="/public/admin-scripts.js"></script>
</AdminLayoutFragment>
);
};

View File

@ -1,908 +1,4 @@
import { AdminLayoutFragment } from "../../shared/ui/fragments.tsx"; export { AdminUsersPageFragment } from "./users_fragments.tsx";
export { AdminUserDetailsPageFragment } from "./user_details_fragments.tsx";
export const AdminUsersPageFragment = ({ export { AdminAppsPageFragment } from "./apps_fragments.tsx";
users, export { AuditLogPageFragment } from "./audit_fragments.tsx";
}: {
users: any[];
}) => {
return (
<AdminLayoutFragment title="User Directory" currentPath="/admin/users">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
User Directory
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage user accounts, view active sessions, and oversee permission
grants.
</p>
</div>
<div style="position: relative; min-width: 240px; max-width: 320px; width: 100%;">
<input
type="text"
id="userSearchInput"
placeholder="Search username or name..."
oninput="filterUsersList()"
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
</div>
{/* Desktop Table (>= 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table id="usersTable">
<thead>
<tr>
<th>Username</th>
<th>Display Name</th>
<th>Account Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => {
const statusClass = user.account_status === "active"
? "badge-success"
: user.account_status === "suspended"
? "badge-danger"
: "badge-warning";
return (
<tr
key={user.id}
class="user-row"
data-search={`${user.username} ${
user.display_name || ""
} ${user.account_status}`.toLowerCase()}
>
<td>
<strong style="color: var(--text-primary); font-family: monospace;">
@{user.username}
</strong>
</td>
<td>{user.display_name || "-"}</td>
<td>
<span class={`badge ${statusClass}`}>
{user.account_status}
</span>
</td>
<td>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<a
href={`/admin/users/${user.id}`}
class="btn-outline"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px; height: 32px; display: inline-flex; align-items: center; gap: 0.35rem; text-decoration: none; font-weight: 600;"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z">
</path>
</svg>
<span>Manage</span>
</a>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Mobile Card Deck (< 768px) */}
<div
id="usersMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;"
>
{users.map((user) => {
const statusClass = user.account_status === "active"
? "badge-success"
: user.account_status === "suspended"
? "badge-danger"
: "badge-warning";
return (
<div
class="card user-card"
key={user.id}
data-search={`${user.username} ${
user.display_name || ""
} ${user.account_status}`.toLowerCase()}
style="margin-bottom: 0;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<div style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700; font-size: 1.1rem;">
{(user.display_name || user.username).charAt(0)
.toUpperCase()}
</div>
<div>
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
{user.display_name || user.username}
</h3>
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
@{user.username}
</span>
</div>
</div>
<span class={`badge ${statusClass}`}>
{user.account_status}
</span>
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
<a
href={`/admin/users/${user.id}`}
class="btn-outline"
style="flex: 1; text-decoration: none; justify-content: center; min-height: 42px; font-size: 0.875rem; font-weight: 600; gap: 0.4rem;"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z">
</path>
</svg>
<span>Manage User</span>
</a>
</div>
</div>
);
})}
</div>
<style>
{`
@media (min-width: 768px) {
.desktop-only { display: block !important; }
.mobile-only { display: none !important; }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
`}
</style>
<script src="/public/admin-scripts.js"></script>
</AdminLayoutFragment>
);
};
export const AdminUserDetailsPageFragment = ({
user,
sessions = [],
passkeys = [],
grants = [],
allApps = [],
allRoles = [],
}: {
user: any;
sessions?: any[];
passkeys?: any[];
grants?: any[];
allApps?: any[];
allRoles?: any[];
}) => {
return (
<AdminLayoutFragment
title={`Manage @${user.username}`}
currentPath="/admin/users"
>
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="margin: 0 0 0.25rem 0; font-size: 1.75rem; font-weight: 700; color: var(--text-primary);">
User Profile: @{user.username}
</h1>
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: monospace;">
UUID: {user.id}
</span>
</div>
<a
href="/admin/users"
class="btn-outline"
style="text-decoration: none; font-size: 0.85rem; min-height: 36px;"
>
&larr; Back to Users
</a>
</div>
{/* Identity & Status Management */}
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
Identity Details & Status
</h3>
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-end; margin-top: 1rem;">
<form
id="editProfileForm"
onsubmit={`handleUpdateProfile(event, '${user.id}')`}
style="flex: 1; min-width: 260px; display: flex; gap: 0.75rem; align-items: flex-end;"
>
<div style="flex: 1;">
<label style="display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.35rem; color: var(--text-secondary);">
Display Name
</label>
<input
type="text"
id="displayNameInput"
value={user.display_name || ""}
placeholder={`e.g. ${user.username}`}
style="width: 100%;"
/>
</div>
<button type="submit" class="btn-primary" style="min-height: 44px;">
Save
</button>
</form>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button
type="button"
class="btn-outline"
onclick={`updateStatus('${user.id}', '${
user.account_status === "active" ? "suspended" : "active"
}', '${user.username}')`}
>
{user.account_status === "active"
? "Suspend User"
: "Activate User"}
</button>
</div>
</div>
</div>
{/* Application RBAC Access Matrix */}
<div
class="card"
style="border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
<div>
<h3 style="margin: 0; color: var(--text-primary);">
Application Access & RBAC Grants
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.2rem; margin-bottom: 0;">
Manage explicit permissions across registered applications.
</p>
</div>
</div>
{/* Grant New Application Form */}
{allApps && allApps.length > 0 && (
<form
id="grantAccessForm"
onsubmit={`handleGrantAccess(event, '${user.id}')`}
style="display: flex; gap: 0.75rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: 1.25rem; background: var(--surface-muted); padding: 1rem; border-radius: var(--radius-md);"
>
<div style="flex: 1; min-width: 180px;">
<label style="display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.35rem; color: var(--text-secondary);">
Select Application
</label>
<select id="grantAppId" style="width: 100%;">
{allApps.map((app) => (
<option key={app.id} value={app.id}>
{app.name} ({app.spiffe_id})
</option>
))}
</select>
</div>
<div style="flex: 1; min-width: 140px;">
<label style="display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.35rem; color: var(--text-secondary);">
Assign Role
</label>
<select id="grantRole" style="width: 100%;">
{allRoles && allRoles.length > 0
? (
allRoles.map((role) => (
<option key={role.id || role.name} value={role.name}>
{role.name} {role.app_id ? "(App Custom)" : "(Global)"}
</option>
))
)
: (
<>
<option value="user">user</option>
<option value="admin">admin</option>
<option value="viewer">viewer</option>
<option value="operator">operator</option>
</>
)}
</select>
</div>
<button type="submit" class="btn-primary" style="min-height: 44px;">
+ Assign Grant
</button>
</form>
)}
{/* Grants Table */}
<div class="table-container">
<table>
<thead>
<tr>
<th>Application</th>
<th>SPIFFE Workload ID</th>
<th>Assigned Role</th>
<th>Granted At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{grants.length === 0
? (
<tr>
<td
colSpan={5}
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
>
No application permissions granted (Default-Deny).
</td>
</tr>
)
: (
grants.map((grant) => (
<tr key={grant.id}>
<td>
<strong style="color: var(--text-primary);">
{grant.app_name}
</strong>
</td>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace;">
{grant.spiffe_id}
</code>
</td>
<td>
<span class="badge badge-info">{grant.role}</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(grant.created_at).toLocaleDateString()}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
>
Revoke
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Out-of-Band Account Recovery */}
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
Out-of-Band Account Recovery
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1rem 0;">
Generate a one-time emergency link allowing the user to bind a new
passkey if all devices are lost.
</p>
<button
type="button"
class="btn-primary"
onclick={`generateRecoveryLink('${user.id}')`}
>
Generate Recovery Link
</button>
<div
id="recovery-link-container"
style="display: none; margin-top: 1rem; padding: 1rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-md);"
>
<p style="margin-top: 0; font-weight: 600; color: var(--text-primary);">
Provide this emergency link to the user:
</p>
<code
id="recovery-link-text"
style="display: block; word-break: break-all; margin-bottom: 0.5rem; color: var(--primary); font-family: monospace; background: var(--surface-card); padding: 0.5rem; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);"
>
</code>
<p style="margin-bottom: 0; font-size: 0.85rem; color: var(--text-muted);">
Link expires in 24 hours.
</p>
</div>
</div>
{/* Active Sessions */}
<div class="card" style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; color: var(--text-primary);">
Active Sessions ({sessions.length})
</h3>
{sessions.length > 0 && (
<button
type="button"
class="btn-danger"
onclick={`revokeAllSessions('${user.id}')`}
>
Revoke All Sessions
</button>
)}
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Created</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{sessions.length === 0
? (
<tr>
<td
colSpan={4}
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
>
No active sessions found.
</td>
</tr>
)
: (
sessions.map((session) => (
<tr key={session.id}>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-family: monospace;">
{session.id.substring(0, 12)}...
</code>
</td>
<td style="color: var(--text-secondary);">
{new Date(session.created_at).toLocaleString()}
</td>
<td style="color: var(--text-secondary);">
{new Date(session.expires_at).toLocaleString()}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeSession('${session.id}')`}
>
Revoke
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Registered Passkeys */}
<div class="card">
<h3 style="margin: 0 0 1rem 0; color: var(--text-primary);">
Registered Passkeys ({passkeys.length})
</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>Credential ID</th>
<th>Counter</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{passkeys.length === 0
? (
<tr>
<td
colSpan={3}
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
>
No registered passkeys.
</td>
</tr>
)
: (
passkeys.map((pk) => (
<tr key={pk.id}>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); word-break: break-all; font-family: monospace;">
{pk.credential_id.substring(0, 32)}...
</code>
</td>
<td style="color: var(--text-secondary);">
{pk.counter}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`deletePasskey('${user.id}', '${pk.id}')`}
>
Delete Device
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<script src="/public/admin-scripts.js"></script>
</AdminLayoutFragment>
);
};
export const AdminAppsPageFragment = ({
apps,
}: {
apps: any[];
}) => {
return (
<AdminLayoutFragment title="Application Registry" currentPath="/admin/apps">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Connected Applications
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Register and manage subsidiary workloads and Zero-Trust SPIFFE
identities.
</p>
</div>
<div style="position: relative; min-width: 240px; max-width: 320px; width: 100%;">
<input
type="text"
id="appSearchInput"
placeholder="Search apps by name, domain..."
oninput="filterAppsList()"
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
</div>
{/* Desktop Table */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table id="appsTable">
<thead>
<tr>
<th>Application Name</th>
<th>SPIFFE Workload ID</th>
<th>Domain</th>
<th>Active Grants</th>
</tr>
</thead>
<tbody>
{apps.length === 0
? (
<tr>
<td
colSpan={4}
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
>
No connected applications registered.
</td>
</tr>
)
: (
apps.map((app) => (
<tr
key={app.id}
class="app-row"
data-search={`${app.name} ${app.domain || ""} ${
app.spiffe_id || ""
}`.toLowerCase()}
>
<td>
<strong style="color: var(--text-primary);">
{app.name}
</strong>
</td>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace; color: var(--primary);">
{app.spiffe_id}
</code>
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{app.domain || "-"}
</td>
<td>
<span class="badge badge-info">
{app.active_grants_count || 0} users
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Mobile Card Deck (< 768px) */}
<div
id="appsMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;"
>
{apps.map((app) => (
<div
class="card app-card"
key={app.id}
data-search={`${app.name} ${app.domain || ""} ${
app.spiffe_id || ""
}`.toLowerCase()}
style="margin-bottom: 0;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
{app.name}
</h3>
<span class="badge badge-info">
{app.active_grants_count || 0} users
</span>
</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.5rem; font-family: monospace;">
{app.spiffe_id}
</div>
<div style="font-size: 0.85rem; color: var(--text-muted);">
Domain: {app.domain || "-"}
</div>
</div>
))}
</div>
<style>
{`
@media (min-width: 768px) {
.desktop-only { display: block !important; }
.mobile-only { display: none !important; }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
`}
</style>
<script src="/public/admin-scripts.js"></script>
</AdminLayoutFragment>
);
};
export const AuditLogPageFragment = ({
logs,
}: {
logs: any[];
}) => {
return (
<AdminLayoutFragment title="Audit Logs" currentPath="/admin/audit-logs">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
Immutable Audit Ledger
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Cryptographically chained Merkle audit trail for authentication,
authorization, and administrative events.
</p>
</div>
<div style="position: relative; min-width: 240px; max-width: 320px; width: 100%;">
<input
type="text"
id="auditSearchInput"
placeholder="Search action, user, IP..."
oninput="filterAuditLogs()"
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
</div>
{/* Desktop Table (>= 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table id="auditTable">
<thead>
<tr>
<th>Timestamp</th>
<th>Event Action</th>
<th>User / Subject</th>
<th>Resource</th>
<th>IP Address</th>
</tr>
</thead>
<tbody>
{logs.map((log) => {
const isFail = log.action.includes("fail") ||
log.action.includes("denied");
const isSuccess = log.action.includes("success") ||
log.action.includes("create") ||
log.action.includes("activate") ||
log.action.includes("updated");
const badgeClass = isFail
? "badge-danger"
: isSuccess
? "badge-success"
: "badge-info";
return (
<tr
key={log.id}
class="log-row"
data-search={`${log.action} ${log.user || "system"} ${
log.resource || ""
} ${log.ip_address || ""}`.toLowerCase()}
>
<td style="font-size: 0.8rem; color: var(--text-muted); white-space: nowrap;">
{new Date(log.created_at).toLocaleString()}
</td>
<td>
<span class={`badge ${badgeClass}`}>
{log.action}
</span>
</td>
<td>
<strong style="color: var(--text-primary);">
{log.user || "System"}
</strong>
</td>
<td style="color: var(--text-secondary);">
{log.resource || "-"}
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{log.ip_address || "-"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Mobile Card Deck (< 768px) */}
<div
id="auditMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;"
>
{logs.map((log) => {
const isFail = log.action.includes("fail") ||
log.action.includes("denied");
const isSuccess = log.action.includes("success") ||
log.action.includes("create") ||
log.action.includes("activate") ||
log.action.includes("updated");
const badgeClass = isFail
? "badge-danger"
: isSuccess
? "badge-success"
: "badge-info";
return (
<div
class="card log-card"
key={log.id}
data-search={`${log.action} ${log.user || "system"} ${
log.resource || ""
} ${log.ip_address || ""}`.toLowerCase()}
style="margin-bottom: 0;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem;">
<span class={`badge ${badgeClass}`}>
{log.action}
</span>
<span style="font-size: 0.75rem; color: var(--text-muted);">
{new Date(log.created_at).toLocaleTimeString()}
</span>
</div>
<div style="font-weight: 600; color: var(--text-primary); margin-bottom: 0.25rem;">
{log.user || "System"}
</div>
<div style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.25rem;">
Resource: {log.resource || "-"}
</div>
<div style="font-size: 0.8rem; font-family: monospace; color: var(--text-muted);">
IP: {log.ip_address || "-"}
</div>
</div>
);
})}
</div>
<style>
{`
@media (min-width: 768px) {
.desktop-only { display: block !important; }
.mobile-only { display: none !important; }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
`}
</style>
<script src="/public/admin-scripts.js"></script>
</AdminLayoutFragment>
);
};

View File

@ -0,0 +1,378 @@
import { AdminLayoutFragment } from "../../shared/ui/fragments.tsx";
export const AdminUserDetailsPageFragment = ({
user,
sessions = [],
passkeys = [],
grants = [],
allApps = [],
allRoles = [],
}: {
user: any;
sessions?: any[];
passkeys?: any[];
grants?: any[];
allApps?: any[];
allRoles?: any[];
}) => {
return (
<AdminLayoutFragment
title={`Manage @${user.username}`}
currentPath="/admin/users"
>
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="margin: 0 0 0.25rem 0; font-size: 1.75rem; font-weight: 700; color: var(--text-primary);">
User Profile: @{user.username}
</h1>
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: monospace;">
UUID: {user.id}
</span>
</div>
<a
href="/admin/users"
class="btn-outline"
style="text-decoration: none; font-size: 0.85rem; min-height: 36px;"
>
&larr; Back to Users
</a>
</div>
{/* Identity & Status Management */}
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
Identity Details & Status
</h3>
<div style="display: flex; gap: 1.5rem; flex-wrap: wrap; align-items: flex-end; margin-top: 1rem;">
<form
id="editProfileForm"
onsubmit={`handleUpdateProfile(event, '${user.id}')`}
style="flex: 1; min-width: 260px; display: flex; gap: 0.75rem; align-items: flex-end;"
>
<div style="flex: 1;">
<label style="display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.35rem; color: var(--text-secondary);">
Display Name
</label>
<input
type="text"
id="displayNameInput"
value={user.display_name || ""}
placeholder={`e.g. ${user.username}`}
style="width: 100%;"
/>
</div>
<button type="submit" class="btn-primary" style="min-height: 44px;">
Save
</button>
</form>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button
type="button"
class="btn-outline"
onclick={`updateStatus('${user.id}', '${
user.account_status === "active" ? "suspended" : "active"
}', '${user.username}')`}
>
{user.account_status === "active"
? "Suspend User"
: "Activate User"}
</button>
</div>
</div>
</div>
{/* Application RBAC Access Matrix */}
<div
class="card"
style="border-left: 4px solid var(--primary); margin-bottom: 1.5rem;"
>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
<div>
<h3 style="margin: 0; color: var(--text-primary);">
Application Access & RBAC Grants
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.2rem; margin-bottom: 0;">
Manage explicit permissions across registered applications.
</p>
</div>
</div>
{/* Grant New Application Form */}
{allApps && allApps.length > 0 && (
<form
id="grantAccessForm"
onsubmit={`handleGrantAccess(event, '${user.id}')`}
style="display: flex; gap: 0.75rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: 1.25rem; background: var(--surface-muted); padding: 1rem; border-radius: var(--radius-md);"
>
<div style="flex: 1; min-width: 180px;">
<label style="display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.35rem; color: var(--text-secondary);">
Select Application
</label>
<select id="grantAppId" style="width: 100%;">
{allApps.map((app) => (
<option key={app.id} value={app.id}>
{app.name} ({app.spiffe_id})
</option>
))}
</select>
</div>
<div style="flex: 1; min-width: 140px;">
<label style="display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.35rem; color: var(--text-secondary);">
Assign Role
</label>
<select id="grantRole" style="width: 100%;">
{allRoles && allRoles.length > 0
? (
allRoles.map((role) => (
<option key={role.id || role.name} value={role.name}>
{role.name} {role.app_id ? "(App Custom)" : "(Global)"}
</option>
))
)
: (
<>
<option value="user">user</option>
<option value="admin">admin</option>
<option value="viewer">viewer</option>
<option value="operator">operator</option>
</>
)}
</select>
</div>
<button type="submit" class="btn-primary" style="min-height: 44px;">
+ Assign Grant
</button>
</form>
)}
{/* Grants Table */}
<div class="table-container">
<table>
<thead>
<tr>
<th>Application</th>
<th>SPIFFE Workload ID</th>
<th>Assigned Role</th>
<th>Granted At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{grants.length === 0
? (
<tr>
<td
colSpan={5}
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
>
No application permissions granted (Default-Deny).
</td>
</tr>
)
: (
grants.map((grant) => (
<tr key={grant.id}>
<td>
<strong style="color: var(--text-primary);">
{grant.app_name}
</strong>
</td>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.8rem; font-family: monospace;">
{grant.spiffe_id}
</code>
</td>
<td>
<span class="badge badge-info">{grant.role}</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">
{new Date(grant.created_at).toLocaleDateString()}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeGrant('${user.id}', '${grant.app_id}', '${grant.app_name}')`}
>
Revoke
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Out-of-Band Account Recovery */}
<div class="card" style="margin-bottom: 1.5rem;">
<h3 style="margin: 0 0 0.5rem 0; color: var(--text-primary);">
Out-of-Band Account Recovery
</h3>
<p style="color: var(--text-secondary); font-size: 0.9rem; margin: 0 0 1rem 0;">
Generate a one-time emergency link allowing the user to bind a new
passkey if all devices are lost.
</p>
<button
type="button"
class="btn-primary"
onclick={`generateRecoveryLink('${user.id}')`}
>
Generate Recovery Link
</button>
<div
id="recovery-link-container"
style="display: none; margin-top: 1rem; padding: 1rem; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-md);"
>
<p style="margin-top: 0; font-weight: 600; color: var(--text-primary);">
Provide this emergency link to the user:
</p>
<code
id="recovery-link-text"
style="display: block; word-break: break-all; margin-bottom: 0.5rem; color: var(--primary); font-family: monospace; background: var(--surface-card); padding: 0.5rem; border-radius: var(--radius-sm); border: 1px solid var(--border-subtle);"
>
</code>
<p style="margin-bottom: 0; font-size: 0.85rem; color: var(--text-muted);">
Link expires in 24 hours.
</p>
</div>
</div>
{/* Active Sessions */}
<div class="card" style="margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0; color: var(--text-primary);">
Active Sessions ({sessions.length})
</h3>
{sessions.length > 0 && (
<button
type="button"
class="btn-danger"
onclick={`revokeAllSessions('${user.id}')`}
>
Revoke All Sessions
</button>
)}
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Created</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{sessions.length === 0
? (
<tr>
<td
colSpan={4}
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
>
No active sessions found.
</td>
</tr>
)
: (
sessions.map((session) => (
<tr key={session.id}>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); font-family: monospace;">
{session.id.substring(0, 12)}...
</code>
</td>
<td style="color: var(--text-secondary);">
{new Date(session.created_at).toLocaleString()}
</td>
<td style="color: var(--text-secondary);">
{new Date(session.expires_at).toLocaleString()}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`revokeSession('${session.id}')`}
>
Revoke
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Registered Passkeys */}
<div class="card">
<h3 style="margin: 0 0 1rem 0; color: var(--text-primary);">
Registered Passkeys ({passkeys.length})
</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>Credential ID</th>
<th>Counter</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{passkeys.length === 0
? (
<tr>
<td
colSpan={3}
style="text-align: center; color: var(--text-muted); padding: 1.5rem;"
>
No registered passkeys.
</td>
</tr>
)
: (
passkeys.map((pk) => (
<tr key={pk.id}>
<td>
<code style="background: var(--surface-muted); padding: 0.2rem 0.4rem; border-radius: var(--radius-sm); word-break: break-all; font-family: monospace;">
{pk.credential_id.substring(0, 32)}...
</code>
</td>
<td style="color: var(--text-secondary);">
{pk.counter}
</td>
<td>
<button
type="button"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px;"
onclick={`deletePasskey('${user.id}', '${pk.id}')`}
>
Delete Device
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<script src="/public/admin-scripts.js"></script>
</AdminLayoutFragment>
);
};

View File

@ -0,0 +1,204 @@
import { AdminLayoutFragment } from "../../shared/ui/fragments.tsx";
export const AdminUsersPageFragment = ({
users,
}: {
users: any[];
}) => {
return (
<AdminLayoutFragment title="User Directory" currentPath="/admin/users">
<div
id="status-banner"
style="display: none; margin-bottom: 1rem; padding: 0.75rem 1rem; border-radius: var(--radius-md); font-size: 0.9rem;"
/>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<div>
<h1 style="font-size: 1.75rem; font-weight: 700; margin: 0 0 0.5rem 0; color: var(--text-primary);">
User Directory
</h1>
<p style="color: var(--text-secondary); margin: 0; font-size: 0.95rem;">
Manage user accounts, view active sessions, and oversee permission
grants.
</p>
</div>
<div style="position: relative; min-width: 240px; max-width: 320px; width: 100%;">
<input
type="text"
id="userSearchInput"
placeholder="Search username or name..."
oninput="filterUsersList()"
style="width: 100%; padding: 0.5rem 1rem 0.5rem 2.25rem; font-size: 0.875rem;"
/>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
style="position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: var(--text-muted); pointer-events: none;"
>
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
</div>
{/* Desktop Table (>= 768px) */}
<div class="card desktop-only" style="display: none;">
<div class="table-container">
<table id="usersTable">
<thead>
<tr>
<th>Username</th>
<th>Display Name</th>
<th>Account Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => {
const statusClass = user.account_status === "active"
? "badge-success"
: user.account_status === "suspended"
? "badge-danger"
: "badge-warning";
return (
<tr
key={user.id}
class="user-row"
data-search={`${user.username} ${
user.display_name || ""
} ${user.account_status}`.toLowerCase()}
>
<td>
<strong style="color: var(--text-primary); font-family: monospace;">
@{user.username}
</strong>
</td>
<td>{user.display_name || "-"}</td>
<td>
<span class={`badge ${statusClass}`}>
{user.account_status}
</span>
</td>
<td>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<a
href={`/admin/users/${user.id}`}
class="btn-outline"
style="padding: 0.35rem 0.75rem; font-size: 0.8rem; min-height: 32px; height: 32px; display: inline-flex; align-items: center; gap: 0.35rem; text-decoration: none; font-weight: 600;"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z">
</path>
</svg>
<span>Manage</span>
</a>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Mobile Card Deck (< 768px) */}
<div
id="usersMobileDeck"
class="mobile-only"
style="display: flex; flex-direction: column; gap: 1rem;"
>
{users.map((user) => {
const statusClass = user.account_status === "active"
? "badge-success"
: user.account_status === "suspended"
? "badge-danger"
: "badge-warning";
return (
<div
class="card user-card"
key={user.id}
data-search={`${user.username} ${
user.display_name || ""
} ${user.account_status}`.toLowerCase()}
style="margin-bottom: 0;"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
<div style="display: flex; align-items: center; gap: 0.65rem;">
<div style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; background: var(--primary-light); color: var(--primary); border-radius: var(--radius-md); font-weight: 700; font-size: 1.1rem;">
{(user.display_name || user.username).charAt(0)
.toUpperCase()}
</div>
<div>
<h3 style="margin: 0; font-size: 1.05rem; color: var(--text-primary);">
{user.display_name || user.username}
</h3>
<span style="font-size: 0.8rem; color: var(--text-muted); font-family: monospace;">
@{user.username}
</span>
</div>
</div>
<span class={`badge ${statusClass}`}>
{user.account_status}
</span>
</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1rem;">
<a
href={`/admin/users/${user.id}`}
class="btn-outline"
style="flex: 1; text-decoration: none; justify-content: center; min-height: 42px; font-size: 0.875rem; font-weight: 600; gap: 0.4rem;"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z">
</path>
</svg>
<span>Manage User</span>
</a>
</div>
</div>
);
})}
</div>
<style>
{`
@media (min-width: 768px) {
.desktop-only { display: block !important; }
.mobile-only { display: none !important; }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
.mobile-only { display: flex !important; }
}
`}
</style>
<script src="/public/admin-scripts.js"></script>
</AdminLayoutFragment>
);
};

View File

@ -1,448 +1,3 @@
import { LayoutFragment } from "../../shared/ui/fragments.tsx"; export { LoginPageFragment } from "./login_fragments.tsx";
export { RegisterPageFragment } from "./register_fragments.tsx";
export const LoginPageFragment = () => { export { RecoveryPageFragment } from "./recovery_fragments.tsx";
return (
<LayoutFragment title="Sign In">
<div data-ignore>
<div class="brand-header">
<div class="brand-logo">
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h1>Welcome Back</h1>
<p class="subtitle">
Sign in securely using your biometric passkey or hardware key.
</p>
</div>
{/* Primary Biometric Hero Button */}
<button
type="button"
id="loginBtn"
class="btn-primary"
style="width: 100%; min-height: 52px; font-size: 1.05rem; border-radius: var(--radius-md); box-shadow: var(--shadow-sm);"
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="7.5" cy="15.5" r="5.5"></circle>
<path d="m21 2-9.6 9.6"></path>
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
</svg>
<span>Sign In with Passkey</span>
</button>
{/* Loading Indicator */}
<div
id="loadingIndicator"
style="display: none; margin-top: 1.25rem; text-align: center; color: var(--primary); font-size: 0.9rem; font-weight: 500;"
>
<div style="display: inline-flex; align-items: center; gap: 0.5rem;">
<svg
style="animation: spin 1s linear infinite;"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<circle
cx="12"
cy="12"
r="10"
stroke-dasharray="32"
stroke-dashoffset="12"
>
</circle>
</svg>
<span>Touch biometric sensor or scan passkey...</span>
</div>
</div>
<div id="statusMessage"></div>
{/* Progressive Disclosure for Non-Resident Keys & Recovery */}
<details style="margin-top: 2rem; border-top: 1px solid var(--border-subtle); padding-top: 1.25rem; text-align: left;">
<summary style="color: var(--text-secondary); font-size: 0.875rem; font-weight: 600; cursor: pointer; user-select: none;">
Advanced & Recovery Options
</summary>
<div style="margin-top: 1rem;">
<label
for="loginUsername"
style="display: block; font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;"
>
Specify Username (Optional)
</label>
<input
type="text"
id="loginUsername"
autocomplete="username webauthn"
placeholder="e.g. pilot_alice"
style="margin-bottom: 0.75rem;"
/>
<p style="font-size: 0.8rem; color: var(--text-muted); margin: 0 0 1rem 0;">
Only required if using legacy, non-discoverable security keys.
</p>
<div style="text-align: center; border-top: 1px dashed var(--border-subtle); padding-top: 0.75rem;">
<a
href="/recovery"
style="color: var(--text-secondary); font-size: 0.85rem; text-decoration: none; font-weight: 500;"
>
🔑 Lost device? Reconstruct account with Recovery Voucher
</a>
</div>
</div>
</details>
<div class="links">
Don't have an account? <a href="/register">Register with Invite</a> |
{" "}
<a href="/join">Join with PIN</a>
</div>
<style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}
</style>
<script src="/public/auth-client.js?v=6"></script>
<script src="/public/webauthn-login.js"></script>
</div>
</LayoutFragment>
);
};
export const RegisterPageFragment = (
{ initialCode = "" }: { initialCode?: string },
) => {
return (
<LayoutFragment title="Create Account">
<div data-ignore>
<div class="brand-header">
<div class="brand-logo">
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
</svg>
</div>
<h1 id="registerTitle">Create Account</h1>
<p class="subtitle" id="registerSubtitle">
Enroll a biometric passkey using your invitation token.
</p>
</div>
{/* Step 1: Registration Form */}
<div id="step1Container">
<div style="text-align: left; margin-bottom: 1.25rem;">
<label
for="username"
style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;"
>
Username
</label>
<input
type="text"
id="username"
autocomplete="username"
placeholder="e.g. pilot_alice"
required
autofocus={!initialCode}
/>
</div>
<div style="text-align: left; margin-bottom: 1.5rem;">
<label
for="inviteCode"
style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;"
>
Invite Code
</label>
<input
type="text"
id="inviteCode"
placeholder="Paste invite token..."
value={initialCode}
required
/>
</div>
<button
type="button"
id="registerBtn"
class="btn-primary"
style="width: 100%; min-height: 50px; font-size: 1rem; border-radius: var(--radius-md); box-shadow: var(--shadow-sm);"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="7.5" cy="15.5" r="5.5"></circle>
<path d="m21 2-9.6 9.6"></path>
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
</svg>
<span>Register Device Passkey</span>
</button>
<div
id="loadingIndicator"
style="display: none; margin-top: 1.25rem; text-align: center; color: var(--primary); font-size: 0.9rem; font-weight: 500;"
>
<div style="display: inline-flex; align-items: center; gap: 0.5rem;">
<svg
style="animation: spin 1s linear infinite;"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<circle
cx="12"
cy="12"
r="10"
stroke-dasharray="32"
stroke-dashoffset="12"
>
</circle>
</svg>
<span>Follow prompt on your device sensor...</span>
</div>
</div>
<div id="statusMessage"></div>
<div class="links">
Already registered? <a href="/login">Sign in</a> |{" "}
<a href="/join">Join with PIN</a>
</div>
</div>
{/* Step 2: Emergency 12-Word Recovery Voucher */}
<div id="step2Container" style="display: none; text-align: left;">
<div style="background: var(--success-bg); border: 1px solid var(--success-border); padding: 1rem; border-radius: var(--radius-md); margin-bottom: 1.5rem;">
<div style="font-weight: 700; color: var(--success-text); margin-bottom: 0.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<span>Passkey Enrolled!</span>
</div>
<p style="margin: 0; font-size: 0.85rem; color: var(--success-text);">
Save your 12-word recovery voucher. If you ever lose this device,
these words allow you to restore access.
</p>
</div>
<label style="display: block; font-size: 0.875rem; font-weight: 700; color: var(--text-primary); margin-bottom: 0.5rem;">
Your 12-Word Recovery Voucher
</label>
<div
id="wordGrid"
style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; background: var(--surface-muted); padding: 1rem; border-radius: var(--radius-md); border: 1px solid var(--border-subtle); margin-bottom: 1rem;"
>
{/* Populated dynamically */}
</div>
<button
type="button"
id="copyWordsBtn"
class="btn-outline"
style="width: 100%; margin-bottom: 1.5rem;"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2">
</path>
</svg>
<span>Copy All Words</span>
</button>
<a
href="/dashboard"
class="btn-primary"
style="width: 100%; min-height: 48px; text-decoration: none;"
>
Continue to Dashboard →
</a>
</div>
<style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.word-cell {
background: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 0.35rem 0.65rem;
font-size: 0.85rem;
font-family: monospace;
display: flex;
align-items: center;
gap: 0.5rem;
}
.word-num {
color: var(--text-muted);
font-size: 0.75rem;
width: 18px;
}
.word-text {
color: var(--text-primary);
font-weight: 600;
}
`}
</style>
<script src="/public/auth-client.js?v=6"></script>
<script type="module" src="/public/webauthn-register.js"></script>
</div>
</LayoutFragment>
);
};
export const RecoveryPageFragment = () => {
return (
<LayoutFragment title="Account Recovery">
<div data-ignore>
<div class="brand-header">
<div class="brand-logo">
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="7.5" cy="15.5" r="5.5"></circle>
<path d="m21 2-9.6 9.6"></path>
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
</svg>
</div>
<h2>Account Recovery</h2>
<p class="subtitle">
Reconstruct your master secret and enroll a new replacement passkey.
</p>
</div>
<form id="recovery-form" style="text-align: left;">
<input type="hidden" id="recovery-code" name="code" />
<div style="margin-bottom: 1.25rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;">
Recovery PIN
</label>
<input
type="password"
id="recovery-pin"
placeholder="Enter your secret recovery PIN"
required
/>
</div>
<div style="margin-bottom: 1.25rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;">
Recovery Method
</label>
<select id="recovery-method">
<option value="voucher">Cold Voucher (12-Word Mnemonic)</option>
<option value="device">Device Share (Browser PRF)</option>
</select>
</div>
<div id="voucher-section" style="margin-bottom: 1.5rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;">
12-Word Recovery Voucher
</label>
<textarea
id="recovery-voucher"
rows={3}
placeholder="abandon ability able about above..."
style="font-family: monospace; font-size: 0.9rem;"
>
</textarea>
</div>
<button
type="submit"
id="reconstructBtn"
class="btn-primary"
style="width: 100%; min-height: 50px; font-size: 1rem;"
>
Reconstruct & Bind New Passkey
</button>
</form>
<div id="error-message" class="error" style="display: none;"></div>
<div id="success-message" class="success" style="display: none;">
Passkey successfully bound! Redirecting to login...
</div>
<div class="links">
Remembered your key? <a href="/login">Back to sign in</a>
</div>
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js">
</script>
<script type="module" src="/public/webauthn-recovery.js"></script>
</div>
</LayoutFragment>
);
};

View File

@ -0,0 +1,137 @@
import { LayoutFragment } from "../../shared/ui/fragments.tsx";
export const LoginPageFragment = () => {
return (
<LayoutFragment title="Sign In">
<div data-ignore>
<div class="brand-header">
<div class="brand-logo">
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h1>Welcome Back</h1>
<p class="subtitle">
Sign in securely using your biometric passkey or hardware key.
</p>
</div>
{/* Primary Biometric Hero Button */}
<button
type="button"
id="loginBtn"
class="btn-primary"
style="width: 100%; min-height: 52px; font-size: 1.05rem; border-radius: var(--radius-md); box-shadow: var(--shadow-sm);"
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="7.5" cy="15.5" r="5.5"></circle>
<path d="m21 2-9.6 9.6"></path>
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
</svg>
<span>Sign In with Passkey</span>
</button>
{/* Loading Indicator */}
<div
id="loadingIndicator"
style="display: none; margin-top: 1.25rem; text-align: center; color: var(--primary); font-size: 0.9rem; font-weight: 500;"
>
<div style="display: inline-flex; align-items: center; gap: 0.5rem;">
<svg
style="animation: spin 1s linear infinite;"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<circle
cx="12"
cy="12"
r="10"
stroke-dasharray="32"
stroke-dashoffset="12"
>
</circle>
</svg>
<span>Touch biometric sensor or scan passkey...</span>
</div>
</div>
<div id="statusMessage"></div>
{/* Progressive Disclosure for Non-Resident Keys & Recovery */}
<details style="margin-top: 2rem; border-top: 1px solid var(--border-subtle); padding-top: 1.25rem; text-align: left;">
<summary style="color: var(--text-secondary); font-size: 0.875rem; font-weight: 600; cursor: pointer; user-select: none;">
Advanced & Recovery Options
</summary>
<div style="margin-top: 1rem;">
<label
for="loginUsername"
style="display: block; font-size: 0.85rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;"
>
Specify Username (Optional)
</label>
<input
type="text"
id="loginUsername"
autocomplete="username webauthn"
placeholder="e.g. pilot_alice"
style="margin-bottom: 0.75rem;"
/>
<p style="font-size: 0.8rem; color: var(--text-muted); margin: 0 0 1rem 0;">
Only required if using legacy, non-discoverable security keys.
</p>
<div style="text-align: center; border-top: 1px dashed var(--border-subtle); padding-top: 0.75rem;">
<a
href="/recovery"
style="color: var(--text-secondary); font-size: 0.85rem; text-decoration: none; font-weight: 500;"
>
🔑 Lost device? Reconstruct account with Recovery Voucher
</a>
</div>
</div>
</details>
<div class="links">
Don't have an account? <a href="/register">Register with Invite</a> |
{" "}
<a href="/join">Join with PIN</a>
</div>
<style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}
</style>
<script src="/public/auth-client.js?v=6"></script>
<script src="/public/webauthn-login.js"></script>
</div>
</LayoutFragment>
);
};

View File

@ -0,0 +1,255 @@
import { Hono } from "jsr:@hono/hono@4";
import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
import { decodeBase64Url } from "jsr:@std/encoding@1/base64url";
import {
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "jsr:@simplewebauthn/server@13";
import type { AuthenticationResponseJSON } from "jsr:@simplewebauthn/server@13";
import { valkey } from "../../core/valkey.ts";
import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts";
import { extractAllSessionIds } from "../../../server/auth-session.ts";
import { auditWrapper } from "../../../server/audit.ts";
import {
createSession,
deleteSession,
getPasskeyByCredentialId,
getPasskeysByUserId,
getUserById,
getUserByUsername,
updatePasskeyCounter,
} from "./queries.ts";
import { LoginPageFragment } from "./login_fragments.tsx";
export const loginRoutes = new Hono();
const rpID = Deno.env.get("RP_ID") ||
(import.meta.main ? undefined : "localhost");
const origin = Deno.env.get("ORIGIN") ||
(import.meta.main ? undefined : "http://localhost");
function getCookieDomain(customRpId?: string): string | undefined {
const envDomain = Deno.env.get("COOKIE_DOMAIN");
if (envDomain) {
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
}
const targetId = customRpId || Deno.env.get("RP_ID") || "";
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
return undefined;
}
const parts = targetId.split(".").filter(Boolean);
if (parts.length >= 2) {
return `.${parts.slice(-2).join(".")}`;
}
return `.${targetId}`;
}
// UI Route
loginRoutes.get("/login", (c) => {
return c.html(LoginPageFragment());
});
// API Routes
loginRoutes.use("/api/login/*", publicRateLimiter);
// Login Challenge
loginRoutes.post("/api/login/challenge", async (c) => {
let body;
try {
body = await c.req.json();
} catch (_err) {
body = {};
}
const username = body.username;
let extensions: any = undefined;
let allowCredentials: any[] | undefined = undefined;
if (username) {
const user = await getUserByUsername(username);
if (user) {
const passkeys = await getPasskeysByUserId(user.id);
if (passkeys.length > 0) {
allowCredentials = passkeys.map((pk: any) => ({
id: pk.credential_id,
type: "public-key",
}));
const prfPasskeys = passkeys.filter((pk: any) =>
pk.prf_enabled && pk.prf_salt
);
if (prfPasskeys.length > 0) {
extensions = {
["prf" as string]: { evalByCredential: {} },
};
for (const pk of prfPasskeys) {
const saltBytes = decodeBase64Url(pk.prf_salt);
extensions["prf"]["evalByCredential"][pk.credential_id] = {
first: saltBytes,
};
}
}
}
}
}
if (!rpID) throw new Error("rpID is missing");
const options = await generateAuthenticationOptions({
rpID,
userVerification: "preferred",
timeout: 60000,
allowCredentials,
extensions,
});
setCookie(c, "expected_authentication_challenge", options.challenge, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
return c.json({ options });
});
// Login Verify
loginRoutes.post("/api/login/verify", async (c) => {
let body;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid request" }, 400);
}
const { response } = body;
const expectedChallenge = getCookie(c, "expected_authentication_challenge");
if (!expectedChallenge) {
return c.json(
{ error: "Missing or expired authentication challenge" },
400,
);
}
const base64CredentialID = response.id;
const passkey = await getPasskeyByCredentialId(base64CredentialID);
if (!passkey) {
return c.json(
{ error: "Passkey not found. Please register your passkey first." },
404,
);
}
const user = await getUserById(passkey.user_id);
if (!user) {
return c.json({ error: "User not found" }, 404);
}
const userId = user.id;
if (user.account_status !== "active") {
auditWrapper.auditLog(
userId,
"login_failed",
null,
{ reason: `Account status is ${user.account_status}` },
getClientIp(c),
);
return c.json(
{ error: "Account is not active. Please contact an administrator." },
403,
);
}
const publicKeyBytes = decodeBase64Url(passkey.public_key);
if (!origin || !rpID) throw new Error("Missing origin or rpID");
let verification;
try {
verification = await verifyAuthenticationResponse({
response: response as AuthenticationResponseJSON,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
credential: {
id: passkey.credential_id,
publicKey: publicKeyBytes,
counter: Number(passkey.counter),
},
});
} catch (error: any) {
return c.json({ error: error.message }, 400);
}
const { verified, authenticationInfo } = verification;
if (!verified || !authenticationInfo) {
auditWrapper.auditLog(
userId,
"login_failed",
null,
{ reason: "verification failed" },
getClientIp(c),
);
return c.json({ error: "Verification failed" }, 400);
}
await updatePasskeyCounter(passkey.id, authenticationInfo.newCounter);
const sessionId = crypto.randomUUID();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
await createSession(sessionId, user.id, expiresAt);
const ttlSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
try {
const sessionData = JSON.stringify({
uuid: user.id,
username: user.username,
});
await valkey.setex(sessionId, ttlSeconds, sessionData);
} catch (_err: unknown) {
auditWrapper.auditLog(
user.id,
"login_failed",
null,
{ reason: "Cache write failure" },
getClientIp(c),
);
return c.json({ error: "Internal server error" }, 500);
}
const oldSessionIds = extractAllSessionIds(c);
if (oldSessionIds.length > 0) {
for (const old of oldSessionIds) {
try {
await valkey.del(old);
await deleteSession(old);
} catch (_e) {}
}
}
const cookieDomain = getCookieDomain(rpID);
setCookie(c, "session_id", sessionId, {
domain: cookieDomain,
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
expires: expiresAt,
});
setCookie(c, "expected_authentication_challenge", "", {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 0,
});
auditWrapper.auditLog(userId, "login_success", null, null, getClientIp(c));
return c.json({ success: true });
});

View File

@ -0,0 +1,93 @@
import { LayoutFragment } from "../../shared/ui/fragments.tsx";
export const RecoveryPageFragment = () => {
return (
<LayoutFragment title="Account Recovery">
<div data-ignore>
<div class="brand-header">
<div class="brand-logo">
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="7.5" cy="15.5" r="5.5"></circle>
<path d="m21 2-9.6 9.6"></path>
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
</svg>
</div>
<h2>Account Recovery</h2>
<p class="subtitle">
Reconstruct your master secret and enroll a new replacement passkey.
</p>
</div>
<form id="recovery-form" style="text-align: left;">
<input type="hidden" id="recovery-code" name="code" />
<div style="margin-bottom: 1.25rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;">
Recovery PIN
</label>
<input
type="password"
id="recovery-pin"
placeholder="Enter your secret recovery PIN"
required
/>
</div>
<div style="margin-bottom: 1.25rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;">
Recovery Method
</label>
<select id="recovery-method">
<option value="voucher">Cold Voucher (12-Word Mnemonic)</option>
<option value="device">Device Share (Browser PRF)</option>
</select>
</div>
<div id="voucher-section" style="margin-bottom: 1.5rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;">
12-Word Recovery Voucher
</label>
<textarea
id="recovery-voucher"
rows={3}
placeholder="abandon ability able about above..."
style="font-family: monospace; font-size: 0.9rem;"
>
</textarea>
</div>
<button
type="submit"
id="reconstructBtn"
class="btn-primary"
style="width: 100%; min-height: 50px; font-size: 1rem;"
>
Reconstruct & Bind New Passkey
</button>
</form>
<div id="error-message" class="error" style="display: none;"></div>
<div id="success-message" class="success" style="display: none;">
Passkey successfully bound! Redirecting to login...
</div>
<div class="links">
Remembered your key? <a href="/login">Back to sign in</a>
</div>
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js">
</script>
<script type="module" src="/public/webauthn-recovery.js"></script>
</div>
</LayoutFragment>
);
};

View File

@ -0,0 +1,193 @@
import { Hono } from "jsr:@hono/hono@4";
import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "jsr:@simplewebauthn/server@13";
import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13";
import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts";
import { auditWrapper } from "../../../server/audit.ts";
import {
bindPasskey,
deletePasskeysByUserId,
deleteRecoverySharesByUserId,
getRecoveryLinkByCode,
getRecoveryShareByUserId,
incrementRecoveryShareAttempts,
markRecoveryLinkUsed,
} from "./queries.ts";
import { RecoveryPageFragment } from "./recovery_fragments.tsx";
export const recoveryRoutes = new Hono();
const rpID = Deno.env.get("RP_ID") ||
(import.meta.main ? undefined : "localhost");
const origin = Deno.env.get("ORIGIN") ||
(import.meta.main ? undefined : "http://localhost");
function constantTimeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
// UI Route
recoveryRoutes.get("/recovery", (c) => {
return c.html(RecoveryPageFragment());
});
// API Routes
recoveryRoutes.use("/api/recovery/*", publicRateLimiter);
// Recovery Challenge
recoveryRoutes.post("/api/recovery/challenge", async (c) => {
try {
const { code, pin } = await c.req.json();
if (!code || !pin) {
return c.json({ error: "Missing recovery code or pin" }, 400);
}
const link = await getRecoveryLinkByCode(code);
if (!link) {
return c.json(
{ error: "Invalid, expired, or already used recovery code" },
400,
);
}
const shareRecord = await getRecoveryShareByUserId(link.user_id);
if (!shareRecord) {
return c.json({
error: "No recovery configuration found for this account.",
}, 400);
}
const pinBuffer = new TextEncoder().encode(pin);
const hashBuffer = await crypto.subtle.digest("SHA-256", pinBuffer);
const pinHash = Array.from(new Uint8Array(hashBuffer)).map((b) =>
b.toString(16).padStart(2, "0")
).join("");
if (!constantTimeCompare(pinHash, shareRecord.pin_hash)) {
await incrementRecoveryShareAttempts(shareRecord.id);
return c.json({ error: "Invalid Recovery PIN" }, 401);
}
if (!rpID) throw new Error("rpID is missing");
const options = await generateRegistrationOptions({
rpName: "Auth-Yes Identity Provider",
rpID: rpID as string,
userID: new TextEncoder().encode(link.user_id),
userName: link.user_id,
attestationType: "none",
authenticatorSelection: {
userVerification: "preferred",
residentKey: "required",
},
supportedAlgorithmIDs: [-8, -7, -257],
extensions: { prf: { eval: { first: new Uint8Array(32) } } } as any,
});
setCookie(c, "expected_recovery_challenge", options.challenge, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
setCookie(c, "recovery_user_id", link.user_id, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
return c.json({ options, serverShareHex: shareRecord.server_share });
} catch (error: any) {
console.error("[Auth API] Recovery Challenge Error:", error);
return c.json({ error: error.message || "Internal server error" }, 500);
}
});
// Recovery Verify
recoveryRoutes.post("/api/recovery/verify", async (c) => {
try {
const { code, response } = await c.req.json();
const expectedChallenge = getCookie(c, "expected_recovery_challenge");
const recoveryUserId = getCookie(c, "recovery_user_id");
if (!expectedChallenge || !recoveryUserId) {
return c.json(
{ error: "Missing or expired recovery session/signature" },
400,
);
}
const link = await getRecoveryLinkByCode(code);
if (!link || link.user_id !== recoveryUserId) {
return c.json({ error: "Invalid or expired recovery code" }, 400);
}
if (!origin || !rpID) throw new Error("Missing origin or rpID");
const verification = await verifyRegistrationResponse({
response: response as RegistrationResponseJSON,
expectedChallenge,
expectedOrigin: origin as string,
expectedRPID: rpID as string,
requireUserVerification: false,
});
if (verification.verified && verification.registrationInfo) {
const { credential, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
const pubKeyBase64 = encodeBase64Url(credential.publicKey);
const aaguid = (credential as any).aaguid ||
(verification.registrationInfo as any)?.aaguid || null;
await deletePasskeysByUserId(link.user_id);
await bindPasskey(
link.user_id,
credential.id,
pubKeyBase64,
credential.counter,
aaguid,
);
await markRecoveryLinkUsed(link.id);
await deleteRecoverySharesByUserId(link.user_id);
auditWrapper.auditLog(
link.user_id,
"account_recovered",
null,
{
aaguid,
credentialDeviceType,
credentialBackedUp,
},
getClientIp(c),
);
setCookie(c, "expected_recovery_challenge", "", { maxAge: 0 });
setCookie(c, "recovery_user_id", "", { maxAge: 0 });
return c.json({ success: true });
} else {
return c.json(
{ error: "Passkey registration failed during recovery" },
400,
);
}
} catch (error: any) {
console.error("[Auth API] Recovery Verify Error:", error);
return c.json({ error: error.message || "Internal server error" }, 400);
}
});

View File

@ -0,0 +1,220 @@
import { LayoutFragment } from "../../shared/ui/fragments.tsx";
export const RegisterPageFragment = (
{ initialCode = "" }: { initialCode?: string },
) => {
return (
<LayoutFragment title="Create Account">
<div data-ignore>
<div class="brand-header">
<div class="brand-logo">
<svg
width="26"
height="26"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
</svg>
</div>
<h1 id="registerTitle">Create Account</h1>
<p class="subtitle" id="registerSubtitle">
Enroll a biometric passkey using your invitation token.
</p>
</div>
{/* Step 1: Registration Form */}
<div id="step1Container">
<div style="text-align: left; margin-bottom: 1.25rem;">
<label
for="username"
style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;"
>
Username
</label>
<input
type="text"
id="username"
autocomplete="username"
placeholder="e.g. pilot_alice"
required
autofocus={!initialCode}
/>
</div>
<div style="text-align: left; margin-bottom: 1.5rem;">
<label
for="inviteCode"
style="display: block; font-size: 0.875rem; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.4rem;"
>
Invite Code
</label>
<input
type="text"
id="inviteCode"
placeholder="Paste invite token..."
value={initialCode}
required
/>
</div>
<button
type="button"
id="registerBtn"
class="btn-primary"
style="width: 100%; min-height: 50px; font-size: 1rem; border-radius: var(--radius-md); box-shadow: var(--shadow-sm);"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="7.5" cy="15.5" r="5.5"></circle>
<path d="m21 2-9.6 9.6"></path>
<path d="m15.5 7.5 3 3L22 7l-3-3"></path>
</svg>
<span>Register Device Passkey</span>
</button>
<div
id="loadingIndicator"
style="display: none; margin-top: 1.25rem; text-align: center; color: var(--primary); font-size: 0.9rem; font-weight: 500;"
>
<div style="display: inline-flex; align-items: center; gap: 0.5rem;">
<svg
style="animation: spin 1s linear infinite;"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<circle
cx="12"
cy="12"
r="10"
stroke-dasharray="32"
stroke-dashoffset="12"
>
</circle>
</svg>
<span>Follow prompt on your device sensor...</span>
</div>
</div>
<div id="statusMessage"></div>
<div class="links">
Already registered? <a href="/login">Sign in</a> |{" "}
<a href="/join">Join with PIN</a>
</div>
</div>
{/* Step 2: Emergency 12-Word Recovery Voucher */}
<div id="step2Container" style="display: none; text-align: left;">
<div style="background: var(--success-bg); border: 1px solid var(--success-border); padding: 1rem; border-radius: var(--radius-md); margin-bottom: 1.5rem;">
<div style="font-weight: 700; color: var(--success-text); margin-bottom: 0.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<span>Passkey Enrolled!</span>
</div>
<p style="margin: 0; font-size: 0.85rem; color: var(--success-text);">
Save your 12-word recovery voucher. If you ever lose this device,
these words allow you to restore access.
</p>
</div>
<label style="display: block; font-size: 0.875rem; font-weight: 700; color: var(--text-primary); margin-bottom: 0.5rem;">
Your 12-Word Recovery Voucher
</label>
<div
id="wordGrid"
style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; background: var(--surface-muted); padding: 1rem; border-radius: var(--radius-md); border: 1px solid var(--border-subtle); margin-bottom: 1rem;"
>
{/* Populated dynamically */}
</div>
<button
type="button"
id="copyWordsBtn"
class="btn-outline"
style="width: 100%; margin-bottom: 1.5rem;"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2">
</path>
</svg>
<span>Copy All Words</span>
</button>
<a
href="/dashboard"
class="btn-primary"
style="width: 100%; min-height: 48px; text-decoration: none;"
>
Continue to Dashboard →
</a>
</div>
<style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.word-cell {
background: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 0.35rem 0.65rem;
font-size: 0.85rem;
font-family: monospace;
display: flex;
align-items: center;
gap: 0.5rem;
}
.word-num {
color: var(--text-muted);
font-size: 0.75rem;
width: 18px;
}
.word-text {
color: var(--text-primary);
font-weight: 600;
}
`}
</style>
<script src="/public/auth-client.js?v=6"></script>
<script type="module" src="/public/webauthn-register.js"></script>
</div>
</LayoutFragment>
);
};

View File

@ -0,0 +1,273 @@
import { Hono } from "jsr:@hono/hono@4";
import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
import { encodeBase64Url } from "jsr:@std/encoding@1/base64url";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from "jsr:@simplewebauthn/server@13";
import type { RegistrationResponseJSON } from "jsr:@simplewebauthn/server@13";
import { valkey } from "../../core/valkey.ts";
import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts";
import { auditWrapper } from "../../../server/audit.ts";
import {
createPasskey,
createSession,
createUser,
getInviteToken,
getUserByUsername,
markInviteTokenUsed,
} from "./queries.ts";
import { RegisterPageFragment } from "./register_fragments.tsx";
export const registerRoutes = new Hono();
const rpID = Deno.env.get("RP_ID") ||
(import.meta.main ? undefined : "localhost");
const origin = Deno.env.get("ORIGIN") ||
(import.meta.main ? undefined : "http://localhost");
function getCookieDomain(customRpId?: string): string | undefined {
const envDomain = Deno.env.get("COOKIE_DOMAIN");
if (envDomain) {
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
}
const targetId = customRpId || Deno.env.get("RP_ID") || "";
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
return undefined;
}
const parts = targetId.split(".").filter(Boolean);
if (parts.length >= 2) {
return `.${parts.slice(-2).join(".")}`;
}
return `.${targetId}`;
}
// UI Route
registerRoutes.get("/register", (c) => {
const code = c.req.query("code") || "";
return c.html(RegisterPageFragment({ initialCode: code }));
});
// API Routes
registerRoutes.use("/api/register/*", publicRateLimiter);
// Register Challenge
registerRoutes.post("/api/register/challenge", async (c) => {
let body;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid payload" }, 400);
}
const { username, inviteCode } = body;
if (!username || !inviteCode) {
return c.json({ error: "Username and invite code are required" }, 400);
}
if (!rpID) throw new Error("rpID missing");
const userIdBytes = new Uint8Array(16);
crypto.getRandomValues(userIdBytes);
const newUserId = crypto.randomUUID();
const options = await generateRegistrationOptions({
rpName: "Auth-Yes Identity",
rpID,
userName: username,
userID: userIdBytes,
attestationType: "direct",
authenticatorSelection: {
residentKey: "required",
requireResidentKey: true,
userVerification: "preferred",
},
timeout: 60000,
extensions: {
["prf" as string]: {},
} as any,
});
setCookie(c, "expected_registration_challenge", options.challenge, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
setCookie(c, "registration_user_id", newUserId, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
return c.json({ options, username });
});
// Verify registration and create UUID/session
registerRoutes.post("/api/register/verify", async (c) => {
try {
const { response, username, inviteCode, upgrade_session } = await c.req
.json();
if (!inviteCode && !upgrade_session) {
return c.json({ error: "inviteCode or upgrade_session required" }, 400);
}
const expectedChallenge = getCookie(c, "expected_registration_challenge");
const registrationUserId = getCookie(c, "registration_user_id");
if (!expectedChallenge || !registrationUserId) {
return c.json({
error: "Missing or expired registration challenge/user ID",
}, 400);
}
let user = await getUserByUsername(username);
if (user) {
return c.json({ error: "Username already exists" }, 409);
}
if (!origin || !rpID) throw new Error("Missing origin or rpID");
let verification;
try {
verification = await verifyRegistrationResponse({
response: response as RegistrationResponseJSON,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
});
} catch (error: any) {
return c.json({ error: error.message }, 400);
}
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
return c.json({ error: "Verification failed" }, 400);
}
const credentialID = registrationInfo.credential.id;
const credentialPublicKey = registrationInfo.credential.publicKey;
const counter = registrationInfo.credential.counter;
const base64CredentialID = typeof credentialID === "string"
? credentialID
: encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer));
const base64PublicKey = encodeBase64Url(
new Uint8Array(credentialPublicKey as unknown as ArrayBuffer),
);
const prfEnabled =
(response.clientExtensionResults as any)?.prf?.enabled === true;
let prfSalt = null;
if (prfEnabled) {
const saltBytes = crypto.getRandomValues(new Uint8Array(32));
prfSalt = encodeBase64Url(saltBytes);
}
if (upgrade_session) {
const sessionDataStr = await valkey.get(upgrade_session);
if (!sessionDataStr) {
return c.json({ error: "Invalid or expired guest session" }, 400);
}
const sessionData = JSON.parse(sessionDataStr);
if (
!sessionData || !sessionData.uuid ||
sessionData.account_status !== "guest"
) {
return c.json({ error: "Invalid guest session state" }, 400);
}
const guestUuid = sessionData.uuid;
user = await createUser(guestUuid, username);
await createPasskey(
user.id,
base64CredentialID,
base64PublicKey,
counter,
registrationInfo.aaguid || "00000000-0000-0000-0000-000000000000",
"Unknown Device",
prfEnabled,
prfSalt,
);
await valkey.setex(
upgrade_session,
28800,
JSON.stringify({ uuid: guestUuid, username, account_status: "active" }),
);
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000);
await createSession(upgrade_session, user.id, expiresAt);
} else {
const invite = await getInviteToken(inviteCode);
if (!invite) {
return c.json(
{ error: "Invalid, expired, or fully claimed invite code" },
400,
);
}
user = await createUser(registrationUserId, username);
await createPasskey(
user.id,
base64CredentialID,
base64PublicKey,
counter,
registrationInfo.aaguid || "00000000-0000-0000-0000-000000000000",
"Unknown Device",
prfEnabled,
prfSalt,
);
await markInviteTokenUsed(invite.id, user.id);
}
auditWrapper.auditLog(
user.id,
"user_registered",
null,
{ username, inviteCode },
getClientIp(c),
);
setCookie(c, "expected_registration_challenge", "", {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 0,
});
setCookie(c, "registration_user_id", "", {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 0,
});
const cookieDomain = getCookieDomain(rpID);
if (cookieDomain) {
setCookie(c, "session_id", "", {
domain: cookieDomain,
path: "/",
maxAge: 0,
});
}
setCookie(c, "session_id", "", { path: "/", maxAge: 0 });
return c.json({ success: true });
} catch (error: any) {
console.error(
"[Auth API] Uncaught Exception in /api/register/verify:",
error,
);
return c.json({ error: error.message || "Internal server error" }, 500);
}
});

View File

@ -1,652 +1,12 @@
import { Hono } from "jsr:@hono/hono@4"; import { Hono } from "jsr:@hono/hono@4";
import { getCookie, setCookie } from "jsr:@hono/hono@4/cookie";
import {
decodeBase64Url,
encodeBase64Url,
} from "jsr:@std/encoding@1/base64url";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "jsr:@simplewebauthn/server@13";
import type {
AuthenticationResponseJSON,
RegistrationResponseJSON,
} from "jsr:@simplewebauthn/server@13";
import { valkey } from "../../core/valkey.ts"; import { loginRoutes } from "./login_routes.ts";
import { getClientIp, publicRateLimiter } from "../../../server/middleware.ts"; import { registerRoutes } from "./register_routes.ts";
import { extractAllSessionIds } from "../../../server/auth-session.ts"; import { recoveryRoutes } from "./recovery_routes.ts";
import { auditWrapper } from "../../../server/audit.ts";
import {
bindPasskey,
createPasskey,
createSession,
createUser,
deletePasskeysByUserId,
deleteRecoverySharesByUserId,
deleteSession,
getInviteToken,
getPasskeyByCredentialId,
getPasskeysByUserId,
getRecoveryLinkByCode,
getRecoveryShareByUserId,
getUserById,
getUserByUsername,
incrementRecoveryShareAttempts,
markInviteTokenUsed,
markRecoveryLinkUsed,
updatePasskeyCounter,
} from "./queries.ts";
import {
LoginPageFragment,
RecoveryPageFragment,
RegisterPageFragment,
} from "./fragments.tsx";
export const authRoutes = new Hono(); export const authRoutes = new Hono();
const rpID = Deno.env.get("RP_ID") || // Mount modular sub-routers
(import.meta.main ? undefined : "localhost"); authRoutes.route("/", loginRoutes);
const origin = Deno.env.get("ORIGIN") || authRoutes.route("/", registerRoutes);
(import.meta.main ? undefined : "http://localhost"); authRoutes.route("/", recoveryRoutes);
function getCookieDomain(customRpId?: string): string | undefined {
const envDomain = Deno.env.get("COOKIE_DOMAIN");
if (envDomain) {
return envDomain.startsWith(".") ? envDomain : `.${envDomain}`;
}
const targetId = customRpId || Deno.env.get("RP_ID") || "";
if (!targetId || !targetId.includes(".") || targetId === "localhost") {
return undefined;
}
const parts = targetId.split(".").filter(Boolean);
if (parts.length >= 2) {
return `.${parts.slice(-2).join(".")}`;
}
return `.${targetId}`;
}
// Pages
authRoutes.get("/login", (c) => {
return c.html(<LoginPageFragment />);
});
authRoutes.get("/register", (c) => {
const code = c.req.query("code") || "";
return c.html(<RegisterPageFragment initialCode={code} />);
});
authRoutes.get("/recovery", (c) => {
return c.html(<RecoveryPageFragment />);
});
// API Routes
authRoutes.use("/api/login/*", publicRateLimiter);
authRoutes.use("/api/register/*", publicRateLimiter);
// Login Challenge
authRoutes.post("/api/login/challenge", async (c) => {
let body;
try {
body = await c.req.json();
} catch (_err) {
body = {};
}
const username = body.username;
let extensions: any = undefined;
let allowCredentials: any[] | undefined = undefined;
if (username) {
const user = await getUserByUsername(username);
if (user) {
const passkeys = await getPasskeysByUserId(user.id);
if (passkeys.length > 0) {
allowCredentials = passkeys.map((pk: any) => ({
id: pk.credential_id,
type: "public-key",
}));
const prfPasskeys = passkeys.filter((pk: any) =>
pk.prf_enabled && pk.prf_salt
);
if (prfPasskeys.length > 0) {
extensions = {
["prf" as string]: { evalByCredential: {} },
};
for (const pk of prfPasskeys) {
const saltBytes = decodeBase64Url(pk.prf_salt);
extensions["prf"]["evalByCredential"][pk.credential_id] = {
first: saltBytes,
};
}
}
}
}
}
if (!rpID) throw new Error("rpID is missing");
const options = await generateAuthenticationOptions({
rpID,
userVerification: "preferred",
timeout: 60000,
allowCredentials,
extensions,
});
setCookie(c, "expected_authentication_challenge", options.challenge, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
return c.json({ options });
});
// Login Verify
authRoutes.post("/api/login/verify", async (c) => {
// const clientType = determineClientType(c);
let body;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid request" }, 400);
}
const { response } = body;
const expectedChallenge = getCookie(c, "expected_authentication_challenge");
if (!expectedChallenge) {
return c.json(
{ error: "Missing or expired authentication challenge" },
400,
);
}
const base64CredentialID = response.id;
const passkey = await getPasskeyByCredentialId(base64CredentialID);
if (!passkey) {
return c.json({
error: "Passkey not found. Please register your passkey first.",
}, 404);
}
const user = await getUserById(passkey.user_id);
if (!user) {
return c.json({ error: "User not found" }, 404);
}
const userId = user.id;
if (user.account_status !== "active") {
auditWrapper.auditLog(userId, "login_failed", null, {
reason: `Account status is ${user.account_status}`,
}, getClientIp(c));
return c.json({
error: "Account is not active. Please contact an administrator.",
}, 403);
}
const publicKeyBytes = decodeBase64Url(passkey.public_key);
if (!origin || !rpID) throw new Error("Missing origin or rpID");
let verification;
try {
verification = await verifyAuthenticationResponse({
response: response as AuthenticationResponseJSON,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
credential: {
id: passkey.credential_id,
publicKey: publicKeyBytes,
counter: Number(passkey.counter),
},
});
} catch (error: any) {
return c.json({ error: error.message }, 400);
}
const { verified, authenticationInfo } = verification;
if (!verified || !authenticationInfo) {
auditWrapper.auditLog(userId, "login_failed", null, {
reason: "verification failed",
}, getClientIp(c));
return c.json({ error: "Verification failed" }, 400);
}
await updatePasskeyCounter(passkey.id, authenticationInfo.newCounter);
const sessionId = crypto.randomUUID();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
await createSession(sessionId, user.id, expiresAt);
const ttlSeconds = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
try {
const sessionData = JSON.stringify({
uuid: user.id,
username: user.username,
});
await valkey.setex(sessionId, ttlSeconds, sessionData);
} catch (_err: unknown) {
auditWrapper.auditLog(user.id, "login_failed", null, {
reason: "Cache write failure",
}, getClientIp(c));
return c.json({ error: "Internal server error" }, 500);
}
const oldSessionIds = extractAllSessionIds(c);
if (oldSessionIds.length > 0) {
for (const old of oldSessionIds) {
try {
await valkey.del(old);
await deleteSession(old);
} catch (_e) {}
}
}
const cookieDomain = getCookieDomain(rpID);
setCookie(c, "session_id", sessionId, {
domain: cookieDomain,
path: "/",
httpOnly: true,
secure: true,
sameSite: "Lax",
expires: expiresAt,
});
setCookie(c, "expected_authentication_challenge", "", {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 0,
});
auditWrapper.auditLog(userId, "login_success", null, null, getClientIp(c));
return c.json({ success: true });
});
// Register Challenge
authRoutes.post("/api/register/challenge", async (c) => {
let body;
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid payload" }, 400);
}
const { username, inviteCode } = body;
if (!username || !inviteCode) {
return c.json({ error: "Username and invite code are required" }, 400);
}
if (!rpID) throw new Error("rpID missing");
const userIdBytes = new Uint8Array(16);
crypto.getRandomValues(userIdBytes);
const newUserId = crypto.randomUUID();
const options = await generateRegistrationOptions({
rpName: "Auth-Yes Identity",
rpID,
userName: username,
userID: userIdBytes,
attestationType: "direct",
authenticatorSelection: {
residentKey: "required",
requireResidentKey: true,
userVerification: "preferred",
},
timeout: 60000,
extensions: {
["prf" as string]: {},
} as any,
});
setCookie(c, "expected_registration_challenge", options.challenge, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
setCookie(c, "registration_user_id", newUserId, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
return c.json({ options, username });
});
// Verify registration and create UUID/session
authRoutes.post("/api/register/verify", async (c) => {
try {
const { response, username, inviteCode, upgrade_session } = await c.req
.json();
if (!inviteCode && !upgrade_session) {
return c.json({ error: "inviteCode or upgrade_session required" }, 400);
}
const expectedChallenge = getCookie(c, "expected_registration_challenge");
const registrationUserId = getCookie(c, "registration_user_id");
if (!expectedChallenge || !registrationUserId) {
return c.json({
error: "Missing or expired registration challenge/user ID",
}, 400);
}
let user = await getUserByUsername(username);
if (user) {
return c.json({ error: "Username already exists" }, 409);
}
if (!origin || !rpID) throw new Error("Missing origin or rpID");
let verification;
try {
verification = await verifyRegistrationResponse({
response: response as RegistrationResponseJSON,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false,
});
} catch (error: any) {
return c.json({ error: error.message }, 400);
}
const { verified, registrationInfo } = verification;
if (!verified || !registrationInfo) {
return c.json({ error: "Verification failed" }, 400);
}
const credentialID = registrationInfo.credential.id;
const credentialPublicKey = registrationInfo.credential.publicKey;
const counter = registrationInfo.credential.counter;
const base64CredentialID = typeof credentialID === "string"
? credentialID
: encodeBase64Url(new Uint8Array(credentialID as unknown as ArrayBuffer));
const base64PublicKey = encodeBase64Url(
new Uint8Array(credentialPublicKey as unknown as ArrayBuffer),
);
const prfEnabled =
(response.clientExtensionResults as any)?.prf?.enabled === true;
let prfSalt = null;
if (prfEnabled) {
const saltBytes = crypto.getRandomValues(new Uint8Array(32));
prfSalt = encodeBase64Url(saltBytes);
}
if (upgrade_session) {
const sessionDataStr = await valkey.get(upgrade_session);
if (!sessionDataStr) {
return c.json({ error: "Invalid or expired guest session" }, 400);
}
const sessionData = JSON.parse(sessionDataStr);
if (
!sessionData || !sessionData.uuid ||
sessionData.account_status !== "guest"
) {
return c.json({ error: "Invalid guest session state" }, 400);
}
const guestUuid = sessionData.uuid;
user = await createUser(guestUuid, username);
await createPasskey(
user.id,
base64CredentialID,
base64PublicKey,
counter,
registrationInfo.aaguid || "00000000-0000-0000-0000-000000000000",
"Unknown Device",
prfEnabled,
prfSalt,
);
await valkey.setex(
upgrade_session,
28800,
JSON.stringify({ uuid: guestUuid, username, account_status: "active" }),
);
const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000);
await createSession(upgrade_session, user.id, expiresAt);
} else {
const invite = await getInviteToken(inviteCode);
if (!invite) {
return c.json(
{ error: "Invalid, expired, or fully claimed invite code" },
400,
);
}
user = await createUser(registrationUserId, username);
await createPasskey(
user.id,
base64CredentialID,
base64PublicKey,
counter,
registrationInfo.aaguid || "00000000-0000-0000-0000-000000000000",
"Unknown Device",
prfEnabled,
prfSalt,
);
await markInviteTokenUsed(invite.id, user.id);
}
auditWrapper.auditLog(
user.id,
"user_registered",
null,
{ username, inviteCode },
getClientIp(c),
);
setCookie(c, "expected_registration_challenge", "", {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 0,
});
setCookie(c, "registration_user_id", "", {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 0,
});
const cookieDomain = getCookieDomain(rpID);
if (cookieDomain) {
setCookie(c, "session_id", "", {
domain: cookieDomain,
path: "/",
maxAge: 0,
});
}
setCookie(c, "session_id", "", { path: "/", maxAge: 0 });
return c.json({ success: true });
} catch (error: any) {
console.error(
"[Auth API] Uncaught Exception in /api/register/verify:",
error,
);
return c.json({ error: error.message || "Internal server error" }, 500);
}
});
// Helper for constant time string comparison
function constantTimeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
authRoutes.use("/api/recovery/*", publicRateLimiter);
// Recovery Challenge
authRoutes.post("/api/recovery/challenge", async (c) => {
try {
const { code, pin } = await c.req.json();
if (!code || !pin) {
return c.json({ error: "Missing recovery code or pin" }, 400);
}
const link = await getRecoveryLinkByCode(code);
if (!link) {
return c.json(
{ error: "Invalid, expired, or already used recovery code" },
400,
);
}
const shareRecord = await getRecoveryShareByUserId(link.user_id);
if (!shareRecord) {
return c.json({
error: "No recovery configuration found for this account.",
}, 400);
}
const pinBuffer = new TextEncoder().encode(pin);
const hashBuffer = await crypto.subtle.digest("SHA-256", pinBuffer);
const pinHash = Array.from(new Uint8Array(hashBuffer)).map((b) =>
b.toString(16).padStart(2, "0")
).join("");
if (!constantTimeCompare(pinHash, shareRecord.pin_hash)) {
await incrementRecoveryShareAttempts(shareRecord.id);
return c.json({ error: "Invalid Recovery PIN" }, 401);
}
if (!rpID) throw new Error("rpID is missing");
const options = await generateRegistrationOptions({
rpName: "Auth-Yes Identity Provider",
rpID: rpID as string,
userID: new TextEncoder().encode(link.user_id),
userName: link.user_id,
attestationType: "none",
authenticatorSelection: {
userVerification: "preferred",
residentKey: "required",
},
supportedAlgorithmIDs: [-8, -7, -257],
extensions: { prf: { eval: { first: new Uint8Array(32) } } } as any,
});
setCookie(c, "expected_recovery_challenge", options.challenge, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
setCookie(c, "recovery_user_id", link.user_id, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 300,
});
return c.json({ options, serverShareHex: shareRecord.server_share });
} catch (error: any) {
console.error("[Auth API] Recovery Challenge Error:", error);
return c.json({ error: error.message || "Internal server error" }, 500);
}
});
// Recovery Verify
authRoutes.post("/api/recovery/verify", async (c) => {
try {
const { code, response, signature } = await c.req.json();
const expectedChallenge = getCookie(c, "expected_recovery_challenge");
const recoveryUserId = getCookie(c, "recovery_user_id");
if (!expectedChallenge || !recoveryUserId || !signature) {
return c.json(
{ error: "Missing or expired recovery session/signature" },
400,
);
}
const link = await getRecoveryLinkByCode(code);
if (!link || link.user_id !== recoveryUserId) {
return c.json({ error: "Invalid or expired recovery code" }, 400);
}
if (!origin || !rpID) throw new Error("Missing origin or rpID");
const verification = await verifyRegistrationResponse({
response: response as RegistrationResponseJSON,
expectedChallenge,
expectedOrigin: origin as string,
expectedRPID: rpID as string,
requireUserVerification: false,
});
if (verification.verified && verification.registrationInfo) {
const { credential, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
const pubKeyBase64 = encodeBase64Url(credential.publicKey);
const aaguid = (credential as any).aaguid ||
(verification.registrationInfo as any)?.aaguid || null;
await deletePasskeysByUserId(link.user_id);
await bindPasskey(
link.user_id,
credential.id,
pubKeyBase64,
credential.counter,
aaguid,
);
await markRecoveryLinkUsed(link.id);
await deleteRecoverySharesByUserId(link.user_id);
auditWrapper.auditLog(
link.user_id,
"account_recovered",
null,
{
aaguid,
credentialDeviceType,
credentialBackedUp,
},
getClientIp(c),
);
setCookie(c, "expected_recovery_challenge", "", { maxAge: 0 });
setCookie(c, "recovery_user_id", "", { maxAge: 0 });
return c.json({ success: true });
} else {
return c.json(
{ error: "Passkey registration failed during recovery" },
400,
);
}
} catch (error: any) {
console.error("[Auth API] Recovery Verify Error:", error);
return c.json({ error: error.message || "Internal server error" }, 400);
}
});

View File

@ -0,0 +1,79 @@
import { LayoutFragment } from "./layout_fragments.tsx";
export const AdminLayoutFragment = ({
children,
title,
currentPath,
}: {
children: any;
title: string;
currentPath: string;
}) => {
const adminNavItems = [
{ label: "Users", href: "/admin/users" },
{ label: "Applications", href: "/admin/apps" },
{ label: "Roles", href: "/admin/roles" },
{ label: "Invite Tokens", href: "/admin/invites" },
{ label: "AAGUID Allow-List", href: "/admin/aaguid" },
{ label: "Audit Logs", href: "/admin/audit-logs" },
];
return (
<LayoutFragment title={title}>
<div class="admin-shell">
{/* Top Header */}
<header class="admin-header">
<div style="display: flex; align-items: center; gap: 0.75rem;">
<a
href="/admin/users"
class="admin-brand"
title="Auth-Yes Admin Console"
>
<span>Auth-Yes</span>
<span class="admin-badge">Admin</span>
</a>
</div>
<div style="display: flex; align-items: center; gap: 0.75rem;">
<a
href="/dashboard"
class="btn-outline"
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 36px; display: inline-flex; align-items: center; justify-content: center; text-decoration: none;"
>
← User Hub
</a>
<a
href="/logout"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 36px; display: inline-flex; align-items: center; justify-content: center; text-decoration: none;"
>
Logout
</a>
</div>
</header>
{/* Sub-Nav Scroller */}
<nav class="admin-nav-bar">
{adminNavItems.map((item) => {
const isActive = currentPath === item.href ||
currentPath.startsWith(item.href + "/");
return (
<a
href={item.href}
class={`admin-nav-item ${isActive ? "active" : ""}`}
>
{item.label}
</a>
);
})}
</nav>
{/* Main Content */}
<main class="admin-main-content">
{children}
</main>
</div>
</LayoutFragment>
);
};

View File

@ -1,428 +1,6 @@
import { COMMON_CSS } from "./CommonStyles.ts"; export {
AuthenticatedLayoutFragment,
export const LayoutFragment = ({ LayoutFragment,
children, } from "./layout_fragments.tsx";
title, export { NavbarFragment } from "./navbar_fragments.tsx";
}: { export { AdminLayoutFragment } from "./admin_layout_fragments.tsx";
children: any;
title: string;
}) => {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="theme-color" content="#0066cc" />
<title>{title} - Auth-Yes</title>
<style>
{`
${COMMON_CSS}
.auth-layout-wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 1.5rem 1rem;
}
.auth-card {
background-color: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 2.25rem 1.75rem;
box-shadow: var(--shadow-md);
width: 100%;
max-width: 440px;
position: relative;
}
.brand-header {
text-align: center;
margin-bottom: 2rem;
}
.brand-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: var(--primary-light);
color: var(--primary);
border-radius: var(--radius-md);
margin-bottom: 1rem;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
color: var(--text-primary);
letter-spacing: -0.025em;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
margin: 0;
line-height: 1.5;
}
.error {
color: var(--danger);
background-color: var(--danger-bg);
border: 1px solid var(--danger-border);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
margin-top: 1rem;
text-align: left;
}
.success {
color: var(--success-text);
background-color: var(--success-bg);
border: 1px solid var(--success-border);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
margin-top: 1rem;
text-align: left;
}
.links {
margin-top: 1.75rem;
text-align: center;
font-size: 0.9rem;
color: var(--text-secondary);
}
.links a {
color: var(--primary);
font-weight: 600;
text-decoration: none;
}
.links a:hover {
text-decoration: underline;
}
/* Admin Header / Layout overrides included from CommonStyles or here if needed */
.admin-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Admin Top Header */
.admin-header {
background-color: var(--surface-card);
border-bottom: 1px solid var(--border-subtle);
padding: 0.75rem 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-sm);
}
.admin-brand {
display: flex;
align-items: center;
gap: 0.65rem;
text-decoration: none;
color: var(--text-primary);
font-weight: 700;
font-size: 1.1rem;
}
.admin-badge {
background-color: #0f172a;
color: #38bdf8;
font-size: 0.75rem;
font-weight: 700;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
letter-spacing: 0.05em;
text-transform: uppercase;
}
/* Sub-Navigation Pill Strip */
.admin-nav-bar {
background-color: var(--surface-card);
border-bottom: 1px solid var(--border-subtle);
padding: 0.5rem 1.25rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
display: flex;
gap: 0.5rem;
white-space: nowrap;
}
.admin-nav-bar::-webkit-scrollbar {
display: none;
}
.admin-nav-item {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.85rem;
border-radius: var(--radius-full);
text-decoration: none;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 600;
border: 1px solid transparent;
transition: all 0.15s ease;
}
.admin-nav-item:hover {
background-color: var(--surface-muted);
color: var(--text-primary);
}
.admin-nav-item.active {
background-color: var(--primary-light);
color: var(--primary);
border-color: var(--primary-ring);
}
/* Admin Main Container */
.admin-main-content {
flex: 1;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem 1rem;
box-sizing: border-box;
}
@media (min-width: 768px) {
.admin-header {
padding: 0.75rem 2rem;
}
.admin-nav-bar {
padding: 0.5rem 2rem;
}
.admin-main-content {
padding: 2.5rem 2rem;
}
}
/* Table Styles */
.table-container {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
th, td {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border-subtle);
font-size: 0.875rem;
}
th {
background-color: var(--surface-muted);
color: var(--text-secondary);
font-weight: 600;
}
tr:last-child td {
border-bottom: none;
}
`}
</style>
{/* Datastar script */}
<script src="/public/datastar-v1.x.js" type="module"></script>
{/* SimpleWebAuthn included on all Layout pages to be available for auth/recovery */}
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js">
</script>
</head>
<body>
<div id="status-banner"></div>
{children}
</body>
</html>
);
};
export const AuthenticatedLayoutFragment = ({
children,
title,
currentPath,
isAdmin = false,
}: {
children: any;
title: string;
currentPath: string;
isAdmin?: boolean;
}) => {
return (
<LayoutFragment title={title}>
<div class="app-shell">
{/* Top Header */}
<NavbarFragment currentPath={currentPath} isAdmin={isAdmin} />
{/* Main Body */}
<main class="main-content">
{children}
</main>
</div>
</LayoutFragment>
);
};
export const NavbarFragment = (
{ currentPath, isAdmin }: { currentPath: string; isAdmin?: boolean },
) => {
return (
<header class="top-bar">
<div style="display: flex; align-items: center;">
<a href="/dashboard" class="brand">
<span class="brand-badge">AY</span>
<span>Auth-Yes</span>
</a>
{/* Desktop Nav Links */}
<nav class="desktop-nav">
<a
href="/dashboard"
class={currentPath === "/dashboard" ? "active" : ""}
>
<span>Launchpad</span>
</a>
<a
href="/dashboard/sessions"
class={currentPath.startsWith("/dashboard/sessions")
? "active"
: ""}
>
<span>Sessions</span>
</a>
<a
href="/dashboard/passkeys"
class={currentPath.startsWith("/dashboard/passkeys")
? "active"
: ""}
>
<span>Passkeys</span>
</a>
{isAdmin && (
<a
href="/admin/users"
class={currentPath.startsWith("/admin") ? "active" : ""}
>
<span>Admin</span>
</a>
)}
</nav>
</div>
<div class="header-actions">
{isAdmin && (
<a
href="/admin/users"
class="btn-primary"
style="padding: 0.4rem 0.85rem; font-size: 0.85rem; min-height: 36px; height: 36px; box-sizing: border-box; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem;"
>
<span>Admin Console</span>
</a>
)}
<a href="/logout" class="logout-link" title="Sign out of your session">
<span>Logout</span>
</a>
</div>
</header>
);
};
export const AdminLayoutFragment = ({
children,
title,
currentPath,
}: {
children: any;
title: string;
currentPath: string;
}) => {
const adminNavItems = [
{ label: "Users", href: "/admin/users" },
{ label: "Applications", href: "/admin/apps" },
{ label: "Roles", href: "/admin/roles" },
{ label: "Invite Tokens", href: "/admin/invites" },
{ label: "AAGUID Allow-List", href: "/admin/aaguid" },
{ label: "Audit Logs", href: "/admin/audit-logs" },
];
return (
<LayoutFragment title={title}>
<div class="admin-shell">
{/* Top Header */}
<header class="admin-header">
<div style="display: flex; align-items: center; gap: 0.75rem;">
<a
href="/admin/users"
class="admin-brand"
title="Auth-Yes Admin Console"
>
<span>Auth-Yes</span>
<span class="admin-badge">Admin</span>
</a>
</div>
<div style="display: flex; align-items: center; gap: 0.75rem;">
<a
href="/dashboard"
class="btn-outline"
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 36px; display: inline-flex; align-items: center; justify-content: center; text-decoration: none;"
>
← User Hub
</a>
<a
href="/logout"
class="btn-danger"
style="padding: 0.35rem 0.75rem; font-size: 0.85rem; min-height: 36px; display: inline-flex; align-items: center; justify-content: center; text-decoration: none;"
>
Logout
</a>
</div>
</header>
{/* Sub-Nav Scroller */}
<nav class="admin-nav-bar">
{adminNavItems.map((item) => {
const isActive = currentPath === item.href ||
currentPath.startsWith(item.href + "/");
return (
<a
href={item.href}
class={`admin-nav-item ${isActive ? "active" : ""}`}
>
{item.label}
</a>
);
})}
</nav>
{/* Main Content */}
<main class="admin-main-content">
{children}
</main>
</div>
</LayoutFragment>
);
};

View File

@ -0,0 +1,287 @@
import { COMMON_CSS } from "./CommonStyles.ts";
import { NavbarFragment } from "./navbar_fragments.tsx";
export const LayoutFragment = ({
children,
title,
}: {
children: any;
title: string;
}) => {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<meta name="theme-color" content="#0066cc" />
<title>{title} - Auth-Yes</title>
<style>
{`
${COMMON_CSS}
.auth-layout-wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 1.5rem 1rem;
}
.auth-card {
background-color: var(--surface-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 2.25rem 1.75rem;
box-shadow: var(--shadow-md);
width: 100%;
max-width: 440px;
position: relative;
}
.brand-header {
text-align: center;
margin-bottom: 2rem;
}
.brand-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
background: var(--primary-light);
color: var(--primary);
border-radius: var(--radius-md);
margin-bottom: 1rem;
}
h1 {
font-size: 1.6rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
color: var(--text-primary);
letter-spacing: -0.025em;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
margin: 0;
line-height: 1.5;
}
.error {
color: var(--danger);
background-color: var(--danger-bg);
border: 1px solid var(--danger-border);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
margin-top: 1rem;
text-align: left;
}
.success {
color: var(--success-text);
background-color: var(--success-bg);
border: 1px solid var(--success-border);
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
margin-top: 1rem;
text-align: left;
}
.links {
margin-top: 1.75rem;
text-align: center;
font-size: 0.9rem;
color: var(--text-secondary);
}
.links a {
color: var(--primary);
font-weight: 600;
text-decoration: none;
}
.links a:hover {
text-decoration: underline;
}
/* Admin Header / Layout overrides included from CommonStyles or here if needed */
.admin-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Admin Top Header */
.admin-header {
background-color: var(--surface-card);
border-bottom: 1px solid var(--border-subtle);
padding: 0.75rem 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--shadow-sm);
}
.admin-brand {
display: flex;
align-items: center;
gap: 0.65rem;
text-decoration: none;
color: var(--text-primary);
font-weight: 700;
font-size: 1.1rem;
}
.admin-badge {
background-color: #0f172a;
color: #38bdf8;
font-size: 0.75rem;
font-weight: 700;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
letter-spacing: 0.05em;
text-transform: uppercase;
}
/* Sub-Navigation Pill Strip */
.admin-nav-bar {
background-color: var(--surface-card);
border-bottom: 1px solid var(--border-subtle);
padding: 0.5rem 1.25rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
display: flex;
gap: 0.5rem;
white-space: nowrap;
}
.admin-nav-bar::-webkit-scrollbar {
display: none;
}
.admin-nav-item {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.85rem;
border-radius: var(--radius-full);
text-decoration: none;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 600;
border: 1px solid transparent;
transition: all 0.15s ease;
}
.admin-nav-item:hover {
background-color: var(--surface-muted);
color: var(--text-primary);
}
.admin-nav-item.active {
background-color: var(--primary-light);
color: var(--primary);
border-color: var(--primary-ring);
}
/* Admin Main Container */
.admin-main-content {
flex: 1;
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem 1rem;
box-sizing: border-box;
}
@media (min-width: 768px) {
.admin-header {
padding: 0.75rem 2rem;
}
.admin-nav-bar {
padding: 0.5rem 2rem;
}
.admin-main-content {
padding: 2.5rem 2rem;
}
}
/* Table Styles */
.table-container {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
th, td {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border-subtle);
font-size: 0.875rem;
}
th {
background-color: var(--surface-muted);
color: var(--text-secondary);
font-weight: 600;
}
tr:last-child td {
border-bottom: none;
}
`}
</style>
{/* Datastar script */}
<script src="/public/datastar-v1.x.js" type="module"></script>
{/* SimpleWebAuthn included on all Layout pages to be available for auth/recovery */}
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js">
</script>
</head>
<body>
<div id="status-banner"></div>
{children}
</body>
</html>
);
};
export const AuthenticatedLayoutFragment = ({
children,
title,
currentPath,
isAdmin = false,
}: {
children: any;
title: string;
currentPath: string;
isAdmin?: boolean;
}) => {
return (
<LayoutFragment title={title}>
<div class="app-shell">
{/* Top Header */}
<NavbarFragment currentPath={currentPath} isAdmin={isAdmin} />
{/* Main Body */}
<main class="main-content">
{children}
</main>
</div>
</LayoutFragment>
);
};

View File

@ -0,0 +1,63 @@
export const NavbarFragment = (
{ currentPath, isAdmin }: { currentPath: string; isAdmin?: boolean },
) => {
return (
<header class="top-bar">
<div style="display: flex; align-items: center;">
<a href="/dashboard" class="brand">
<span class="brand-badge">AY</span>
<span>Auth-Yes</span>
</a>
{/* Desktop Nav Links */}
<nav class="desktop-nav">
<a
href="/dashboard"
class={currentPath === "/dashboard" ? "active" : ""}
>
<span>Launchpad</span>
</a>
<a
href="/dashboard/sessions"
class={currentPath.startsWith("/dashboard/sessions")
? "active"
: ""}
>
<span>Sessions</span>
</a>
<a
href="/dashboard/passkeys"
class={currentPath.startsWith("/dashboard/passkeys")
? "active"
: ""}
>
<span>Passkeys</span>
</a>
{isAdmin && (
<a
href="/admin/users"
class={currentPath.startsWith("/admin") ? "active" : ""}
>
<span>Admin</span>
</a>
)}
</nav>
</div>
<div class="header-actions">
{isAdmin && (
<a
href="/admin/users"
class="btn-primary"
style="padding: 0.4rem 0.85rem; font-size: 0.85rem; min-height: 36px; height: 36px; box-sizing: border-box; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem;"
>
<span>Admin Console</span>
</a>
)}
<a href="/logout" class="logout-link" title="Sign out of your session">
<span>Logout</span>
</a>
</div>
</header>
);
};