Files
electric-cherry/src/routes/account/+page.svelte
T
josh b5037a70fe
CI / checks (push) Successful in 6m41s
Fix mobile overflow and touch targets
2026-10-16 17:48:06 -04:00

300 lines
10 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import { onMount } from 'svelte';
import { roleLabels, type AuthUser } from '$lib/auth.js';
import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js';
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
let exampleDate = $state<Date | null>(null);
const zones = $derived([...new Set([...availableTimeZones(), $timeZone])].sort());
onMount(() => {
exampleDate = new Date();
});
import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let busy = $state(false);
let signingOut = $state(false);
let error = $state('');
let notice = $state('');
const user = $derived<AuthUser | null>($page.data.user ?? null);
async function changePassword(event: SubmitEvent) {
event.preventDefault();
if (busy || signingOut) return;
error = '';
notice = '';
if (newPassword !== confirmPassword) {
error = 'The new passwords do not match.';
return;
}
const required = user?.mustChangePassword;
busy = true;
try {
const response = await fetch('/api/auth/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not change your password.');
currentPassword = '';
newPassword = '';
confirmPassword = '';
if (required) {
window.location.assign('/');
return;
}
await invalidateAll();
notice = 'Your password has been changed.';
} catch (cause) {
error = (cause as Error).message;
} finally {
busy = false;
}
}
async function logout() {
if (busy || signingOut) return;
signingOut = true;
error = '';
try {
const response = await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (!response.ok) {
const body = await response.json();
throw new Error(body.error ?? 'Could not sign out.');
}
window.location.assign('/login');
} catch (cause) {
error = (cause as Error).message;
signingOut = false;
}
}
</script>
<svelte:head><title>My account · Electric Cherry</title></svelte:head>
<section class="page">
<SectionHeader
title="My account"
subtitle="Manage your password, display preferences, and current session."
/>
{#if user}
<section class="panel identity">
<div>
<h2>{user.displayName}</h2>
<p>@{user.username} · <span class="role">{roleLabels[user.role]}</span></p>
</div>
<Button disabled={busy || signingOut} loading={signingOut} onclick={logout}
>Sign out</Button
>
</section>
{#if user.mustChangePassword}<p class="required" role="status">
Your account uses a temporary password. Choose a new password before continuing to
Electric Cherry.
</p>{/if}
<section class="panel">
<h2>{user.mustChangePassword ? 'Choose your password' : 'Change password'}</h2>
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p
class="notice"
role="status"
>
{notice}
</p>{/if}
<form onsubmit={changePassword} aria-busy={busy}>
<fieldset disabled={busy || signingOut}>
<Field label="Username" hiddenLabel class="password-username"
><Input
type="text"
value={user.username}
autocomplete="username"
aria-label="Username"
readonly
tabindex={-1}
/></Field
>
<Field
forId="current-password"
label={user.mustChangePassword ? 'Temporary password' : 'Current password'}
>
<Input
id="current-password"
name="currentPassword"
type="password"
autocomplete="current-password"
required
maxlength={128}
bind:value={currentPassword}
/></Field
>
<Field forId="new-password" label="New password">
<Input
id="new-password"
name="newPassword"
type="password"
autocomplete="new-password"
required
minlength={8}
maxlength={128}
bind:value={newPassword}
aria-describedby="new-password-help"
/></Field
>
<p id="new-password-help" class="hint">
Use 8–128 characters and a password you do not use elsewhere.
</p>
<Field forId="confirm-password" label="Confirm new password">
<Input
id="confirm-password"
name="confirmPassword"
type="password"
autocomplete="new-password"
required
minlength={8}
maxlength={128}
bind:value={confirmPassword}
/></Field
>
<div>
<Button
type="submit"
variant="primary"
loading={busy}
disabled={busy || signingOut}
>{user.mustChangePassword
? 'Save password and continue'
: 'Change password'}</Button
>
</div>
</fieldset>
</form>
</section>
<section class="panel" aria-labelledby="display-heading">
<h2 id="display-heading">Display preferences</h2>
<Field class="timezone" label="Timezone">
<Select bind:value={$timeZone} aria-describedby="timezone-help">
{#each zones as zone}<option value={zone}
>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE
? ' (default)'
: ''}</option
>{/each}
</Select>
</Field>
<p id="timezone-help">
Dates and times throughout the app use this timezone, including daylight saving
changes. Saved automatically in this browser.
</p>
{#if exampleDate}<p>Example: {$timeFormat.dateTime(exampleDate)}</p>{/if}
{#if $timeZonePersistenceError}<p role="status">
Browser storage is unavailable. Your timezone applies for this visit but cannot
be saved.
</p>{/if}
</section>
{/if}
</section>
<style>
.panel :global(.timezone) {
display: grid;
gap: 8px;
margin-top: 16px;
font-size: var(--text-sm);
max-width: 420px;
}
.panel :global(.timezone ~ p) {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.page {
max-width: 760px;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 24px;
}
.panel {
padding: 24px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
}
h2 {
font-size: var(--text-md);
margin: 0;
}
.identity {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.identity > div {
min-width: 0;
overflow-wrap: anywhere;
}
.identity p {
margin: 8px 0 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.role {
text-transform: capitalize;
}
.required {
padding: 16px;
margin: 0;
color: var(--text-primary);
background: var(--surface-raised);
border-left: 3px solid var(--accent-primary);
font-size: var(--text-sm);
line-height: 1.6;
}
form {
margin-top: 24px;
max-width: 460px;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
display: grid;
gap: 18px;
min-width: 0;
}
.panel :global(.password-username) {
position: absolute;
width: 1px;
height: 1px;
clip-path: inset(50%);
overflow: hidden;
padding: 0;
border: 0;
}
.hint {
margin: -8px 0 0;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
}
.error {
color: var(--state-danger);
font-size: var(--text-sm);
}
.notice {
color: var(--state-online);
font-size: var(--text-sm);
}
@media (max-width: 540px) {
.identity {
align-items: flex-start;
flex-direction: column;
}
}
</style>