The admin area was reachable only from the desktop header's is_admin link; on mobile there was no tab or row, so admins had to type the URL and no tab highlighted. Add a conditional Admin row to the mobile Account hub and `/admin` to the Account tab's match prefixes, so the tab stays highlighted while an admin is in the admin area. e2e: an admin session shows an Admin row linking to /admin; a non-admin never sees it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
403 lines
12 KiB
Svelte
403 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { browser } from '$app/environment';
|
|
import { goto } from '$app/navigation';
|
|
import { changePassword, logout } from '$lib/api/auth';
|
|
import { ApiError } from '$lib/api/client';
|
|
import { preferences } from '$lib/preferences.svelte';
|
|
import { session } from '$lib/session.svelte';
|
|
import Sheet from '$lib/components/Sheet.svelte';
|
|
import User from '@lucide/svelte/icons/user';
|
|
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
|
import KeyRound from '@lucide/svelte/icons/key-round';
|
|
import LogOut from '@lucide/svelte/icons/log-out';
|
|
import Shield from '@lucide/svelte/icons/shield';
|
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
|
|
|
let currentPassword = $state('');
|
|
let newPassword = $state('');
|
|
let confirmPassword = $state('');
|
|
let submitting = $state(false);
|
|
let success = $state<string | null>(null);
|
|
let error: string | null = $state(null);
|
|
|
|
let isMobileViewport = $state(false);
|
|
let passwordSheetOpen = $state(false);
|
|
let loggingOut = $state(false);
|
|
|
|
const passwordsMatch = $derived(
|
|
newPassword.length > 0 && newPassword === confirmPassword
|
|
);
|
|
const canSubmit = $derived(
|
|
Boolean(session.user) &&
|
|
currentPassword.length > 0 &&
|
|
newPassword.length >= 8 &&
|
|
passwordsMatch &&
|
|
!submitting
|
|
);
|
|
|
|
const memberSince = $derived(
|
|
session.user ? new Date(session.user.created_at).toLocaleDateString() : null
|
|
);
|
|
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
const mql = window.matchMedia('(max-width: 640px)');
|
|
isMobileViewport = mql.matches;
|
|
const onChange = (e: MediaQueryListEvent) => {
|
|
isMobileViewport = e.matches;
|
|
};
|
|
mql.addEventListener('change', onChange);
|
|
return () => mql.removeEventListener('change', onChange);
|
|
});
|
|
|
|
async function submit(e: SubmitEvent) {
|
|
e.preventDefault();
|
|
if (!canSubmit) return;
|
|
submitting = true;
|
|
success = null;
|
|
error = null;
|
|
try {
|
|
await changePassword({
|
|
current_password: currentPassword,
|
|
new_password: newPassword
|
|
});
|
|
success = 'Password updated. Other devices have been signed out.';
|
|
currentPassword = '';
|
|
newPassword = '';
|
|
confirmPassword = '';
|
|
} catch (e) {
|
|
if (e instanceof ApiError && e.status === 401 && !session.user) {
|
|
await goto('/login');
|
|
return;
|
|
}
|
|
error = e instanceof Error ? e.message : String(e);
|
|
} finally {
|
|
submitting = false;
|
|
}
|
|
}
|
|
|
|
// Mirror the root layout's logout flow so the user lands at /login
|
|
// with the same session-clearing semantics. Replicated here rather
|
|
// than imported because the layout's handler closes over its own
|
|
// local `loggingOut` state.
|
|
async function handleLogout() {
|
|
loggingOut = true;
|
|
try {
|
|
await logout();
|
|
} finally {
|
|
session.setUser(null);
|
|
preferences.clearForLogout();
|
|
loggingOut = false;
|
|
await goto('/login');
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#snippet passwordForm()}
|
|
<form onsubmit={submit} action="javascript:void(0)" data-testid="password-form">
|
|
<label class="form-field">
|
|
<span>Current password</span>
|
|
<input
|
|
type="password"
|
|
bind:value={currentPassword}
|
|
autocomplete="current-password"
|
|
required
|
|
data-testid="current-password"
|
|
/>
|
|
</label>
|
|
<label class="form-field">
|
|
<span>New password</span>
|
|
<input
|
|
type="password"
|
|
bind:value={newPassword}
|
|
autocomplete="new-password"
|
|
minlength="8"
|
|
required
|
|
data-testid="new-password"
|
|
/>
|
|
</label>
|
|
<label class="form-field">
|
|
<span>Confirm new password</span>
|
|
<input
|
|
type="password"
|
|
bind:value={confirmPassword}
|
|
autocomplete="new-password"
|
|
minlength="8"
|
|
required
|
|
data-testid="confirm-password"
|
|
/>
|
|
{#if confirmPassword.length > 0 && !passwordsMatch}
|
|
<span class="field-error" role="alert" data-testid="mismatch">
|
|
Passwords don't match.
|
|
</span>
|
|
{/if}
|
|
</label>
|
|
<button
|
|
class="primary"
|
|
type="submit"
|
|
disabled={!canSubmit}
|
|
data-testid="password-submit"
|
|
>
|
|
{submitting ? 'Updating…' : 'Update password'}
|
|
</button>
|
|
{#if success}
|
|
<p class="success" data-testid="password-success">{success}</p>
|
|
{/if}
|
|
{#if error}
|
|
<p role="alert" class="form-error" data-testid="password-error">{error}</p>
|
|
{/if}
|
|
</form>
|
|
{/snippet}
|
|
|
|
{#if !session.user}
|
|
<p class="status" data-testid="account-signin">
|
|
<a href="/login?next=/profile/account">Sign in</a> to manage your account.
|
|
</p>
|
|
{:else if isMobileViewport}
|
|
<div class="mobile-hub" data-testid="account-hub">
|
|
<header class="hub-profile" data-testid="account-hub-profile">
|
|
<div class="hub-avatar" aria-hidden="true">
|
|
<User size={28} aria-hidden="true" />
|
|
</div>
|
|
<div class="hub-name" data-testid="account-username">
|
|
{session.user.username}
|
|
</div>
|
|
{#if memberSince}
|
|
<div class="hub-meta">Member since {memberSince}</div>
|
|
{/if}
|
|
</header>
|
|
|
|
<div class="row-group">
|
|
<a class="row" href="/profile" data-testid="account-row-profile">
|
|
<User size={18} aria-hidden="true" />
|
|
<span class="row-label">Profile</span>
|
|
<ChevronRight size={16} aria-hidden="true" class="row-chev" />
|
|
</a>
|
|
<a
|
|
class="row"
|
|
href="/profile/preferences"
|
|
data-testid="account-row-preferences"
|
|
>
|
|
<SlidersHorizontal size={18} aria-hidden="true" />
|
|
<span class="row-label">Preferences</span>
|
|
<ChevronRight size={16} aria-hidden="true" class="row-chev" />
|
|
</a>
|
|
<button
|
|
type="button"
|
|
class="row"
|
|
onclick={() => (passwordSheetOpen = true)}
|
|
data-testid="account-row-change-password"
|
|
>
|
|
<KeyRound size={18} aria-hidden="true" />
|
|
<span class="row-label">Change password</span>
|
|
<ChevronRight size={16} aria-hidden="true" class="row-chev" />
|
|
</button>
|
|
</div>
|
|
|
|
{#if session.user.is_admin}
|
|
<!-- Admin is reachable from the desktop header; the mobile chrome
|
|
has no Admin tab, so the Account hub is its entry point. -->
|
|
<div class="row-group">
|
|
<a class="row" href="/admin" data-testid="account-row-admin">
|
|
<Shield size={18} aria-hidden="true" />
|
|
<span class="row-label">Admin</span>
|
|
<ChevronRight size={16} aria-hidden="true" class="row-chev" />
|
|
</a>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="row-group">
|
|
<button
|
|
type="button"
|
|
class="row row-destructive"
|
|
onclick={handleLogout}
|
|
disabled={loggingOut}
|
|
data-testid="account-row-logout"
|
|
>
|
|
<LogOut size={18} aria-hidden="true" />
|
|
<span class="row-label">{loggingOut ? 'Logging out…' : 'Log out'}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<section class="card" data-testid="account-desktop-card">
|
|
<h2>Change password</h2>
|
|
<p class="hint">
|
|
Changing your password signs out every other device using this account.
|
|
Bot API tokens keep working — revoke them individually from the bot-token
|
|
list if you want to invalidate them too.
|
|
</p>
|
|
{@render passwordForm()}
|
|
</section>
|
|
{/if}
|
|
|
|
<Sheet
|
|
open={passwordSheetOpen && isMobileViewport}
|
|
title="Change password"
|
|
onClose={() => (passwordSheetOpen = false)}
|
|
testid="password-sheet"
|
|
>
|
|
<p class="hint sheet-hint">
|
|
Changing your password signs out every other device. Bot API tokens are
|
|
unaffected.
|
|
</p>
|
|
{@render passwordForm()}
|
|
</Sheet>
|
|
|
|
<style>
|
|
.card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-md);
|
|
padding: var(--space-4);
|
|
max-width: 32rem;
|
|
}
|
|
|
|
.hint {
|
|
color: var(--text-muted);
|
|
font-size: var(--font-sm);
|
|
}
|
|
|
|
.sheet-hint {
|
|
margin-top: 0;
|
|
}
|
|
|
|
form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
.primary {
|
|
background: var(--primary);
|
|
color: var(--primary-contrast);
|
|
border-color: var(--primary);
|
|
margin-top: var(--space-1);
|
|
align-self: flex-start;
|
|
}
|
|
|
|
.primary:hover:not(:disabled) {
|
|
background: var(--primary-hover);
|
|
border-color: var(--primary-hover);
|
|
}
|
|
|
|
.field-error {
|
|
color: var(--danger);
|
|
font-size: var(--font-sm);
|
|
}
|
|
|
|
.form-error {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.success {
|
|
color: var(--success);
|
|
}
|
|
|
|
.status {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* ===== Mobile account hub (inset-grouped iOS-style list) ===== */
|
|
|
|
.mobile-hub {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-4);
|
|
padding: var(--space-2) 0;
|
|
}
|
|
|
|
.hub-profile {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: var(--space-1);
|
|
padding: var(--space-4) 0 var(--space-2);
|
|
}
|
|
|
|
.hub-avatar {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 64px;
|
|
height: 64px;
|
|
border-radius: 50%;
|
|
background: var(--surface-elevated);
|
|
color: var(--text-muted);
|
|
border: 1px solid var(--border);
|
|
margin-bottom: var(--space-2);
|
|
}
|
|
|
|
.hub-name {
|
|
font-size: var(--font-lg);
|
|
font-weight: var(--weight-semibold);
|
|
color: var(--text);
|
|
}
|
|
|
|
.hub-meta {
|
|
color: var(--text-muted);
|
|
font-size: var(--font-sm);
|
|
}
|
|
|
|
.row-group {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-lg);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
width: 100%;
|
|
padding: var(--space-3) var(--space-4);
|
|
background: transparent;
|
|
border: 0;
|
|
border-bottom: 1px solid var(--border);
|
|
color: var(--text);
|
|
text-decoration: none;
|
|
text-align: left;
|
|
font-size: var(--font-base);
|
|
min-height: 52px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.row:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.row:hover:not(:disabled) {
|
|
background: var(--surface-elevated);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.row-label {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* Lucide icons render as <svg>; need :global to penetrate scoping
|
|
since we use `class={...}` on the icon, which is hashed by Svelte
|
|
only when the selector matches a local element. */
|
|
.row :global(.row-chev) {
|
|
color: var(--text-muted);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.row-destructive {
|
|
color: var(--danger);
|
|
font-weight: var(--weight-medium);
|
|
justify-content: center;
|
|
}
|
|
|
|
.row-destructive:hover:not(:disabled) {
|
|
background: var(--danger-soft-bg);
|
|
}
|
|
|
|
.row:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
</style>
|