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:
parent
8b30b17dbe
commit
06cad3d8fe
16
AGENTS.md
16
AGENTS.md
@ -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.
|
||||||
|
|||||||
@ -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).");
|
||||||
}
|
}
|
||||||
|
|||||||
157
src/features/admin/apps_fragments.tsx
Normal file
157
src/features/admin/apps_fragments.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
172
src/features/admin/audit_fragments.tsx
Normal file
172
src/features/admin/audit_fragments.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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;"
|
|
||||||
>
|
|
||||||
← 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
378
src/features/admin/user_details_fragments.tsx
Normal file
378
src/features/admin/user_details_fragments.tsx
Normal 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;"
|
||||||
|
>
|
||||||
|
← 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
204
src/features/admin/users_fragments.tsx
Normal file
204
src/features/admin/users_fragments.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
137
src/features/auth/login_fragments.tsx
Normal file
137
src/features/auth/login_fragments.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
255
src/features/auth/login_routes.ts
Normal file
255
src/features/auth/login_routes.ts
Normal 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 });
|
||||||
|
});
|
||||||
93
src/features/auth/recovery_fragments.tsx
Normal file
93
src/features/auth/recovery_fragments.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
193
src/features/auth/recovery_routes.ts
Normal file
193
src/features/auth/recovery_routes.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
220
src/features/auth/register_fragments.tsx
Normal file
220
src/features/auth/register_fragments.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
273
src/features/auth/register_routes.ts
Normal file
273
src/features/auth/register_routes.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
79
src/shared/ui/admin_layout_fragments.tsx
Normal file
79
src/shared/ui/admin_layout_fragments.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
287
src/shared/ui/layout_fragments.tsx
Normal file
287
src/shared/ui/layout_fragments.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
63
src/shared/ui/navbar_fragments.tsx
Normal file
63
src/shared/ui/navbar_fragments.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user