auth-yes/ui/components/RegisterPage.tsx
google-labs-jules[bot] edbceae0fb feat(events): implement bifurcated join inputs and NAT-safe idempotency
- Adds UI links for joining with PIN in Login and Register pages.
- Normalizes event slugs to lowercase (preserving hyphens) and event PINs to strip all hyphens/spaces to handle raw inputs.
- Implements a pre-check rate limit pattern (`isRateLimited`) to safely enforce a max of 5 failed attempts per IP window (60s) without rate-limiting successful authentications.
- Achieves NAT-safe idempotency in `POST /api/join` by extracting and reusing active event guest sessions instead of blindly incrementing claimed seats on every request.
- Integrates complete test suite coverage for these new constraints.

Co-authored-by: mrteye <1945243+mrteye@users.noreply.github.com>
2026-08-26 23:57:13 +00:00

221 lines
7.2 KiB
XML

import { Layout } from "./Layout.tsx";
import { WebAuthnScript } from "./auth/WebAuthnScript.tsx";
export const RegisterPage = (
{ initialCode = "" }: { initialCode?: string },
) => {
return (
<Layout title="Create Account">
<div>
<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>
</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>
<WebAuthnScript isRegister />
</Layout>
);
};