import { css, html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import { hashPasswordClient } from "../lib/client-auth"; import { authenticateWithPasskey, isPasskeySupported, } from "../lib/client-passkey"; import type { PasswordStrength } from "./password-strength"; import "./password-strength"; import type { PasswordStrengthResult } from "./password-strength"; interface User { email: string; name: string | null; avatar: string; role?: "user" | "admin"; has_subscription?: boolean; } @customElement("auth-component") export class AuthComponent extends LitElement { @state() user: User | null = null; @state() loading = true; @state() showModal = false; @state() email = ""; @state() password = ""; @state() name = ""; @state() error = ""; @state() isSubmitting = false; @state() needsRegistration = false; @state() passwordStrength: PasswordStrengthResult | null = null; @state() passkeySupported = false; @state() needsEmailVerification = false; @state() verificationCode = ""; @state() resendCodeTimer = 0; @state() resendingCode = false; private resendInterval: number | null = null; private codeSentAt: number | null = null; // Unix timestamp in seconds when code was sent static override styles = css` :host { display: block; } .auth-container { position: relative; } .auth-button { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; background: var(--primary); color: white; border: 2px solid var(--primary); border-radius: 8px; cursor: pointer; font-size: 1rem; font-weight: 500; transition: all 0.2s; font-family: inherit; } .auth-button:hover { background: transparent; color: var(--primary); } .auth-button:hover .email { color: var(--primary); } .auth-button img { transition: all 0.2s; } .auth-button:hover img { opacity: 0.8; } .user-info { display: flex; align-items: center; gap: 0.75rem; } .email { font-weight: 500; color: white; font-size: 0.875rem; transition: all 0.2s; } .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 2000; padding: 1rem; } .modal { background: var(--background); border: 2px solid var(--secondary); border-radius: 12px; padding: 2rem; max-width: 400px; width: 100%; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); } .modal-title { margin-top: 0; margin-bottom: 1rem; color: var(--text); } .form-group { margin-bottom: 1rem; } label { display: block; margin-bottom: 0.25rem; font-weight: 500; color: var(--text); font-size: 0.875rem; } input { width: 100%; padding: 0.75rem; border: 2px solid var(--secondary); border-radius: 6px; font-size: 1rem; font-family: inherit; background: var(--background); color: var(--text); transition: all 0.2s; box-sizing: border-box; } input::placeholder { color: var(--secondary); opacity: 1; } input:focus { outline: none; border-color: var(--primary); } .error-message { color: var(--accent); font-size: 0.875rem; margin-top: 1rem; } button { padding: 0.75rem 1.5rem; border: 2px solid var(--primary); border-radius: 6px; font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s; font-family: inherit; } button:disabled { opacity: 0.6; cursor: not-allowed; } .btn-primary { background: var(--primary); color: white; flex: 1; } .btn-primary:hover:not(:disabled) { background: transparent; color: var(--primary); } .btn-neutral { background: transparent; color: var(--text); border-color: var(--secondary); } .btn-neutral:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); } .btn-rejection { background: transparent; color: var(--accent); border-color: var(--accent); } .btn-rejection:hover:not(:disabled) { background: var(--accent); color: white; } .modal-actions { display: flex; gap: 0.5rem; margin-top: 1rem; } .user-menu { position: absolute; top: calc(100% + 0.5rem); right: 0; background: var(--background); border: 2px solid var(--secondary); border-radius: 8px; padding: 0.5rem; min-width: 200px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column; gap: 0.5rem; z-index: 100; } .user-menu a, .user-menu button { padding: 0.75rem 1rem; background: transparent; color: var(--text); text-decoration: none; border: none; border-radius: 6px; font-weight: 500; text-align: left; transition: all 0.2s; font-family: inherit; font-size: 1rem; cursor: pointer; } .user-menu a:hover, .user-menu button:hover { background: var(--secondary); } .admin-link { color: #dc2626; border: 2px dashed #dc2626 !important; } .admin-link:hover { background: #fee2e2; color: #991b1b; border-color: #991b1b !important; } .loading { font-size: 0.875rem; color: var(--text); } .info-text { color: var(--text); font-size: 0.875rem; margin: 0 0 1.5rem 0; line-height: 1.5; } .verification-code-input { text-align: center; font-size: 1.5rem; letter-spacing: 0.5rem; font-weight: 600; padding: 1rem; font-family: 'Monaco', 'Courier New', monospace; } .resend-link { text-align: center; margin-top: 1rem; font-size: 0.875rem; color: var(--text); } .resend-button { background: none; border: none; color: var(--primary); cursor: pointer; text-decoration: underline; font-size: 0.875rem; padding: 0; font-family: inherit; } .resend-button:hover:not(:disabled) { color: var(--accent); } .resend-button:disabled { color: var(--secondary); cursor: not-allowed; text-decoration: none; } .btn-secondary { background: transparent; color: var(--text); border-color: var(--secondary); flex: 1; } .btn-secondary:hover:not(:disabled) { border-color: var(--primary); color: var(--primary); } .divider { display: flex; align-items: center; text-align: center; margin: 1.5rem 0; color: var(--secondary); font-size: 0.875rem; } .divider::before, .divider::after { content: ""; flex: 1; border-bottom: 1px solid var(--secondary); } .divider::before { margin-right: 0.5rem; } .divider::after { margin-left: 0.5rem; } .btn-passkey { background: transparent; color: var(--primary); border-color: var(--primary); width: 100%; margin-bottom: 0; } .btn-passkey:hover:not(:disabled) { background: var(--primary); color: white; } `; override async connectedCallback() { super.connectedCallback(); this.passkeySupported = isPasskeySupported(); await this.checkAuth(); } async checkAuth() { try { const response = await fetch("/api/auth/me"); if (response.ok) { this.user = await response.json(); } else if (window.location.pathname !== "/") { window.location.href = "/"; } } finally { this.loading = false; } } public isAuthenticated(): boolean { return this.user !== null; } public openAuthModal() { this.openModal(); } private openModal() { this.showModal = true; this.needsRegistration = false; this.email = ""; this.password = ""; this.name = ""; this.error = ""; } private closeModal() { this.showModal = false; this.needsRegistration = false; this.email = ""; this.password = ""; this.name = ""; this.error = ""; } private async handleSubmit(e: Event) { e.preventDefault(); this.error = ""; this.isSubmitting = true; try { // Hash password client-side with expensive PBKDF2 const passwordHash = await hashPasswordClient(this.password, this.email); if (this.needsRegistration) { const response = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email: this.email, password: passwordHash, name: this.name || null, }), }); if (!response.ok) { const data = await response.json(); this.error = data.error || "Registration failed"; return; } const data = await response.json(); if (data.email_verification_required) { this.needsEmailVerification = true; this.password = ""; this.error = ""; this.startResendTimer(data.verification_code_sent_at); return; } this.user = data; this.closeModal(); await this.checkAuth(); window.dispatchEvent(new CustomEvent("auth-changed")); window.location.href = "/classes"; } else { const response = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email: this.email, password: passwordHash, }), }); if (!response.ok) { const data = await response.json(); if (response.status === 401) { this.needsRegistration = true; this.error = ""; return; } this.error = data.error || "Login failed"; return; } const data = await response.json(); if (data.email_verification_required) { this.needsEmailVerification = true; this.password = ""; this.error = ""; this.startResendTimer(data.verification_code_sent_at); return; } this.user = data; this.closeModal(); await this.checkAuth(); window.dispatchEvent(new CustomEvent("auth-changed")); window.location.href = "/classes"; } } catch (error) { // Catch crypto.subtle errors and other exceptions this.error = error instanceof Error ? error.message : "An error occurred"; } finally { this.isSubmitting = false; } } private async handleLogout() { this.showModal = false; try { await fetch("/api/auth/logout", { method: "POST" }); this.user = null; window.dispatchEvent(new CustomEvent("auth-changed")); window.location.href = "/"; } catch { // Silent fail } } private toggleMenu() { this.showModal = !this.showModal; } private handleEmailInput(e: Event) { this.email = (e.target as HTMLInputElement).value; } private handleNameInput(e: Event) { this.name = (e.target as HTMLInputElement).value; } private handlePasswordInput(e: Event) { this.password = (e.target as HTMLInputElement).value; } private handleVerificationCodeInput(e: Event) { this.verificationCode = (e.target as HTMLInputElement).value; } private async handleVerifyEmail(e: Event) { e.preventDefault(); this.error = ""; this.isSubmitting = true; try { const response = await fetch("/api/auth/verify-email", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email: this.email, code: this.verificationCode, }), }); if (!response.ok) { const data = await response.json(); this.error = data.error || "Verification failed"; return; } // Successfully verified - redirect to classes this.closeModal(); await this.checkAuth(); window.dispatchEvent(new CustomEvent("auth-changed")); window.location.href = "/classes"; } catch (error) { this.error = error instanceof Error ? error.message : "An error occurred"; } finally { this.isSubmitting = false; } } private handlePasswordBlur() { if (!this.needsRegistration) return; const strengthComponent = this.shadowRoot?.querySelector( "password-strength", ) as PasswordStrength | null; if (strengthComponent && this.password) { strengthComponent.checkHIBP(this.password); } } private handleStrengthChange(e: CustomEvent) { this.passwordStrength = e.detail; } private async handlePasskeyLogin() { this.error = ""; this.isSubmitting = true; try { const result = await authenticateWithPasskey(this.email || undefined); if (!result.success) { this.error = result.error || "Passkey authentication failed"; return; } // Success - reload to get user info await this.checkAuth(); this.closeModal(); window.dispatchEvent(new CustomEvent("auth-changed")); window.location.href = "/classes"; } finally { this.isSubmitting = false; } } private startResendTimer(sentAtTimestamp: number) { // Use provided timestamp this.codeSentAt = sentAtTimestamp; // Clear existing interval if any if (this.resendInterval !== null) { clearInterval(this.resendInterval); } // Update timer based on elapsed time const updateTimer = () => { if (this.codeSentAt === null) return; const now = Math.floor(Date.now() / 1000); const elapsed = now - this.codeSentAt; const remaining = Math.max(0, 5 * 60 - elapsed); this.resendCodeTimer = remaining; if (remaining <= 0) { if (this.resendInterval !== null) { clearInterval(this.resendInterval); this.resendInterval = null; } } }; // Update immediately updateTimer(); // Then update every second this.resendInterval = window.setInterval(updateTimer, 1000); } private async handleResendCode() { this.error = ""; this.resendingCode = true; try { const response = await fetch("/api/auth/resend-verification-code", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email: this.email, }), }); if (!response.ok) { const data = await response.json(); this.error = data.error || "Failed to resend code"; return; } // Start the 5-minute timer this.startResendTimer(data.verification_code_sent_at); } catch (error) { this.error = error instanceof Error ? error.message : "An error occurred"; } finally { this.resendingCode = false; } } private formatTimer(seconds: number): string { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, "0")}`; } override disconnectedCallback() { super.disconnectedCallback(); // Clean up timer when component is removed if (this.resendInterval !== null) { clearInterval(this.resendInterval); this.resendInterval = null; } } override render() { if (this.loading) { return html`
Loading...
`; } return html`
${ this.user ? html` ${ this.showModal ? html`
Classes Settings ${ this.user.role === "admin" ? html`Admin` : "" }
` : "" } ` : html` ` }
${ this.showModal && !this.user ? html` ` : "" } `; } }