From 8e34a58c0a142e19dd17ca3e1a01910f9f0079ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 01:20:12 +0200 Subject: [PATCH] refactor(settings): split account-section into profile, passkey, and avatar modules --- .../account-section/account-section-utils.ts | 58 ++ .../account-section/avatar-section.tsx | 184 ++++++ .../index.tsx} | 529 +----------------- .../account-section/passkey-list-section.tsx | 284 ++++++++++ 4 files changed, 538 insertions(+), 517 deletions(-) create mode 100644 src/components/settings/account-section/account-section-utils.ts create mode 100644 src/components/settings/account-section/avatar-section.tsx rename src/components/settings/{account-section.tsx => account-section/index.tsx} (56%) create mode 100644 src/components/settings/account-section/passkey-list-section.tsx diff --git a/src/components/settings/account-section/account-section-utils.ts b/src/components/settings/account-section/account-section-utils.ts new file mode 100644 index 000000000..32253ddf1 --- /dev/null +++ b/src/components/settings/account-section/account-section-utils.ts @@ -0,0 +1,58 @@ +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +/** + * v1.4.37 — silent browser-zone auto-seed for the timezone picker. + * + * Until v1.4.37 the picker carried a "Browser-Zeitzone übernehmen" / + * "Use browser timezone" button so the user could overwrite the + * Europe/Berlin seed with their detected zone on demand. The button + * was visually noisy next to the picker on mobile and almost every + * user wants the browser zone anyway, so the affordance retired and + * the bootstrap effect seeds the form for them. + * + * Rules: + * + * - If the stored value is anything other than the Europe/Berlin + * default, respect it. The user explicitly picked it. + * - If the stored value is the Europe/Berlin default but the + * browser actually IS in Berlin, leave it alone — the picker + * stays on Berlin and the next save is a no-op. + * - If the stored value is the default AND the browser reports a + * non-Berlin zone, pre-fill the picker with the detected zone. + * The form's existing submit handler persists the change on the + * next save; no toast, no banner, no opt-in. + * + * The bootstrap deliberately runs inline during render (the strict + * `react-hooks/set-state-in-effect` rule outlaws setState in an + * effect for this hydration shape), so this helper has to stay + * pure — no DOM access, no `useState`. The detected browser zone is + * passed in by the caller via `detectBrowserTimezone()`. + */ +export function resolveInitialTimezone( + storedTimezone: string | null | undefined, + detectedBrowserTimezone: string, +): string { + const stored = storedTimezone || DEFAULT_TIMEZONE; + const shouldAutoSeed = + stored === DEFAULT_TIMEZONE && + detectedBrowserTimezone.length > 0 && + detectedBrowserTimezone !== DEFAULT_TIMEZONE; + return shouldAutoSeed ? detectedBrowserTimezone : stored; +} + +/** + * v1.16.4 — settings status hints store the i18n KEY (+ params), not + * the translated string: a locale switch re-renders the hint in the + * new language instead of freezing the old-language snapshot. Server- + * provided error text (which has no key) rides `text` verbatim. + */ +export type StatusMessage = + | { key: string; params?: Record } + | { text: string }; + +export function statusText( + msg: StatusMessage, + t: (key: string, params?: Record) => string, +): string { + return "key" in msg ? t(msg.key, msg.params) : msg.text; +} diff --git a/src/components/settings/account-section/avatar-section.tsx b/src/components/settings/account-section/avatar-section.tsx new file mode 100644 index 000000000..d3f48242b --- /dev/null +++ b/src/components/settings/account-section/avatar-section.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { ImageUp, Loader2, Trash2 } from "lucide-react"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/use-auth"; +import { useTranslations } from "@/lib/i18n/context"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { statusText, type StatusMessage } from "./account-section-utils"; + +// Accepted upload types + byte cap mirror the server contract in +// `src/app/api/user/avatar/route.ts` so the client rejects an obvious +// bad file before the round-trip. The server still re-validates by +// magic-byte sniff — this is a UX shortcut, not the security boundary. +const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"]; +const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024; + +function getAvatarInitials(name: string): string { + return name + .split(/[\s._-]+/) + .slice(0, 2) + .map((s) => s[0]?.toUpperCase() ?? "") + .join(""); +} + +export function AvatarSection() { + const { t } = useTranslations(); + const { user, refetch } = useAuth(); + const fileInputRef = useRef(null); + const [msg, setMsg] = useState(null); + const [msgType, setMsgType] = useState<"success" | "error" | null>(null); + + const upload = useMutation({ + mutationFn: async (file: File) => { + const body = new FormData(); + body.append("file", file); + const res = await apiFetchRaw("/api/user/avatar", { + method: "POST", + body, + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + if (res.status === 415) + throw new Error(t("settings.avatar.invalidType")); + if (res.status === 413) throw new Error(t("settings.avatar.tooLarge")); + throw new Error(json.error || t("settings.avatar.error")); + } + }, + onSuccess: async () => { + setMsgType("success"); + setMsg({ key: "settings.avatar.uploaded" }); + await refetch(); + }, + onError: (err: Error) => { + setMsgType("error"); + setMsg({ text: err.message }); + }, + }); + + const remove = useMutation({ + mutationFn: async () => { + const res = await apiFetchRaw("/api/user/avatar", { method: "DELETE" }); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + throw new Error(json.error || t("settings.avatar.error")); + } + }, + onSuccess: async () => { + setMsgType("success"); + setMsg({ key: "settings.avatar.removed" }); + await refetch(); + }, + onError: (err: Error) => { + setMsgType("error"); + setMsg({ text: err.message }); + }, + }); + + function handleFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + // Reset so re-selecting the same file still fires `onChange`. + e.target.value = ""; + if (!file) return; + setMsg(null); + setMsgType(null); + if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) { + setMsgType("error"); + setMsg({ key: "settings.avatar.invalidType" }); + return; + } + if (file.size > AVATAR_MAX_UPLOAD_BYTES) { + setMsgType("error"); + setMsg({ key: "settings.avatar.tooLarge" }); + return; + } + upload.mutate(file); + } + + if (!user) return null; + const avatarUrl = user.avatarUrl ?? null; + const busy = upload.isPending || remove.isPending; + + return ( + + +

+ {t("settings.avatar.description")} +

+
+ + {avatarUrl && } + + {getAvatarInitials(user.username)} + + +
+ + + {avatarUrl && ( + + )} +
+
+

+ {t("settings.avatar.hint")} +

+ {msg && ( +

+ {statusText(msg, t)} +

+ )} +
+ ); +} diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section/index.tsx similarity index 56% rename from src/components/settings/account-section.tsx rename to src/components/settings/account-section/index.tsx index 4329df081..c6d917ae1 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section/index.tsx @@ -1,32 +1,9 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - AlertTriangle, - ImageUp, - KeyRound, - Loader2, - Save, - Shield, - Trash2, - User, -} from "lucide-react"; +import { KeyRound, Loader2, Save, Shield, User } from "lucide-react"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -42,11 +19,9 @@ import { NativeSelect } from "@/components/ui/native-select"; import { PasswordInput } from "@/components/ui/password-input"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; -import { formatDate } from "@/lib/format"; import { locales, localeLabels, type Locale } from "@/lib/i18n/config"; import { useTranslations } from "@/lib/i18n/context"; import { describePasskeyError } from "@/lib/passkey-errors"; -import { queryKeys } from "@/lib/query-keys"; import { AboutMeSection } from "@/components/settings/about-me-section"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -54,78 +29,17 @@ import { TimezonePicker } from "@/components/settings/timezone-picker"; import { TimeFormatSelect } from "@/components/settings/time-format-select"; import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select"; import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card"; -import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format"; -import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { detectBrowserTimezone } from "@/lib/tz/format"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { + resolveInitialTimezone, + statusText, + type StatusMessage, +} from "./account-section-utils"; +import { PasskeyListSection } from "./passkey-list-section"; +import { AvatarSection } from "./avatar-section"; -interface PasskeyInfo { - id: string; - name: string; - credentialDeviceType: string; - credentialBackedUp: boolean; - createdAt: string; -} - -// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant -// retired; the shared `` primitive owns the visual -// contract now. Existing ` - - {avatarUrl && ( - - )} - - -

- {t("settings.avatar.hint")} -

- {msg && ( -

- {statusText(msg, t)} -

- )} - - ); -} diff --git a/src/components/settings/account-section/passkey-list-section.tsx b/src/components/settings/account-section/passkey-list-section.tsx new file mode 100644 index 000000000..4144f1ef9 --- /dev/null +++ b/src/components/settings/account-section/passkey-list-section.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { AlertTriangle, Loader2, Trash2 } from "lucide-react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatDate } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiDelete, apiGet } from "@/lib/api/api-fetch"; + +interface PasskeyInfo { + id: string; + name: string; + credentialDeviceType: string; + credentialBackedUp: boolean; + createdAt: string; +} + +export function PasskeyListSection({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [deleteMsg, setDeleteMsg] = useState(null); + + const { data: passkeys } = useQuery({ + queryKey: queryKeys.passkeys(), + queryFn: async () => { + return apiGet("/api/auth/passkeys"); + }, + enabled: isAuthenticated, + }); + + const deletePasskey = useMutation({ + mutationFn: async (id: string) => { + await apiDelete(`/api/auth/passkeys/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() }); + setDeleteMsg(null); + }, + onError: (err: Error) => { + setDeleteMsg(err.message); + }, + }); + + const DEVICE_TYPE_LABELS: Record = { + singleDevice: t("settings.singleDevice"), + multiDevice: t("settings.multiDevice"), + }; + + if (!passkeys || passkeys.length === 0) { + return ( +
+

{t("settings.passkeys")}

+

+ {t("settings.noPasskeys")} +

+
+ ); + } + + return ( +
+

+ {t("settings.registeredPasskeys")} +

+

+ {t("settings.passkeysDescription")} +

+ {/* Phase A5 / B-mobile HIGH: the passkey table previously + rendered with `min-w-[620px]` inside `overflow-x-auto`, + which on a 393px viewport hid the right-most column — + including the destructive delete action. Render the desktop + table only at `≥ md`, and at `< md` paint a card-list where + every passkey's name, device type, backup status, created + date, and delete action are all visible without scrolling. */} + {/* v1.4.33 — desktop table flips to card list at `lg` instead + of `md` so iPad portrait (768 px = exactly the `md` + inflection) lands on the card layout. The table needs + ~620 px of column width to read; below that it scrolls + horizontally and the destructive delete column is the one + that disappears. */} +
+ + + + + + + + + + + + {passkeys.map((pk, idx) => ( + + + + + + + + ))} + +
+ {t("settings.passkeyName")} + + {t("settings.passkeyDevice")} + + {t("settings.passkeyBackup")} + + {t("settings.passkeyCreated")} + + {t("settings.passkeyActions")} +
{pk.name} + {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? + pk.credentialDeviceType} + + + {pk.credentialBackedUp + ? t("settings.backedUp") + : t("common.no")} + + + {formatDate(pk.createdAt)} + + + + + + + + + {t("settings.deletePasskey")} + + + {t("settings.deletePasskeyDescription")} + + + + + {t("common.cancel")} + + deletePasskey.mutate(pk.id)} + > + {deletePasskey.isPending && ( + + )} + {t("common.delete")} + + + + +
+
+ + {/* Mobile + tablet card list — < lg only. Each passkey gets + its own card so the delete action stays visible and + tap-targetable (44 px) without the user having to + horizontally scroll through a wide table. v1.4.33 bumped + the breakpoint from `md` to `lg` so iPad portrait (768 px) + stays on the card layout instead of flipping between + layouts on rotation. */} +
    + {passkeys.map((pk) => ( +
  • +
    +
    +

    {pk.name}

    + {/* v1.4.27 MB7 / CF-75 — promote the device-type from + plain text to an outline Badge so the mobile card + list reads consistent with the desktop table's + "Single-device / Multi-device" column. Sits on the + same chip row as the backup status and date so all + metadata reads as a single horizontal stride. */} +
    + + {DEVICE_TYPE_LABELS[pk.credentialDeviceType] ?? + pk.credentialDeviceType} + + + {pk.credentialBackedUp + ? t("settings.backedUp") + : t("common.no")} + + + {formatDate(pk.createdAt)} + +
    +
    + + + + + + + + {t("settings.deletePasskey")} + + + {t("settings.deletePasskeyDescription")} + + + + {t("common.cancel")} + deletePasskey.mutate(pk.id)} + > + {deletePasskey.isPending && ( + + )} + {t("common.delete")} + + + + +
    +
  • + ))} +
+ {deleteMsg && ( +
+ + {deleteMsg} +
+ )} +
+ ); +}