Files
Mangalord/frontend/src/routes/profile/account/+page.svelte
MechaCat02 af6a07bd1f feat(nav): give admins a mobile entry point to the admin area
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>
2026-06-25 21:27:58 +02:00

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>