import { css, html, LitElement } from "lit"; import { customElement, state } from "lit/decorators.js"; import { UAParser } from "ua-parser-js"; import { hashPasswordClient } from "../lib/client-auth"; import { isPasskeySupported, registerPasskey } from "../lib/client-passkey"; interface User { email: string; name: string | null; avatar: string; created_at: number; } interface Session { id: string; ip_address: string | null; user_agent: string | null; created_at: number; expires_at: number; is_current: boolean; } interface Passkey { id: string; name: string | null; created_at: number; last_used_at: number | null; } interface Subscription { id: string; status: string; current_period_start: number | null; current_period_end: number | null; cancel_at_period_end: number; canceled_at: number | null; } type SettingsPage = | "account" | "sessions" | "passkeys" | "billing" | "notifications" | "danger"; @customElement("user-settings") export class UserSettings extends LitElement { @state() user: User | null = null; @state() sessions: Session[] = []; @state() passkeys: Passkey[] = []; @state() subscription: Subscription | null = null; @state() loading = true; @state() loadingSessions = true; @state() loadingPasskeys = true; @state() loadingSubscription = true; @state() error = ""; @state() showDeleteConfirm = false; @state() currentPage: SettingsPage = "account"; @state() editingEmail = false; @state() editingPassword = false; @state() newEmail = ""; @state() newPassword = ""; @state() newName = ""; @state() newAvatar = ""; @state() passkeySupported = false; @state() addingPasskey = false; @state() emailNotificationsEnabled = true; @state() deletingAccount = false; @state() emailChangeMessage = ""; @state() pendingEmailChange = ""; @state() updatingEmail = false; static override styles = css` :host { display: block; } .settings-container { max-width: 80rem; margin: 0 auto; padding: 2rem; } h1 { margin-bottom: 1rem; color: var(--text); } .tabs { display: flex; gap: 1rem; border-bottom: 2px solid var(--secondary); margin-bottom: 2rem; } .tab { padding: 0.75rem 1.5rem; border: none; background: transparent; color: var(--text); cursor: pointer; font-size: 1rem; font-weight: 500; font-family: inherit; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s; } .tab:hover { color: var(--primary); } .tab.active { color: var(--primary); border-bottom-color: var(--primary); } .tab-content { display: none; } .tab-content.active { display: block; } .content-inner { max-width: 56rem; } .section { background: var(--background); border: 1px solid var(--secondary); border-radius: 12px; padding: 2rem; margin-bottom: 2rem; } .section-title { font-size: 1.25rem; font-weight: 600; color: var(--text); margin: 0 0 1.5rem 0; } .field-group { margin-bottom: 1.5rem; } .field-group:last-child { margin-bottom: 0; } .field-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; } .field-label { font-weight: 500; color: var(--text); font-size: 0.875rem; margin-bottom: 0.5rem; display: block; } .field-value { font-size: 1rem; color: var(--text); opacity: 0.8; } .change-link { background: none; border: 1px solid var(--secondary); color: var(--text); font-size: 0.875rem; font-weight: 500; cursor: pointer; padding: 0.25rem 0.75rem; border-radius: 6px; font-family: inherit; transition: all 0.2s; } .change-link:hover { border-color: var(--primary); color: var(--primary); } .btn { padding: 0.75rem 1.5rem; border-radius: 6px; font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s; font-family: inherit; border: 2px solid transparent; } .btn-rejection { background: transparent; color: var(--accent); border-color: var(--accent); } .btn-rejection:hover { background: var(--accent); color: white; } .btn-affirmative { background: var(--primary); color: white; border-color: var(--primary); } .btn-affirmative:hover:not(:disabled) { background: transparent; color: var(--primary); } .btn-success { background: var(--success); color: white; border-color: var(--success); } .btn-success:hover:not(:disabled) { background: transparent; color: var(--success); } .btn-small { padding: 0.5rem 1rem; font-size: 0.875rem; } .avatar-container:hover .avatar-overlay { opacity: 1; } .avatar-overlay { position: absolute; top: 0; left: 0; width: 48px; height: 48px; background: rgba(0, 0, 0, 0.2); border-radius: 50%; border: 2px solid transparent; display: flex; align-items: center; justify-content: center; opacity: 0; cursor: pointer; } .reload-symbol { font-size: 18px; color: white; transform: rotate(79deg) translate(0px, -2px); } .profile-row { display: flex; align-items: center; gap: 1rem; } .avatar-container { position: relative; } .field-description { font-size: 0.875rem; color: var(--paynes-gray); margin: 0.5rem 0; } .danger-section { border-color: var(--accent); } .danger-section .section-title { color: var(--accent); } .danger-text { color: var(--text); opacity: 0.7; margin-bottom: 1.5rem; line-height: 1.5; } .success-message { padding: 1rem; background: rgba(76, 175, 80, 0.1); border: 1px solid rgba(76, 175, 80, 0.3); border-radius: 0.5rem; color: var(--text); } .spinner { display: inline-block; width: 1rem; height: 1rem; border: 2px solid rgba(255, 255, 255, 0.3); border-top-color: white; border-radius: 50%; animation: spin 0.6s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .session-list { display: flex; flex-direction: column; gap: 1rem; } .session-card { background: var(--background); border: 1px solid var(--secondary); border-radius: 8px; padding: 1.25rem; } .session-card.current { border-color: var(--accent); background: rgba(239, 131, 84, 0.03); } .session-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; } .session-title { font-weight: 600; color: var(--text); } .current-badge { display: inline-block; background: var(--accent); color: white; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; } .session-details { display: grid; gap: 0.75rem; } .session-row { display: grid; grid-template-columns: 100px 1fr; gap: 1rem; } .session-label { font-weight: 500; color: var(--text); opacity: 0.6; font-size: 0.875rem; } .session-value { color: var(--text); font-size: 0.875rem; } .user-agent { font-family: monospace; word-break: break-all; } .field-input { padding: 0.5rem; border: 1px solid var(--secondary); border-radius: 6px; font-family: inherit; font-size: 1rem; color: var(--text); background: var(--background); flex: 1; } .field-input:focus { outline: none; border-color: var(--primary); } .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; } .modal { background: var(--background); border: 2px solid var(--accent); border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; } .modal h3 { margin-top: 0; color: var(--accent); } .modal-actions { display: flex; gap: 0.5rem; margin-top: 1.5rem; } .btn-neutral { background: transparent; color: var(--text); border-color: var(--secondary); } .btn-neutral:hover { border-color: var(--primary); color: var(--primary); } .error { color: var(--accent); } .error-banner { background: #fecaca; border: 2px solid rgba(220, 38, 38, 0.8); border-radius: 6px; padding: 1rem; margin-bottom: 1.5rem; color: #dc2626; font-weight: 500; } .loading { text-align: center; color: var(--text); padding: 2rem; } .setting-row { display: flex; align-items: center; justify-content: space-between; padding: 1rem; border: 1px solid var(--secondary); border-radius: 6px; gap: 1rem; } .setting-info { flex: 1; } .toggle { position: relative; display: inline-block; width: 48px; height: 24px; } .toggle input { opacity: 0; width: 0; height: 0; } .toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--secondary); transition: 0.2s; border-radius: 24px; } .toggle-slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: 0.2s; border-radius: 50%; } .toggle input:checked + .toggle-slider { background-color: var(--primary); } .toggle input:checked + .toggle-slider:before { transform: translateX(24px); } @media (max-width: 768px) { .settings-container { padding: 1rem; } .tabs { overflow-x: auto; } .tab { white-space: nowrap; } .content-inner { padding: 0; } } `; override async connectedCallback() { super.connectedCallback(); this.passkeySupported = isPasskeySupported(); // Check for tab query parameter const params = new URLSearchParams(window.location.search); const tab = params.get("tab"); if (tab && this.isValidTab(tab)) { this.currentPage = tab as SettingsPage; } await this.loadUser(); await this.loadSessions(); await this.loadSubscription(); if (this.passkeySupported) { await this.loadPasskeys(); } } private isValidTab(tab: string): boolean { return [ "account", "sessions", "passkeys", "billing", "notifications", "danger", ].includes(tab); } private setTab(tab: SettingsPage) { this.currentPage = tab; this.error = ""; // Clear errors when switching tabs // Update URL without reloading page const url = new URL(window.location.href); url.searchParams.set("tab", tab); window.history.pushState({}, "", url); } async loadUser() { try { const response = await fetch("/api/auth/me"); if (!response.ok) { window.location.href = "/"; return; } const data = await response.json(); this.user = data; this.emailNotificationsEnabled = data.email_notifications_enabled ?? true; } finally { this.loading = false; } } async loadSessions() { try { const response = await fetch("/api/sessions"); if (response.ok) { const data = await response.json(); this.sessions = data.sessions; } } finally { this.loadingSessions = false; } } async loadPasskeys() { try { const response = await fetch("/api/passkeys"); if (response.ok) { const data = await response.json(); this.passkeys = data.passkeys; } } finally { this.loadingPasskeys = false; } } async loadSubscription() { try { const response = await fetch("/api/billing/subscription"); if (response.ok) { const data = await response.json(); this.subscription = data.subscription; } } finally { this.loadingSubscription = false; } } async handleAddPasskey() { this.addingPasskey = true; this.error = ""; try { const name = prompt("Name this passkey (optional):"); if (name === null) { // User cancelled return; } const result = await registerPasskey(name || undefined); if (!result.success) { this.error = result.error || "Failed to register passkey"; return; } // Reload passkeys await this.loadPasskeys(); } finally { this.addingPasskey = false; } } async handleDeletePasskey(passkeyId: string) { if (!confirm("Are you sure you want to delete this passkey?")) { return; } this.error = ""; try { const response = await fetch(`/api/passkeys/${passkeyId}`, { method: "DELETE", }); if (!response.ok) { const data = await response.json(); this.error = data.error || "Failed to delete passkey"; return; } // Reload passkeys await this.loadPasskeys(); } catch (err) { this.error = err instanceof Error ? err.message : "Failed to delete passkey"; } } async handleLogout() { this.error = ""; try { const response = await fetch("/api/auth/logout", { method: "POST" }); if (!response.ok) { const data = await response.json(); this.error = data.error || "Failed to logout"; return; } window.location.href = "/"; } catch (err) { this.error = err instanceof Error ? err.message : "Failed to logout"; } } async handleDeleteAccount() { this.deletingAccount = true; this.error = ""; document.body.style.cursor = "wait"; try { const response = await fetch("/api/user", { method: "DELETE", }); if (!response.ok) { const data = await response.json(); this.error = data.error || "Failed to delete account"; return; } window.location.href = "/"; } catch { this.error = "Failed to delete account"; } finally { this.deletingAccount = false; this.showDeleteConfirm = false; document.body.style.cursor = ""; } } async handleUpdateEmail() { this.error = ""; this.emailChangeMessage = ""; if (!this.newEmail) { this.error = "Email required"; return; } this.updatingEmail = true; try { const response = await fetch("/api/user/email", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: this.newEmail }), }); const data = await response.json(); if (!response.ok) { this.error = data.error || "Failed to update email"; return; } // Show success message with pending email this.emailChangeMessage = data.message || "Verification email sent"; this.pendingEmailChange = data.pendingEmail || this.newEmail; this.editingEmail = false; this.newEmail = ""; } catch { this.error = "Failed to update email"; } finally { this.updatingEmail = false; } } async handleUpdatePassword() { this.error = ""; if (!this.newPassword) { this.error = "Password required"; return; } if (this.newPassword.length < 8) { this.error = "Password must be at least 8 characters"; return; } try { // Hash password client-side before sending const passwordHash = await hashPasswordClient( this.newPassword, this.user?.email ?? "", ); const response = await fetch("/api/user/password", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: passwordHash }), }); if (!response.ok) { const data = await response.json(); this.error = data.error || "Failed to update password"; return; } this.editingPassword = false; this.newPassword = ""; } catch { this.error = "Failed to update password"; } } async handleUpdateName() { this.error = ""; if (!this.newName) { this.error = "Name required"; return; } try { const response = await fetch("/api/user/name", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: this.newName }), }); if (!response.ok) { const data = await response.json(); this.error = data.error || "Failed to update name"; return; } // Reload user data await this.loadUser(); this.newName = ""; } catch { this.error = "Failed to update name"; } } async handleUpdateAvatar() { this.error = ""; if (!this.newAvatar) { this.error = "Avatar required"; return; } try { const response = await fetch("/api/user/avatar", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ avatar: this.newAvatar }), }); if (!response.ok) { const data = await response.json(); this.error = data.error || "Failed to update avatar"; return; } // Reload user data await this.loadUser(); this.newAvatar = ""; } catch { this.error = "Failed to update avatar"; } } async handleCreateCheckout() { this.loading = true; this.error = ""; try { const response = await fetch("/api/billing/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, }); if (!response.ok) { const data = await response.json(); this.error = data.error || "Failed to create checkout session"; return; } const { url } = await response.json(); window.open(url, "_blank"); } catch { this.error = "Failed to create checkout session"; } finally { this.loading = false; } } async handleOpenPortal() { this.loading = true; this.error = ""; try { const response = await fetch("/api/billing/portal", { method: "POST", headers: { "Content-Type": "application/json" }, }); if (!response.ok) { const data = await response.json(); this.error = data.error || "Failed to open customer portal"; return; } const { url } = await response.json(); window.open(url, "_blank"); } catch { this.error = "Failed to open customer portal"; } finally { this.loading = false; } } generateRandomAvatar() { // Generate a random string for the avatar const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let result = ""; for (let i = 0; i < 8; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } this.newAvatar = result; this.handleUpdateAvatar(); } formatDate(timestamp: number, future = false): string { const date = new Date(timestamp * 1000); const now = new Date(); const diff = Math.abs(now.getTime() - date.getTime()); // For future dates (like expiration) if (future || date > now) { // Less than a day if (diff < 24 * 60 * 60 * 1000) { const hours = Math.floor(diff / (60 * 60 * 1000)); return `in ${hours} hour${hours === 1 ? "" : "s"}`; } // Less than a week if (diff < 7 * 24 * 60 * 60 * 1000) { const days = Math.floor(diff / (24 * 60 * 60 * 1000)); return `in ${days} day${days === 1 ? "" : "s"}`; } // Show full date return date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, }); } // For past dates // Less than a minute if (diff < 60 * 1000) { return "Just now"; } // Less than an hour if (diff < 60 * 60 * 1000) { const minutes = Math.floor(diff / (60 * 1000)); return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; } // Less than a day if (diff < 24 * 60 * 60 * 1000) { const hours = Math.floor(diff / (60 * 60 * 1000)); return `${hours} hour${hours === 1 ? "" : "s"} ago`; } // Less than a week if (diff < 7 * 24 * 60 * 60 * 1000) { const days = Math.floor(diff / (24 * 60 * 60 * 1000)); return `${days} day${days === 1 ? "" : "s"} ago`; } // Show full date return date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, }); } async handleKillSession(sessionId: string) { this.error = ""; try { const response = await fetch(`/api/sessions`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId }), }); if (!response.ok) { const data = await response.json(); this.error = data.error || "Failed to kill session"; return; } // Reload sessions await this.loadSessions(); } catch { this.error = "Failed to kill session"; } } parseUserAgent(userAgent: string | null): string { if (!userAgent) return "Unknown"; const parser = new UAParser(userAgent); const result = parser.getResult(); const browser = result.browser.name ? `${result.browser.name}${result.browser.version ? ` ${result.browser.version}` : ""}` : ""; const os = result.os.name ? `${result.os.name}${result.os.version ? ` ${result.os.version}` : ""}` : ""; if (browser && os) { return `${browser} on ${os}`; } if (browser) return browser; if (os) return os; return userAgent; } renderAccountPage() { if (!this.user) return html``; const createdDate = new Date( this.user.created_at * 1000, ).toLocaleDateString(); return html`
${ this.error ? html`
${this.error}
` : "" }

Profile Information

Avatar
{ this.newName = (e.target as HTMLInputElement).value; }} @blur=${() => { if (this.newName && this.newName !== (this.user?.name ?? "")) { this.handleUpdateName(); } }} placeholder="Your name" />
${ this.emailChangeMessage ? html`
${this.emailChangeMessage} ${this.pendingEmailChange ? html`
New email: ${this.pendingEmailChange}` : ""}
${this.user.email}
` : this.editingEmail ? html`
{ this.newEmail = (e.target as HTMLInputElement).value; }} placeholder=${this.user.email} />
` : html`
${this.user.email}
` }
${ this.editingPassword ? html`
{ this.newPassword = (e.target as HTMLInputElement).value; }} placeholder="New password" />
` : html`
••••••••
` }
${ this.passkeySupported ? html`

Passkeys provide a more secure and convenient way to sign in without passwords. They use biometric authentication or your device's security features.

${ this.loadingPasskeys ? html`
Loading passkeys...
` : this.passkeys.length === 0 ? html`
No passkeys registered yet
` : html`
${this.passkeys.map( (passkey) => html`
Name ${passkey.name || "Unnamed passkey"}
Created ${new Date(passkey.created_at * 1000).toLocaleDateString()}
${ passkey.last_used_at ? html`
Last used ${new Date(passkey.last_used_at * 1000).toLocaleDateString()}
` : "" }
`, )}
` }
` : "" }
${createdDate}
`; } renderSessionsPage() { return html`
${ this.error ? html`
${this.error}
` : "" }

Active Sessions

${ this.loadingSessions ? html`
Loading sessions...
` : this.sessions.length === 0 ? html`

No active sessions

` : html`
${this.sessions.map( (session) => html`
Session ${session.is_current ? html`Current` : ""}
IP Address ${session.ip_address ?? "Unknown"}
Device ${this.parseUserAgent(session.user_agent)}
Created ${this.formatDate(session.created_at)}
Expires ${this.formatDate(session.expires_at, true)}
${ session.is_current ? html` ` : html` ` }
`, )}
` }
`; } renderBillingPage() { if (this.loadingSubscription) { return html`
Loading subscription...
`; } const hasActiveSubscription = this.subscription && (this.subscription.status === "active" || this.subscription.status === "trialing"); if (this.subscription && !hasActiveSubscription) { // Has a subscription but it's not active (canceled, expired, etc.) const statusColor = this.subscription.status === "canceled" ? "var(--accent)" : "var(--secondary)"; return html`
${ this.error ? html`
${this.error}
` : "" }

Subscription

${this.subscription.status}
${ this.subscription.canceled_at ? html`
${this.formatDate(this.subscription.canceled_at)}
` : "" }

Reactivate your subscription to unlock unlimited transcriptions.

`; } if (hasActiveSubscription) { return html`
${ this.error ? html`
${this.error}
` : "" }

Subscription

${this.subscription.status} ${ this.subscription.cancel_at_period_end ? html` (Cancels at end of period) ` : "" }
${ this.subscription.current_period_start && this.subscription.current_period_end ? html`
${this.formatDate(this.subscription.current_period_start)} - ${this.formatDate(this.subscription.current_period_end)}
` : "" }

Opens the customer portal where you can update payment methods, view invoices, and manage your subscription.

`; } return html`
${ this.error ? html`
${this.error}
` : "" }

Billing & Subscription

Activate your subscription to unlock unlimited transcriptions. Note: We currently offer a single subscription tier.

`; } renderDangerPage() { return html`
${ this.error ? html`
${this.error}
` : "" }

Delete Account

Once you delete your account, there is no going back. This will permanently delete your account and all associated data.

`; } renderNotificationsPage() { return html`
${ this.error ? html`
${this.error}
` : "" }

Email Notifications

Control which emails you receive from Thistle.

Transcription Complete

Get notified when your transcription is ready

`; } override render() { if (this.loading) { return html`
Loading...
`; } if (!this.user) { return html`
No user data available
`; } return html`

Settings

${this.currentPage === "account" ? this.renderAccountPage() : ""} ${this.currentPage === "sessions" ? this.renderSessionsPage() : ""} ${this.currentPage === "billing" ? this.renderBillingPage() : ""} ${this.currentPage === "notifications" ? this.renderNotificationsPage() : ""} ${this.currentPage === "danger" ? this.renderDangerPage() : ""}
${ this.showDeleteConfirm ? html` ` : "" } `; } }