Files
Oxicloud/frontend/src/routes/profile/+page.svelte
T

1576 lines
46 KiB
Svelte
Raw Normal View History

2026-06-17 17:06:30 -06:00
<script lang="ts">
2026-06-17 22:30:56 -06:00
import { errorToast } from '$lib/utils/errors';
import { relativeTimeAgo } from '$lib/utils/time';
import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { Pathname } from '$app/types';
import { page } from '$app/state';
import { ApiError } from '$lib/api/client';
2026-06-17 22:07:18 -06:00
import {
changePassword,
createAppPassword,
isAutoAppPassword,
listAppPasswords,
revokeAppPassword,
updateAvatar,
updateProfile,
type AppPassword,
type ProfilePatch
} from '$lib/api/endpoints/profile';
import { fetchMe, getOidcProviders, startOidcLink, unlinkOidc } from '$lib/api/endpoints/auth';
2026-06-17 17:06:30 -06:00
import { SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
2026-06-17 22:07:18 -06:00
import Icon from '$lib/icons/Icon.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { preferences } from '$lib/stores/preferences.svelte';
2026-06-17 17:06:30 -06:00
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte';
2026-06-17 22:07:18 -06:00
import { formatBytes } from '$lib/utils/format';
import { formatDate } from '$lib/utils/display';
import { resizeImageToDataUrl } from '$lib/utils/imageResize';
2026-06-17 17:06:30 -06:00
let givenName = $state('');
let familyName = $state('');
let username = $state('');
let preferredLocale = $state<string>('');
let notifyOnShare = $state(true);
// Batched into the profile save flow (same UX as
// `notifyOnShare` above). The `preferences` store is still the
// source of truth for the persisted value — this local mirrors it
// on hydrate, and the diff feeds `patch.ui_preferences` on save
// so the whole card follows one save discipline.
let hideDotfiles = $state(false);
2026-06-17 17:06:30 -06:00
let currentPw = $state('');
let newPw = $state('');
let confirmPw = $state('');
let savingProfile = $state(false);
let savingPassword = $state(false);
2026-06-17 22:07:18 -06:00
let avatarBusy = $state(false);
let passwordLoginEnabled = $state(true);
// OIDC providers snapshot for the SSO link/unlink card. Populated
// on mount; determines whether we render the "Connect SSO" card
// (requires oidcEnabled) and what display label to show.
let oidcEnabled = $state(false);
let oidcProviderName = $state<string>('SSO');
let ssoBusy = $state(false);
2026-06-17 22:07:18 -06:00
// Avatar edit panel.
let avatarEditOpen = $state(false);
let avatarTab = $state<'url' | 'upload'>('url');
let avatarUrl = $state('');
let avatarPreview = $state<string | null>(null);
let uploadedDataUrl = $state<string | null>(null);
let avatarImgFailed = $state(false);
let appPasswords = $state<AppPassword[]>([]);
let appPwLoadFailed = $state(false);
let generated = $state<{ label: string; password: string } | null>(null);
let newLabel = $state('');
let creatingPw = $state(false);
let autoExpanded = $state(false);
2026-08-21 16:49:32 +02:00
const isOidc = $derived(session.me?.full.federation_kind === 'oidc');
const isLocal = $derived(!session.me?.full.federation_kind);
2026-06-17 22:07:18 -06:00
const usernameClaimed = $derived(!!session.user?.username);
const isAdmin = $derived(session.user?.role === 'admin');
2026-08-21 16:49:32 +02:00
const canEditImage = $derived(session.me?.can_edit_image === true && isLocal);
2026-08-05 23:35:05 +02:00
// Show the change-password card when the user CAN change their
// local password: they have `password_hash` on file AND the
// deployment offers password login (backend `change_password`
// refuses on either count — see `AuthApplicationService::change_password`).
// Distinct from the OLD `isLocal && passwordLoginEnabled` gate,
// which refused any SSO-linked account regardless of whether they
// carried a local password. Hybrid accounts (OIDC + local
// password) are a legitimate posture and MUST be able to rotate
// their local credential; the new gate lets them, and the backend
// refusal covers the pure-SSO case where has_password is false.
2026-08-21 16:49:32 +02:00
const showPasswordCard = $derived(
(session.me?.full.has_password ?? false) && passwordLoginEnabled
);
2026-06-17 22:07:18 -06:00
// SSO card gates — see docs/plan/oidc-account-linking.md.
// Connect: only when OIDC is enabled AND the user isn't already linked.
// Disconnect: only when currently OIDC-linked AND the user has an
// alternative auth method (password or OPAQUE-registered) — else
// unlinking would lock them out.
2026-08-21 16:49:32 +02:00
const canConnectSso = $derived(oidcEnabled && !session.me?.full.federation_kind);
// Show the disconnect button whenever the user is OIDC-linked.
// The backend guard (`AuthApplicationService::unlink_oidc`) is the
// source of truth for the "no alternative auth" refusal — it also
// checks `opaque_registered`, which isn't exposed on UserDto today
// (deliberately kept off `/api/auth/me` to avoid leaking OPAQUE
// adoption status through user-directory endpoints). The UI shows
// the button unconditionally and surfaces the backend's 403 as a
// user-facing "set a password first" prompt.
2026-08-21 16:49:32 +02:00
const canDisconnectSso = $derived(session.me?.full.federation_kind === 'oidc');
/**
* Mandatory change-password mode. TRUE when the backend has
* flagged the account (`session.mustChangePassword`) OR the URL
* carries `?forcePasswordChange=1` (arrived here from the login
* form / layout guard). Either signal locks the page into a
* single-purpose form: banner + password card only, other cards
* hidden. The URL param is a belt-and-braces alongside the store
* — a stale-tab session that lost the flag momentarily still
* shows the mandatory UI if the URL says so, and the layout guard
* will bounce a non-flagged user back off `/profile` naturally.
*/
const forceModeQueryParam = $derived(page.url.searchParams.get('forcePasswordChange') === '1');
const mandatoryMode = $derived(session.mustChangePassword || forceModeQueryParam);
/**
* Destination to bounce back to after a successful change. Only
* consulted in mandatory mode; caller-supplied via `?next=<encoded>`
* (added by the layout guard). Falls back to `/files` — the
* standard SPA landing point — when absent or when the value
* isn't a same-origin path (`startsWith('/')`).
*/
const nextAfterChange = $derived.by(() => {
const raw = page.url.searchParams.get('next');
if (!raw) return '/files';
try {
const decoded = decodeURIComponent(raw);
return decoded.startsWith('/') ? decoded : '/files';
} catch {
return '/files';
}
});
2026-08-21 16:49:32 +02:00
const storagePct = $derived.by(() => {
const full = session.me?.full;
if (!full || full.storage_quota_bytes <= 0) return 0;
return Math.min(100, Math.round((full.storage_used_bytes / full.storage_quota_bytes) * 100));
});
2026-06-17 22:07:18 -06:00
const storageBarClass = $derived(
storagePct > 90 ? 'bar__fill--red' : storagePct > 70 ? 'bar__fill--orange' : 'bar__fill--green'
);
const initials = $derived(
(session.user?.username || session.user?.email || '?').slice(0, 2).toUpperCase()
);
const userPasswords = $derived(appPasswords.filter((p) => !isAutoAppPassword(p)));
const autoPasswords = $derived(appPasswords.filter((p) => isAutoAppPassword(p)));
/** Relative time (e.g. "3 days ago"); "Never" when absent. */
2026-06-17 22:30:56 -06:00
const timeAgo = (value: string | null | undefined): string =>
relativeTimeAgo(value, { empty: t('profile.never', 'Never'), invalidAsString: true });
2026-06-17 22:07:18 -06:00
2026-06-17 17:06:30 -06:00
function hydrate() {
2026-08-21 16:49:32 +02:00
const me = session.me;
if (!me) return;
// Public identity (name / handle) reads via `me.full.user`;
// admin-visible extras (preferred_locale) via `me.full`;
// self-only bag flags (notify_on_share) via `me` directly.
// The three-level indirection makes the audience of each
// field visible at the callsite (docs/plan/userdto-refactor.md).
givenName = me.full.user.given_name ?? '';
familyName = me.full.user.family_name ?? '';
username = me.full.user.username ?? '';
preferredLocale = me.full.preferred_locale ?? '';
notifyOnShare = me.notify_on_share;
// Source of truth is the preferences store, which itself
2026-08-21 16:49:32 +02:00
// derives from `session.me.ui_preferences`. Reading through
// the store here (rather than the raw bag) means a new
// preference field just needs a getter in the store and its
// own line here — no wire-format knowledge on the page.
hideDotfiles = preferences.hideDotfiles;
2026-06-17 17:06:30 -06:00
}
async function saveProfile(e: SubmitEvent) {
e.preventDefault();
2026-08-21 16:49:32 +02:00
const me = session.me;
if (!me) return;
2026-06-17 22:07:18 -06:00
// Build a sparse patch of only the fields the user actually changed.
// Sending empty strings the user never touched would 400 on the server.
const patch: ProfilePatch = {};
2026-08-21 16:49:32 +02:00
if (!usernameClaimed && username.trim() && username.trim() !== (me.full.user.username ?? '')) {
2026-06-17 22:07:18 -06:00
patch.username = username.trim();
}
2026-08-21 16:49:32 +02:00
if (givenName.trim() !== (me.full.user.given_name ?? '')) patch.given_name = givenName.trim();
if (familyName.trim() !== (me.full.user.family_name ?? ''))
patch.family_name = familyName.trim();
if ((preferredLocale || '') !== (me.full.preferred_locale ?? '')) {
2026-06-17 22:07:18 -06:00
patch.preferred_locale = preferredLocale || undefined;
}
2026-08-21 16:49:32 +02:00
if (notifyOnShare !== me.notify_on_share) patch.notify_on_share = notifyOnShare;
// Ship the diff as a partial `ui_preferences` patch — the
// server does a shallow merge, so only the changed key is
// touched; siblings set on other devices survive.
if (hideDotfiles !== preferences.hideDotfiles) {
patch.ui_preferences = { hide_dotfiles: hideDotfiles };
}
2026-06-17 22:07:18 -06:00
if (Object.keys(patch).length === 0) {
ui.notify(t('profile.profile_no_changes', 'No changes to save.'), 'info');
return;
}
2026-06-17 17:06:30 -06:00
savingProfile = true;
try {
// PATCH /me/profile echoes SelfUser (same shape as GET /me)
// so the SPA absorbs the just-written state in one round
// trip — no follow-up refresh needed. `session.user` is a
// derived accessor over `session.me.full.user`, so it
// updates in lockstep with the me assignment.
const updated = await updateProfile(patch);
session.me = updated;
2026-06-17 22:07:18 -06:00
if (patch.preferred_locale) await setLocale(patch.preferred_locale as Locale);
2026-06-17 17:06:30 -06:00
ui.notify(t('profile.saved', 'Profile saved'), 'success');
} catch (err) {
2026-06-17 22:30:56 -06:00
errorToast(err);
2026-06-17 17:06:30 -06:00
} finally {
savingProfile = false;
}
}
async function savePassword(e: SubmitEvent) {
e.preventDefault();
if (newPw !== confirmPw) {
ui.notify(t('profile.password_mismatch', 'Passwords do not match'), 'error');
return;
}
2026-06-17 22:07:18 -06:00
if (newPw.length < 8) {
ui.notify(
t('profile.password_too_short', 'Password must be at least 8 characters.'),
'error'
);
return;
}
if (newPw === currentPw) {
// Fast client-side reject — the backend also enforces this
// (400 `PasswordUnchanged`) but the SPA can save the round-
// trip. Load-bearing in mandatory mode: silently accepting
// same-as-current would clear the force flag without a real
// rotation, defeating the "temporary password" pattern.
ui.notify(
t('profile.password_unchanged', 'New password must differ from the current one.'),
'error'
);
return;
}
2026-06-17 17:06:30 -06:00
savingPassword = true;
try {
await changePassword(currentPw, newPw);
currentPw = newPw = confirmPw = '';
ui.notify(t('profile.password_updated', 'Password updated'), 'success');
// If we're in mandatory mode, the backend just cleared the
// force flag AND revoked all sessions. Refresh the session
// so the layout guard lifts, then bounce to the intended
// destination the layout captured on entry. Refresh order
// matters: goto() before the session refresh would race
// the layout's `mustChangePassword` derived and re-redirect
// us right back to /profile.
if (mandatoryMode) {
try {
const me = await fetchMe();
if (me) session.setUser(me);
} catch {
/* stale session state is recoverable — the next request refreshes it */
}
await goto(resolve(nextAfterChange as Pathname), { replaceState: true });
}
2026-06-17 17:06:30 -06:00
} catch (err) {
// Remap the backend's `PasswordUnchanged` error_type to a
// specific, translatable message — the generic errorToast
// would show the raw server string. Every other error
// path still flows through errorToast.
if (err instanceof ApiError && err.errorType === 'PasswordUnchanged') {
ui.notify(
t('profile.password_unchanged', 'New password must differ from the current one.'),
'error'
);
} else {
errorToast(err);
}
2026-06-17 17:06:30 -06:00
} finally {
savingPassword = false;
}
}
// In mandatory mode, focus the current-password input as soon as
// the DOM is ready so the user can type without scrolling / clicking
// around to find the form. `tick()` waits for the reactive render;
// the null-check tolerates the (rare) case where the form isn't
// mounted yet on first paint.
onMount(async () => {
if (!mandatoryMode) return;
await tick();
const el = document.querySelector<HTMLInputElement>(
'[data-testid="profile-current-password-input"]'
);
el?.focus();
});
2026-06-17 22:07:18 -06:00
// ── Avatar edit panel ──────────────────────────────────────────────────
function openAvatarEdit() {
avatarEditOpen = true;
avatarTab = 'url';
avatarUrl = '';
avatarPreview = null;
uploadedDataUrl = null;
}
function closeAvatarEdit() {
avatarEditOpen = false;
uploadedDataUrl = null;
avatarPreview = null;
}
async function onAvatarFile(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const dataUrl = await resizeImageToDataUrl(file);
uploadedDataUrl = dataUrl;
avatarPreview = dataUrl;
} catch (err) {
uploadedDataUrl = null;
avatarPreview = null;
2026-06-17 22:30:56 -06:00
errorToast(err);
2026-06-17 22:07:18 -06:00
} finally {
input.value = '';
}
}
async function commitAvatar(image: string | null) {
avatarBusy = true;
try {
await updateAvatar(image);
if (session.user) session.user = { ...session.user, image };
avatarImgFailed = false;
closeAvatarEdit();
} catch (err) {
2026-06-17 22:30:56 -06:00
errorToast(err);
2026-06-17 22:07:18 -06:00
} finally {
avatarBusy = false;
}
}
async function saveAvatar() {
if (avatarTab === 'url') {
await commitAvatar(avatarUrl.trim() || null);
} else {
if (!uploadedDataUrl) {
ui.notify(t('profile.photo_no_file', 'Choose a photo first.'), 'error');
return;
}
await commitAvatar(uploadedDataUrl);
}
}
// ── App passwords ──────────────────────────────────────────────────────
async function loadAppPasswords() {
try {
appPasswords = await listAppPasswords();
appPwLoadFailed = false;
} catch {
appPwLoadFailed = true;
}
}
async function createPw() {
const label = newLabel.trim();
if (!label) {
ui.notify(t('profile.error_label_required', 'Enter a label.'), 'error');
return;
}
creatingPw = true;
try {
const password = await createAppPassword(label);
generated = { label, password };
newLabel = '';
await loadAppPasswords();
} catch (err) {
2026-06-17 22:30:56 -06:00
errorToast(err);
2026-06-17 22:07:18 -06:00
} finally {
creatingPw = false;
}
}
async function revokePw(p: AppPassword) {
const ok = await confirmDialog({
title: t('profile.app_pw_revoke', 'Revoke app password'),
message: t('profile.confirm_revoke', { label: p.label }, 'Revoke "{{label}}"?'),
confirmText: t('profile.app_pw_revoke', 'Revoke'),
danger: true
});
if (!ok) return;
try {
await revokeAppPassword(p.id);
generated = null;
await loadAppPasswords();
} catch (err) {
2026-06-17 22:30:56 -06:00
errorToast(err);
2026-06-17 22:07:18 -06:00
}
}
async function copyGenerated() {
if (!generated) return;
try {
await navigator.clipboard.writeText(generated.password);
ui.notify(t('profile.copied', 'Copied'), 'success');
} catch {
ui.notify(t('profile.copy_failed', 'Could not copy'), 'error');
}
}
2026-06-17 17:06:30 -06:00
onMount(async () => {
if (!session.loaded) await session.load();
hydrate();
2026-06-17 22:07:18 -06:00
void loadAppPasswords();
try {
const providers = await getOidcProviders();
// Only an explicit `false` hides the password card; an absent flag
// (no OIDC configured) leaves local password login available.
if (providers.password_login_enabled === false) passwordLoginEnabled = false;
// Capture OIDC state for the SSO link/unlink card. `provider_name`
// is the display label the FE renders in the "Connected to X"
// affordance.
oidcEnabled = providers.enabled === true;
if (typeof providers.provider_name === 'string' && providers.provider_name.length > 0) {
oidcProviderName = providers.provider_name;
}
2026-06-17 22:07:18 -06:00
} catch {
/* leave password login enabled */
}
// Toast handling for the OIDC-link callback redirect. The
// backend redirects here with `?linked=1` on success or
// `?link_error=<reason>` on refusal (see plan doc for the
// stable reason keys). Show a translated toast and strip the
// query params via history.replaceState so a page reload
// doesn't re-fire the toast.
const params = page.url.searchParams;
const linked = params.get('linked');
const linkError = params.get('link_error');
if (linked === '1') {
ui.notify(t('profile.sso_linked_success', 'Single sign-on connected successfully.'), 'info');
// Session's federation_kind may still be stale from before
// the round-trip; re-fetch to pick up the fresh columns
// (federation_kind should now be 'oidc').
try {
const me = await fetchMe();
2026-08-21 16:49:32 +02:00
if (me) session.me = me;
} catch {
/* stale session is recoverable — next request refreshes */
}
} else if (linkError) {
// Map the stable reason keys to translated messages. Falls
// back to a generic message for keys we don't recognise
// (forward-compatible with new refusal reasons).
// 10 s dwell (vs the 4 s default) — the copy is long enough
// that the default vanishes before the user finishes reading.
const msg = ssoLinkErrorMessage(linkError);
ui.notify(msg, 'error', 10000);
}
if (linked !== null || linkError !== null) {
const stripped = new URL(page.url);
stripped.searchParams.delete('linked');
stripped.searchParams.delete('link_error');
window.history.replaceState(
window.history.state,
'',
stripped.pathname + stripped.search + stripped.hash
);
}
2026-06-17 17:06:30 -06:00
});
function ssoLinkErrorMessage(key: string): string {
// Keys match the `reason` field of `federation.link_refused`
// audit events — see docs/plan/oidc-account-linking.md.
switch (key) {
case 'email_mismatch':
return t(
'profile.sso_link_error_email_mismatch',
"The email from your SSO provider doesn't match your OxiCloud account email."
);
case 'email_not_provided':
return t(
'profile.sso_link_error_email_not_provided',
"Your SSO provider didn't return an email address, so we can't verify the link."
);
case 'already_linked_elsewhere':
return t(
'profile.sso_link_error_already_linked_elsewhere',
'This SSO identity is already linked to a different OxiCloud account.'
);
case 'already_linked':
return t(
'profile.sso_link_error_already_linked',
'Your account is already linked to a different SSO identity. Disconnect first.'
);
case 'session_expired':
return t(
'profile.sso_link_error_session_expired',
'Your session expired during the SSO round-trip. Please sign in again.'
);
default:
return t('profile.sso_link_error_generic', 'SSO link failed. Please try again.');
}
}
async function onConnectSso(e: SubmitEvent) {
e.preventDefault();
ssoBusy = true;
try {
const url = await startOidcLink();
// Full-page navigation so the browser leaves the SPA and
// hits the IdP; the callback lands back on /profile via
// the extended callback dispatch. `goto()` would stay in
// the SPA and never leave.
window.location.assign(url);
} catch (err) {
ssoBusy = false;
errorToast(err);
}
}
async function onDisconnectSso(e: SubmitEvent) {
e.preventDefault();
const ok = await confirmDialog({
title: t('profile.sso_disconnect_confirm_title', 'Disconnect Single Sign-On?'),
message: t(
'profile.sso_disconnect_confirm_message',
"You'll only be able to sign in with your password or OPAQUE credential after this."
),
confirmText: t('profile.sso_disconnect_confirm_button', 'Disconnect'),
danger: true
});
if (!ok) return;
ssoBusy = true;
try {
await unlinkOidc();
const me = await fetchMe();
2026-08-21 16:49:32 +02:00
if (me) session.me = me;
ui.notify(t('profile.sso_unlinked_success', 'Single sign-on disconnected.'), 'info');
} catch (err) {
if (err instanceof ApiError && err.errorType === 'NoAlternativeAuth') {
ui.notify(
t(
'profile.sso_unlink_no_alt_auth',
'Set a password first — otherwise you would be locked out.'
),
'error',
10000
);
} else {
errorToast(err);
}
} finally {
ssoBusy = false;
}
}
2026-06-17 17:06:30 -06:00
</script>
<svelte:head><title>{t('nav.profile', 'Profile')} · OxiCloud</title></svelte:head>
<main class="profile" class:profile--mandatory={mandatoryMode}>
2026-06-17 17:06:30 -06:00
<h1>{t('nav.profile', 'Profile')}</h1>
{#if mandatoryMode}
<!--
Mandatory-mode banner. Rendered above every other section
whenever `session.mustChangePassword` is TRUE or the URL
carries `?forcePasswordChange=1`. Explains WHY the user
landed here (an admin picked a temporary password) and
what they need to do (rotate before continuing). Backend
also refuses every non-allowlisted endpoint with 403
PasswordChangeRequired — so a user who dismisses the
banner via URL manipulation still can't reach any file /
DAV / admin endpoint until the change lands.
-->
<div
class="mandatory-banner"
role="alert"
data-testid="profile-mandatory-change-password-banner"
>
<Icon name="shield-alt" />
<div class="mandatory-banner__body">
<strong>
{t('profile.mandatory_change_title', 'Please change your password to continue.')}
</strong>
<p>
{t(
'profile.mandatory_change_body',
'An administrator has set a temporary password for your account. Choose your own password below before you can access the rest of the application.'
)}
</p>
</div>
</div>
{/if}
2026-06-17 17:06:30 -06:00
{#if session.user}
2026-06-17 22:07:18 -06:00
<!-- Avatar / identity -->
<div class="card avatar-card">
<div class="avatar-section">
{#if session.user.image && !avatarImgFailed}
<img
class="avatar-lg"
src={session.user.image}
alt={initials}
onerror={() => (avatarImgFailed = true)}
/>
{:else}
<span class="avatar-lg avatar-lg--initials">{initials}</span>
{/if}
<div class="avatar-info">
<h2>{session.user.username || session.user.email || '—'}</h2>
<div class="muted">{session.user.email}</div>
<span class="role-badge" class:role-badge--admin={isAdmin}>
<Icon name={isAdmin ? 'shield-alt' : 'user'} />
{isAdmin ? t('profile.role_admin', 'Administrator') : t('profile.role_user', 'User')}
</span>
{#if isOidc && session.user.image}
<p class="muted">
{t('profile.photo_managed_by_oidc', 'Photo managed by your identity provider.')}
</p>
{/if}
</div>
{#if canEditImage}
<button
class="btn btn-secondary avatar-edit-btn"
data-testid="profile-avatar-edit-btn"
2026-06-17 22:07:18 -06:00
title={t('profile.edit_photo', 'Edit photo')}
onclick={openAvatarEdit}
>
<Icon name="pencil-alt" />
</button>
{/if}
</div>
{#if canEditImage && avatarEditOpen}
<div class="avatar-edit" data-testid="profile-avatar-edit-panel">
2026-06-17 22:07:18 -06:00
<div class="avatar-tabs">
<button
class="avatar-tab"
data-testid="profile-avatar-url-tab"
2026-06-17 22:07:18 -06:00
class:avatar-tab--active={avatarTab === 'url'}
onclick={() => (avatarTab = 'url')}
>
{t('profile.photo_tab_url', 'URL')}
</button>
<button
class="avatar-tab"
data-testid="profile-avatar-upload-tab"
2026-06-17 22:07:18 -06:00
class:avatar-tab--active={avatarTab === 'upload'}
onclick={() => (avatarTab = 'upload')}
>
{t('profile.photo_tab_upload', 'Upload')}
</button>
</div>
{#if avatarTab === 'url'}
<input
type="url"
data-testid="profile-avatar-url-input"
bind:value={avatarUrl}
placeholder="https://example.com/photo.jpg"
/>
2026-06-17 22:07:18 -06:00
<small class="muted">
{t('profile.photo_url_hint', 'https://, http://, or data:image/…;base64,… accepted')}
</small>
{:else}
<label class="avatar-file-label btn btn-secondary">
<Icon name="user-plus" />
<span>{t('profile.photo_choose_file', 'Choose a photo (PNG, JPEG, WebP)')}</span>
<input
type="file"
data-testid="profile-avatar-file-input"
2026-06-17 22:07:18 -06:00
accept="image/png,image/jpeg,image/webp"
hidden
onchange={onAvatarFile}
/>
</label>
{#if avatarPreview}
<img class="avatar-preview" src={avatarPreview} alt={t('profile.avatar', 'Avatar')} />
{/if}
<small class="muted">
{t(
'profile.photo_resize_note',
'Images larger than 512 × 512 px are automatically resized.'
)}
</small>
{/if}
<div class="avatar-edit-actions">
<button
class="btn btn-primary"
data-testid="profile-avatar-save-btn"
disabled={avatarBusy}
onclick={saveAvatar}
>
2026-06-17 22:07:18 -06:00
{t('profile.photo_save', 'Save')}
</button>
{#if session.user.image}
<button
class="btn link-btn link-btn--danger"
data-testid="profile-avatar-remove-btn"
2026-06-17 22:07:18 -06:00
disabled={avatarBusy}
onclick={() => commitAvatar(null)}
>
{t('profile.photo_remove', 'Remove photo')}
</button>
{/if}
<button
class="btn btn-secondary"
data-testid="profile-avatar-cancel-btn"
disabled={avatarBusy}
onclick={closeAvatarEdit}
>
2026-06-17 22:07:18 -06:00
{t('common.cancel', 'Cancel')}
</button>
</div>
</div>
{/if}
</div>
<!-- Account details -->
<div class="card">
<h2><Icon name="id-card" /> {t('profile.account_details', 'Account Details')}</h2>
<div class="info-grid">
<div class="info-item">
<div class="info-label"><Icon name="user" /> {t('profile.username', 'Username')}</div>
<div class="info-value">{session.user.username || '—'}</div>
</div>
<div class="info-item">
<div class="info-label"><Icon name="envelope" /> {t('profile.email', 'Email')}</div>
<div class="info-value">{session.user.email}</div>
</div>
<div class="info-item">
<div class="info-label"><Icon name="shield-alt" /> {t('profile.role', 'Role')}</div>
<div class="info-value">
{isAdmin ? t('profile.role_admin', 'Administrator') : t('profile.role_user', 'User')}
</div>
</div>
<div class="info-item">
<div class="info-label">
<Icon name="clock" />
{t('profile.last_login', 'Last Login')}
</div>
2026-08-21 16:49:32 +02:00
<div class="info-value">{timeAgo(session.me?.full.last_login_at)}</div>
2026-06-17 22:07:18 -06:00
</div>
</div>
</div>
<!-- Storage -->
<div class="card">
<h2><Icon name="hdd" /> {t('profile.storage', 'Storage')}</h2>
<div class="storage-stats">
<div class="storage-stat">
2026-08-21 16:49:32 +02:00
<div class="stat-value">{formatBytes(session.me?.full.storage_used_bytes ?? 0)}</div>
2026-06-17 22:07:18 -06:00
<div class="muted">{t('profile.used', 'Used')}</div>
</div>
<div class="storage-stat">
<div class="stat-value">
2026-08-21 16:49:32 +02:00
{(session.me?.full.storage_quota_bytes ?? 0) > 0
? formatBytes(session.me?.full.storage_quota_bytes ?? 0)
2026-06-17 22:07:18 -06:00
: '∞'}
</div>
<div class="muted">{t('profile.quota', 'Quota')}</div>
</div>
<div class="storage-stat">
<div class="stat-value">
2026-08-21 16:49:32 +02:00
{(session.me?.full.storage_quota_bytes ?? 0) > 0 ? `${storagePct}%` : '—'}
2026-06-17 22:07:18 -06:00
</div>
<div class="muted">{t('profile.usage', 'Usage')}</div>
</div>
</div>
<div class="bar">
<div class={`bar__fill ${storageBarClass}`} style:width="{storagePct}%"></div>
</div>
</div>
<!-- Edit profile (hidden for OIDC users) -->
<div class="card">
<h2><Icon name="id-badge" /> {t('profile.edit_profile', 'Edit Profile')}</h2>
{#if isOidc}
<div class="alert alert--info">
<Icon name="info-circle" />
<span>
{t(
'profile.edit_oidc_managed',
'To change your information (name, profile picture, …), please update it at your identity provider. Your changes will appear on your next sign-in.'
)}
</span>
</div>
{:else}
<form data-testid="profile-edit-form" onsubmit={saveProfile}>
2026-06-17 22:07:18 -06:00
<label>
<span>{t('profile.username', 'Username')}</span>
<input
data-testid="profile-username-input"
2026-06-17 22:07:18 -06:00
bind:value={username}
maxlength="64"
autocomplete="username"
disabled={usernameClaimed}
/>
<small class="muted">
{usernameClaimed
? t('profile.username_already_claimed', "Username can't be changed once set.")
: t(
'profile.username_claim_hint',
"2–64 characters. Once chosen, the username can't be changed."
)}
</small>
</label>
<label>
<span>{t('profile.given_name', 'First name')}</span>
<input
data-testid="profile-given-name-input"
bind:value={givenName}
maxlength="128"
autocomplete="given-name"
/>
2026-06-17 22:07:18 -06:00
</label>
<label>
<span>{t('profile.family_name', 'Last name')}</span>
<input
data-testid="profile-family-name-input"
bind:value={familyName}
maxlength="128"
autocomplete="family-name"
/>
2026-06-17 22:07:18 -06:00
</label>
<label>
<span>{t('profile.language', 'Language')}</span>
<select data-testid="profile-language-select" bind:value={preferredLocale}>
<option value="" data-testid="profile-language-auto-option"
>{t('profile.language_auto', 'Automatic')}</option
>
2026-06-17 22:07:18 -06:00
{#each SUPPORTED_LOCALES as loc (loc)}
<option value={loc} data-testid={`profile-language-option-${loc}`}>{loc}</option>
2026-06-17 22:07:18 -06:00
{/each}
</select>
</label>
<label class="checkbox">
<input
type="checkbox"
data-testid="profile-notify-on-share-checkbox"
bind:checked={notifyOnShare}
/>
2026-06-17 22:07:18 -06:00
<span>{t('profile.notify_on_share', 'Email me when someone shares with me')}</span>
</label>
<label class="checkbox">
<input
type="checkbox"
data-testid="profile-hide-dotfiles-checkbox"
bind:checked={hideDotfiles}
/>
<span
>{t(
'profile.hide_dotfiles',
'Hide files whose name starts with a dot (.env, .git, …)'
)}</span
>
</label>
<button type="submit" data-testid="profile-save-btn" disabled={savingProfile}
2026-06-17 22:07:18 -06:00
>{t('profile.save_profile', 'Save changes')}</button
>
</form>
{/if}
</div>
<!-- App passwords -->
{#if !appPwLoadFailed}
<div class="card">
<h2><Icon name="key" /> {t('profile.app_passwords', 'App Passwords')}</h2>
<p class="muted">
{t(
'profile.app_pw_desc',
'Generate passwords for WebDAV, CalDAV, and CardDAV clients. Each password is shown only once.'
)}
</p>
<div class="app-pw-create">
<input
data-testid="profile-app-pw-label-input"
2026-06-17 22:07:18 -06:00
bind:value={newLabel}
maxlength="128"
placeholder={t('profile.app_pw_label_placeholder', 'Label (e.g. Thunderbird, macOS)')}
/>
<button
class="btn btn-primary"
data-testid="profile-app-pw-generate-btn"
disabled={creatingPw}
onclick={createPw}
>
2026-06-17 22:07:18 -06:00
<Icon name="user-plus" />
{t('profile.generate', 'Generate')}
</button>
</div>
{#if generated}
<div class="generated">
<div>
{t('profile.new_password_for', 'New password for')}
<strong>{generated.label}</strong>:
</div>
<div class="generated__value">
<code>{generated.password}</code>
<button
class="btn-action"
data-testid="profile-app-pw-copy-btn"
2026-06-17 22:07:18 -06:00
title={t('profile.copy_to_clipboard', 'Copy to clipboard')}
onclick={copyGenerated}
>
<Icon name="copy" />
</button>
</div>
<small class="muted">
{t(
'profile.copy_warning',
"Copy this password now. You won't be able to see it again."
)}
</small>
</div>
{/if}
{#if userPasswords.length === 0}
<p class="muted">{t('profile.no_app_passwords', 'No app passwords yet.')}</p>
{:else}
<table class="pw-table">
<thead>
<tr>
<th>{t('profile.col_label', 'Label')}</th>
<th>{t('profile.col_created', 'Created')}</th>
<th>{t('profile.col_last_used', 'Last Used')}</th>
<th>{t('profile.col_status', 'Status')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each userPasswords as p (p.id)}
<tr>
<td>{p.label}</td>
<td>{formatDate(p.created_at)}</td>
<td>{p.last_used_at ? timeAgo(p.last_used_at) : t('profile.never', 'Never')}</td>
<td>
{#if p.active !== false}
<span class="badge badge--active">{t('profile.active', 'Active')}</span>
{:else}
<span class="badge badge--revoked">{t('profile.revoked', 'Revoked')}</span>
{/if}
</td>
<td>
{#if p.active !== false}
<button
class="btn-action btn-action--danger"
data-testid={`profile-app-pw-revoke-${p.id}`}
2026-06-17 22:07:18 -06:00
title={t('profile.revoke_title', 'Revoke')}
onclick={() => revokePw(p)}
>
<Icon name="trash-alt" />
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#if autoPasswords.length > 0}
<div class="app-pw-auto">
<button
class="app-pw-auto__toggle"
data-testid="profile-app-pw-auto-toggle-btn"
onclick={() => (autoExpanded = !autoExpanded)}
>
2026-06-17 22:07:18 -06:00
<Icon name={autoExpanded ? 'chevron-down' : 'chevron-right'} />
<span>{t('profile.client_sessions', 'Client sessions')}</span>
<span class="badge badge--count">{autoPasswords.length}</span>
</button>
{#if autoExpanded}
<p class="muted">
{t(
'profile.client_sessions_desc',
'Auto-generated when you connect a Nextcloud-compatible client.'
)}
</p>
<table class="pw-table">
<thead>
<tr>
<th>{t('profile.col_client', 'Client')}</th>
<th>{t('profile.col_created', 'Created')}</th>
<th>{t('profile.col_last_used', 'Last Used')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each autoPasswords as p (p.id)}
<tr>
<td>{p.label}</td>
<td>{formatDate(p.created_at)}</td>
<td
>{p.last_used_at
? timeAgo(p.last_used_at)
: t('profile.never', 'Never')}</td
>
<td>
{#if p.active !== false}
<button
class="btn-action btn-action--danger"
data-testid={`profile-app-pw-auto-revoke-${p.id}`}
2026-06-17 22:07:18 -06:00
title={t('profile.revoke_title', 'Revoke')}
onclick={() => revokePw(p)}
>
<Icon name="trash-alt" />
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Change password -->
{#if showPasswordCard}
<form class="card password-card" data-testid="profile-password-form" onsubmit={savePassword}>
2026-06-17 22:07:18 -06:00
<h2><Icon name="key" /> {t('profile.change_password', 'Change Password')}</h2>
2026-06-17 17:06:30 -06:00
<label>
2026-06-17 22:07:18 -06:00
<span>{t('profile.current_password', 'Current Password')}</span>
<input
type="password"
data-testid="profile-current-password-input"
bind:value={currentPw}
autocomplete="current-password"
/>
2026-06-17 17:06:30 -06:00
</label>
<label>
2026-06-17 22:07:18 -06:00
<span>{t('profile.new_password', 'New Password')}</span>
<input
type="password"
data-testid="profile-new-password-input"
bind:value={newPw}
minlength="8"
autocomplete="new-password"
/>
2026-06-17 22:07:18 -06:00
<small class="muted">{t('profile.min_8_chars', 'At least 8 characters')}</small>
2026-06-17 17:06:30 -06:00
</label>
<label>
2026-06-17 22:07:18 -06:00
<span>{t('profile.confirm_password', 'Confirm New Password')}</span>
<input
type="password"
data-testid="profile-confirm-password-input"
bind:value={confirmPw}
minlength="8"
autocomplete="new-password"
/>
2026-06-17 17:06:30 -06:00
</label>
<button type="submit" data-testid="profile-update-password-btn" disabled={savingPassword}>
2026-06-17 22:07:18 -06:00
{t('profile.update_password', 'Update Password')}
2026-06-17 17:06:30 -06:00
</button>
</form>
{/if}
<!--
OIDC identity link / unlink card. See
docs/plan/oidc-account-linking.md § UX flow.
Two mutually-exclusive states: Connect (no federation yet) or
Disconnect (currently OIDC-linked). The Connect button
navigates to the IdP; the Disconnect button unlinks and
refreshes the session. Backend enforces safety checks —
email-match on link, no-alternative-auth refusal on unlink.
-->
{#if canConnectSso}
<form class="card sso-card" data-testid="profile-sso-connect-card" onsubmit={onConnectSso}>
<h2><Icon name="link" /> {t('profile.sso_connect_title', 'Connect Single Sign-On')}</h2>
<p>
{t(
'profile.sso_connect_description',
{ provider: oidcProviderName },
'Link your account to {{provider}} so you can sign in with SSO instead of your password.'
)}
</p>
<button type="submit" data-testid="profile-sso-connect-btn" disabled={ssoBusy}>
{t(
'profile.sso_connect_button',
{ provider: oidcProviderName },
'Connect with {{provider}}'
)}
</button>
</form>
{:else if canDisconnectSso}
<form
class="card sso-card"
data-testid="profile-sso-disconnect-card"
onsubmit={onDisconnectSso}
>
<h2><Icon name="link" /> {t('profile.sso_disconnect_title', 'Single Sign-On')}</h2>
<p>
{t(
'profile.sso_disconnect_description',
{ provider: oidcProviderName },
'Your account is connected to {{provider}}. Disconnecting will require you to sign in with your password from now on.'
)}
</p>
<button type="submit" data-testid="profile-sso-disconnect-btn" disabled={ssoBusy}>
{t('profile.sso_disconnect_button', 'Disconnect Single Sign-On')}
</button>
</form>
{/if}
2026-06-17 17:06:30 -06:00
{:else}
<p>{t('common.loading', 'Loading…')}</p>
{/if}
</main>
<style>
.profile {
2026-06-17 22:07:18 -06:00
max-width: 40rem;
2026-06-17 17:06:30 -06:00
margin: 0 auto;
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
/*
* Mandatory-mode: hide every card except the identity header
* (`.avatar-card` keeps context — who am I?) and the password
* form. Backend blocks non-allowlisted endpoints with 403 anyway;
* this is the UX side of that lock so the user sees exactly one
* form to fill in. Ergonomically loud banner + a single card.
*/
.profile--mandatory :global(.card):not(.avatar-card, .password-card) {
display: none;
}
.mandatory-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: var(--color-bg-warning-subtle, var(--color-bg-surface));
border: 1px solid var(--color-border-warning, var(--color-border));
border-left: 4px solid var(--color-accent-warning, var(--color-accent));
border-radius: var(--radius-md, var(--radius-lg));
color: var(--color-text);
}
.mandatory-banner__body strong {
display: block;
margin-bottom: 0.25rem;
}
.mandatory-banner__body p {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-muted);
}
2026-06-17 17:06:30 -06:00
.card h2 {
margin: 0 0 0.25rem;
font-size: 1.125rem;
2026-06-17 22:07:18 -06:00
display: flex;
align-items: center;
gap: 0.5rem;
}
.avatar-section {
display: flex;
align-items: center;
gap: 1.25rem;
}
.avatar-lg {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
flex: none;
}
.avatar-lg--initials {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--color-accent-gradient, var(--color-accent));
color: var(--color-on-accent);
font-size: 1.5rem;
font-weight: var(--weight-bold);
}
.avatar-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.avatar-info h2 {
margin: 0;
font-size: 1.25rem;
}
.avatar-edit-btn {
align-self: flex-start;
flex: none;
}
.role-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
align-self: flex-start;
padding: 0.1rem 0.55rem;
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: var(--weight-semibold, 600);
background: var(--color-bg-muted);
color: var(--color-text);
}
.role-badge--admin {
background: var(--color-warning-bg);
color: var(--color-warning-text);
}
.avatar-edit {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.avatar-tabs {
display: flex;
gap: 0.5rem;
}
.avatar-tab {
padding: 0.35rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.avatar-tab--active {
background: var(--color-bg-hover);
color: var(--color-text);
}
.avatar-file-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
align-self: flex-start;
cursor: pointer;
}
.avatar-preview {
width: 96px;
height: 96px;
border-radius: 50%;
object-fit: cover;
}
.avatar-edit-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.info-label {
display: flex;
align-items: center;
gap: 0.375rem;
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.info-value {
font-weight: var(--weight-medium, 500);
overflow-wrap: break-word;
2026-06-17 22:07:18 -06:00
}
.storage-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
text-align: center;
}
.storage-stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.75rem 0.5rem;
background: var(--color-bg-muted);
border-radius: var(--radius-md);
}
.stat-value {
font-size: 1.25rem;
font-weight: var(--weight-bold, 700);
}
.bar {
height: 8px;
background: var(--color-bg-muted);
border-radius: var(--radius-full);
overflow: hidden;
}
.bar__fill {
height: 100%;
}
.bar__fill--green {
background: var(--color-success-text, var(--color-accent));
}
.bar__fill--orange {
background: var(--color-warning-text);
}
.bar__fill--red {
background: var(--color-danger-text);
}
.muted {
color: var(--color-text-muted);
font-size: var(--text-sm);
margin: 0;
}
.alert {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
}
.alert--info {
background: var(--color-info-bg);
color: var(--color-info-text);
}
.app-pw-create {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.app-pw-create input {
flex: 1;
min-width: 12rem;
}
.generated {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.75rem;
background: var(--color-bg-hover);
border-radius: var(--radius-md);
}
.generated__value {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.generated code {
font-family: var(--font-mono, monospace);
}
.pw-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.pw-table th,
.pw-table td {
text-align: left;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.pw-table th {
color: var(--color-text-muted);
font-weight: var(--weight-semibold, 600);
}
.badge {
display: inline-block;
padding: 0.05rem 0.45rem;
border-radius: var(--radius-sm);
font-size: var(--text-xs, 0.7rem);
font-weight: var(--weight-semibold, 600);
}
.badge--active {
background: var(--color-success-bg, var(--color-bg-muted));
color: var(--color-success-text, var(--color-text));
}
.badge--revoked {
background: var(--color-bg-muted);
color: var(--color-text-muted);
}
.badge--count {
background: var(--color-bg-muted);
color: var(--color-text-muted);
}
.app-pw-auto {
padding-top: 0.5rem;
border-top: 1px solid var(--color-border);
}
.app-pw-auto__toggle {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 1rem;
padding: 0.25rem 0;
}
form {
display: flex;
flex-direction: column;
gap: 0.75rem;
2026-06-17 17:06:30 -06:00
}
label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--color-text);
}
label.checkbox {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
input,
select {
padding: 0.5rem 0.625rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
font-size: 1rem;
}
label.checkbox input {
width: auto;
}
2026-06-17 22:07:18 -06:00
.btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.btn-secondary {
background: var(--color-bg-hover);
}
.btn-primary {
background: var(--color-primary);
color: var(--color-text-light);
border-color: transparent;
}
.btn-action {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3rem;
border: none;
border-radius: var(--radius-md);
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.btn-action--danger {
color: var(--color-danger-alt);
2026-06-17 22:07:18 -06:00
}
button[type='submit'] {
2026-06-17 17:06:30 -06:00
align-self: flex-start;
padding: 0.5rem 1.25rem;
border: none;
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
cursor: pointer;
}
2026-06-17 22:07:18 -06:00
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.8125rem;
}
.link-btn--danger {
color: var(--color-danger-text);
}
@media (width <= 32rem) {
.info-grid {
grid-template-columns: 1fr;
}
2026-06-17 17:06:30 -06:00
}
</style>