mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
refactor(settings): split account-section into profile, passkey, and avatar modules
This commit is contained in:
1 parent
fa5c6500b5
commit
8e34a58c0a
4 files changed
+538
-517
No files matched your search
@@ -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<string, string | number> }
|
||||
| { text: string };
|
||||
|
||||
export function statusText(
|
||||
msg: StatusMessage,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
return "key" in msg ? t(msg.key, msg.params) : msg.text;
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [msg, setMsg] = useState<StatusMessage | null>(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<HTMLInputElement>) {
|
||||
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 (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ImageUp}
|
||||
title={t("settings.avatar.title")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<p className="text-muted-foreground mb-4 pl-7 text-sm">
|
||||
{t("settings.avatar.description")}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 pl-7">
|
||||
<Avatar className="size-16">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="text-base">
|
||||
{getAvatarInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<ImageUp className="h-4 w-4" />
|
||||
)}
|
||||
{avatarUrl
|
||||
? t("settings.avatar.replace")
|
||||
: t("settings.avatar.upload")}
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
remove.mutate();
|
||||
}}
|
||||
>
|
||||
{remove.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.avatar.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
{t("settings.avatar.hint")}
|
||||
</p>
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-3 text-sm ${
|
||||
msgType === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{statusText(msg, t)}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
+12
-517
@@ -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 `<NativeSelect>` primitive owns the visual
|
||||
// contract now. Existing `<select className={NATIVE_SELECT_CLASS}>`
|
||||
// call sites in this file swapped to `<NativeSelect>` below.
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
type StatusMessage =
|
||||
| { key: string; params?: Record<string, string | number> }
|
||||
| { text: string };
|
||||
|
||||
function statusText(
|
||||
msg: StatusMessage,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
return "key" in msg ? t(msg.key, msg.params) : msg.text;
|
||||
}
|
||||
export { resolveInitialTimezone } from "./account-section-utils";
|
||||
|
||||
export function AccountSection() {
|
||||
const { t, locale, setLocale, pendingLocale } = useTranslations();
|
||||
@@ -764,422 +678,3 @@ export function AccountSection() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteMsg, setDeleteMsg] = useState<string | null>(null);
|
||||
|
||||
const { data: passkeys } = useQuery({
|
||||
queryKey: queryKeys.passkeys(),
|
||||
queryFn: async () => {
|
||||
return apiGet<PasskeyInfo[]>("/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<string, string> = {
|
||||
singleDevice: t("settings.singleDevice"),
|
||||
multiDevice: t("settings.multiDevice"),
|
||||
};
|
||||
|
||||
if (!passkeys || passkeys.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{t("settings.passkeys")}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.noPasskeys")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.registeredPasskeys")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.passkeysDescription")}
|
||||
</p>
|
||||
{/* 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. */}
|
||||
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyDevice")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyBackup")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("settings.passkeyActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{passkeys.map((pk, idx) => (
|
||||
<tr key={pk.id} className={idx % 2 === 0 ? "bg-muted/20" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{pk.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(pk.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-8 w-8"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-label={t("settings.deletePasskey")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-busy={deletePasskey.isPending || undefined}
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{deletePasskey.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<ul
|
||||
className="mt-3 space-y-2 lg:hidden"
|
||||
data-testid="passkeys-mobile-list"
|
||||
>
|
||||
{passkeys.map((pk) => (
|
||||
<li
|
||||
key={pk.id}
|
||||
className="bg-card border-border rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{pk.name}</p>
|
||||
{/* 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. */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(pk.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive min-h-11 min-w-11"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-label={t("settings.deletePasskey")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-busy={deletePasskey.isPending || undefined}
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{deletePasskey.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{deleteMsg && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive mt-2 flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{deleteMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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("");
|
||||
}
|
||||
|
||||
function AvatarSection() {
|
||||
const { t } = useTranslations();
|
||||
const { user, refetch } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [msg, setMsg] = useState<StatusMessage | null>(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<HTMLInputElement>) {
|
||||
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 (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ImageUp}
|
||||
title={t("settings.avatar.title")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<p className="text-muted-foreground mb-4 pl-7 text-sm">
|
||||
{t("settings.avatar.description")}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 pl-7">
|
||||
<Avatar className="size-16">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="text-base">
|
||||
{getAvatarInitials(user.username)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={busy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<ImageUp className="h-4 w-4" />
|
||||
)}
|
||||
{avatarUrl
|
||||
? t("settings.avatar.replace")
|
||||
: t("settings.avatar.upload")}
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
remove.mutate();
|
||||
}}
|
||||
>
|
||||
{remove.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.avatar.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
{t("settings.avatar.hint")}
|
||||
</p>
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-3 text-sm ${
|
||||
msgType === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{statusText(msg, t)}
|
||||
</p>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
const { data: passkeys } = useQuery({
|
||||
queryKey: queryKeys.passkeys(),
|
||||
queryFn: async () => {
|
||||
return apiGet<PasskeyInfo[]>("/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<string, string> = {
|
||||
singleDevice: t("settings.singleDevice"),
|
||||
multiDevice: t("settings.multiDevice"),
|
||||
};
|
||||
|
||||
if (!passkeys || passkeys.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{t("settings.passkeys")}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.noPasskeys")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.registeredPasskeys")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("settings.passkeysDescription")}
|
||||
</p>
|
||||
{/* 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. */}
|
||||
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyName")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyDevice")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyBackup")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("settings.passkeyCreated")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("settings.passkeyActions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{passkeys.map((pk, idx) => (
|
||||
<tr key={pk.id} className={idx % 2 === 0 ? "bg-muted/20" : ""}>
|
||||
<td className="px-3 py-2 font-medium">{pk.name}</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDate(pk.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-8 w-8"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-label={t("settings.deletePasskey")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-busy={deletePasskey.isPending || undefined}
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{deletePasskey.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<ul
|
||||
className="mt-3 space-y-2 lg:hidden"
|
||||
data-testid="passkeys-mobile-list"
|
||||
>
|
||||
{passkeys.map((pk) => (
|
||||
<li
|
||||
key={pk.id}
|
||||
className="bg-card border-border rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{pk.name}</p>
|
||||
{/* 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. */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
|
||||
pk.credentialDeviceType}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={pk.credentialBackedUp ? "secondary" : "outline"}
|
||||
className="text-[11px]"
|
||||
>
|
||||
{pk.credentialBackedUp
|
||||
? t("settings.backedUp")
|
||||
: t("common.no")}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(pk.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive min-h-11 min-w-11"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-label={t("settings.deletePasskey")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.deletePasskey")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.deletePasskeyDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deletePasskey.isPending}
|
||||
aria-busy={deletePasskey.isPending || undefined}
|
||||
onClick={() => deletePasskey.mutate(pk.id)}
|
||||
>
|
||||
{deletePasskey.isPending && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{deleteMsg && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive mt-2 flex items-center gap-2 text-sm"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{deleteMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user