- Scaffold shared UI fragments and styles in src/shared/ui/ - Implement full Auth vertical slice in src/features/auth/ with WebAuthn ceremonies and recovery endpoints - Implement full Admin vertical slice in src/features/admin/ with responsive tables and mobile decks - Add public client-side JS utilities and pure JS BIP-39 module - Mount routes in src/main.ts and keep legacy server/ and ui/ quarantined - Add pure JSX and API tests for Auth and Admin slices
449 lines
15 KiB
XML
449 lines
15 KiB
XML
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>
|
|
);
|
|
};
|
|
|
|
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>
|
|
);
|
|
};
|