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`
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`No active sessions
` : html`Reactivate your subscription to unlock unlimited transcriptions.
Opens the customer portal where you can update payment methods, view invoices, and manage your subscription.
Activate your subscription to unlock unlimited transcriptions. Note: We currently offer a single subscription tier.
Once you delete your account, there is no going back. This will permanently delete your account and all associated data.
Control which emails you receive from Thistle.
Get notified when your transcription is ready