- 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>
174 lines
5.8 KiB
XML
174 lines
5.8 KiB
XML
import { Layout } from "./Layout.tsx";
|
|
|
|
export const LoginPage = () => {
|
|
return (
|
|
<Layout title="Sign In">
|
|
<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"
|
|
>
|
|
<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>
|
|
</div>
|
|
|
|
<style>
|
|
{`
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
`}
|
|
</style>
|
|
|
|
<script src="/public/auth-client.js?v=6"></script>
|
|
<script
|
|
dangerouslySetInnerHTML={{
|
|
__html: `
|
|
const btn = document.getElementById('loginBtn');
|
|
const loader = document.getElementById('loadingIndicator');
|
|
const status = document.getElementById('statusMessage');
|
|
|
|
btn.addEventListener('click', async () => {
|
|
loader.style.display = 'block';
|
|
btn.disabled = true;
|
|
status.textContent = '';
|
|
status.className = '';
|
|
|
|
try {
|
|
const username = document.getElementById('loginUsername')?.value?.trim() || '';
|
|
await startWebAuthnLogin(username);
|
|
} catch (_e) {
|
|
// handled in auth-client.js
|
|
} finally {
|
|
loader.style.display = 'none';
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// Initialize WebAuthn Conditional UI (Autofill) if supported
|
|
if (window.PublicKeyCredential && PublicKeyCredential.isConditionalMediationAvailable) {
|
|
PublicKeyCredential.isConditionalMediationAvailable().then(available => {
|
|
if (available) {
|
|
console.log("[WebAuthn] Conditional mediation autofill available");
|
|
startWebAuthnConditionalLogin();
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
`,
|
|
}}
|
|
>
|
|
</script>
|
|
</Layout>
|
|
);
|
|
};
|