mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
refactor(settings): thin the Account tree down to the profile
Two cards on Settings → Account were asking questions that already have a home somewhere else. - cycle tracking is a module, and the Modules hub carries the switch that drives the same `cycleTrackingEnabled` column. The Account card was a second door to one decision, so it goes, and the "manage in Account" deep-link beside the module switch goes with it. That link pointed at a `#cycle-tracking` anchor no element ever rendered, so it never landed anywhere in the first place - changing a password belongs with the second factors and the passkeys. The card moves to Settings → Security and leads that page. It renders before the factor status resolves: it is now the only way to change a password and must not depend on a read it does not need Account keeps the profile, the photo, the identity fields and the AI context. The `settings.cycleTracking.*` strings leave all six bundles.
This commit is contained in:
1 parent
0b09eb6b1e
commit
13b4a0be0b
13 files changed
+349
-240
No files matched your search
@@ -6037,14 +6037,6 @@
|
||||
"createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen."
|
||||
},
|
||||
"genderOther": "Divers",
|
||||
"cycleTracking": {
|
||||
"title": "Zyklus-Tracking",
|
||||
"description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights.",
|
||||
"enable": "Zyklus-Tracking aktivieren",
|
||||
"manage": "Zyklus-Tracking öffnen",
|
||||
"error": "Zyklus-Tracking konnte nicht aktualisiert werden.",
|
||||
"detail": "Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte ins Glukose-Panel zu synchronisieren.",
|
||||
|
||||
@@ -6037,14 +6037,6 @@
|
||||
"createDetail": "The token is shown once on creation — store it then; it cannot be recovered."
|
||||
},
|
||||
"genderOther": "Other",
|
||||
"cycleTracking": {
|
||||
"title": "Cycle tracking",
|
||||
"description": "Track your menstrual cycle, predictions, symptoms, and phase insights.",
|
||||
"enable": "Enable cycle tracking",
|
||||
"manage": "Open cycle tracking",
|
||||
"error": "Couldn't update cycle tracking.",
|
||||
"detail": "On by default for accounts set to female; available to everyone."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings into the glucose panel.",
|
||||
|
||||
@@ -6037,14 +6037,6 @@
|
||||
"createDetail": "El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar."
|
||||
},
|
||||
"genderOther": "Otro",
|
||||
"cycleTracking": {
|
||||
"title": "Seguimiento del ciclo",
|
||||
"description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases.",
|
||||
"enable": "Activar el seguimiento del ciclo",
|
||||
"manage": "Abrir seguimiento del ciclo",
|
||||
"error": "No se pudo actualizar el seguimiento del ciclo.",
|
||||
"detail": "Activado por defecto en cuentas configuradas como femeninas; disponible para todos."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.",
|
||||
|
||||
@@ -6037,14 +6037,6 @@
|
||||
"createDetail": "Le jeton n'est affiché qu'une fois à la création — conservez-le alors ; il ne peut pas être récupéré."
|
||||
},
|
||||
"genderOther": "Autre",
|
||||
"cycleTracking": {
|
||||
"title": "Suivi du cycle",
|
||||
"description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases.",
|
||||
"enable": "Activer le suivi du cycle",
|
||||
"manage": "Ouvrir le suivi du cycle",
|
||||
"error": "Impossible de mettre à jour le suivi du cycle.",
|
||||
"detail": "Activé par défaut pour les comptes définis comme féminins ; disponible pour tous."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Connecte ton propre site Nightscout pour synchroniser les mesures de glucose en continu dans le panneau glucose.",
|
||||
|
||||
@@ -6037,14 +6037,6 @@
|
||||
"createDetail": "Il token viene mostrato una sola volta alla creazione — salvalo subito; non può essere recuperato."
|
||||
},
|
||||
"genderOther": "Altro",
|
||||
"cycleTracking": {
|
||||
"title": "Monitoraggio del ciclo",
|
||||
"description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi.",
|
||||
"enable": "Attiva il monitoraggio del ciclo",
|
||||
"manage": "Apri il monitoraggio del ciclo",
|
||||
"error": "Impossibile aggiornare il monitoraggio del ciclo.",
|
||||
"detail": "Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue del glucosio nel pannello glucosio.",
|
||||
|
||||
@@ -6037,14 +6037,6 @@
|
||||
"createDetail": "Token jest pokazywany tylko raz przy tworzeniu — zapisz go wtedy; nie można go odzyskać."
|
||||
},
|
||||
"genderOther": "Inna",
|
||||
"cycleTracking": {
|
||||
"title": "Śledzenie cyklu",
|
||||
"description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz.",
|
||||
"enable": "Włącz śledzenie cyklu",
|
||||
"manage": "Otwórz śledzenie cyklu",
|
||||
"error": "Nie udało się zaktualizować śledzenia cyklu.",
|
||||
"detail": "Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich."
|
||||
},
|
||||
"nightscout": "Nightscout",
|
||||
"nightscoutTag": "CGM",
|
||||
"nightscoutDescription": "Połącz własną stronę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.",
|
||||
|
||||
@@ -163,11 +163,20 @@ describe("<ModulesSection>", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a manage deep-link beside the delegated coach + cycle switches", () => {
|
||||
it("keeps a manage deep-link beside the delegated coach switch", () => {
|
||||
const html = render();
|
||||
// Coach → Coach settings; cycle → the Account cycle-tracking card.
|
||||
// Coach → Coach settings. Coach is the only delegated module with more
|
||||
// to configure than on/off (cadence, memory) at a settings address.
|
||||
expect(html).toContain('href="/settings/coach"');
|
||||
expect(html).toContain('href="/settings/account#cycle-tracking"');
|
||||
});
|
||||
|
||||
it("offers no Account deep-link beside the cycle switch", () => {
|
||||
// Cycle used to carry a second enable surface in Settings → Account,
|
||||
// and this link pointed at it. The switch here drives the same column,
|
||||
// so the Account card went and the link with it — a deep-link to an
|
||||
// anchor nothing renders is a door into a wall.
|
||||
const html = render();
|
||||
expect(html).not.toContain("/settings/account#cycle-tracking");
|
||||
});
|
||||
|
||||
it("no longer renders the 'Always on' core-domains card", () => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Where the change-password card lives.
|
||||
*
|
||||
* It used to sit at the bottom of Settings → Account, under the profile form.
|
||||
* Changing a password is the same kind of decision as enrolling a second
|
||||
* factor, so it moved to Settings → Security. Two things have to hold for
|
||||
* that move to be real rather than a duplicate:
|
||||
*
|
||||
* 1. Security mounts it — and mounts it BEFORE the factor status resolves,
|
||||
* because it is now the only way to change a password and must not
|
||||
* depend on a query it does not need.
|
||||
* 2. Account does not, and no second copy of the dialog exists anywhere.
|
||||
*
|
||||
* The second claim is checked against the source tree rather than a render,
|
||||
* because a render of `<AccountSection>` proves the card is absent from the
|
||||
* one branch the test happened to reach. Sweeping the files proves there is
|
||||
* exactly one place in the app that can paint it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: undefined, isLoading: true }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: null,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { PasswordCard } from "../password-card";
|
||||
import { SecuritySection } from "../security-section";
|
||||
|
||||
const SLOT = 'data-slot="settings-password-card"';
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__") continue;
|
||||
walk(full, out);
|
||||
} else if (entry.endsWith(".tsx")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("change-password card placement", () => {
|
||||
it("paints its slot", () => {
|
||||
// Positive control for the two assertions below — without it, a renamed
|
||||
// slot would make every "is it here / is it gone" check read green.
|
||||
expect(render(<PasswordCard />)).toContain(SLOT);
|
||||
});
|
||||
|
||||
it("is mounted by Settings → Security before the factor status lands", () => {
|
||||
// The mocked query never resolves, which is the case that matters: the
|
||||
// MFA read is still in flight (or failing) and the password card is
|
||||
// reachable regardless.
|
||||
const html = render(<SecuritySection />);
|
||||
expect(html).toContain(SLOT);
|
||||
});
|
||||
|
||||
it("has exactly one home in the tree", () => {
|
||||
const owners = walk(join(ROOT, "src"))
|
||||
.filter((file) => readFileSync(file, "utf8").includes("PasswordCard"))
|
||||
.map((file) => file.slice(ROOT.length + 1))
|
||||
.sort();
|
||||
expect(owners).toEqual([
|
||||
"src/components/settings/password-card.tsx",
|
||||
"src/components/settings/security-section/index.tsx",
|
||||
]);
|
||||
});
|
||||
|
||||
it("left no password form behind in the Account section", () => {
|
||||
const account = readFileSync(
|
||||
join(ROOT, "src/components/settings/account-section/index.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
for (const marker of [
|
||||
"settings.passwordReset",
|
||||
"settings.changePassword",
|
||||
"/api/auth/password",
|
||||
"current-password",
|
||||
]) {
|
||||
expect(account, `Account still carries ${marker}`).not.toContain(marker);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Save, Shield, User } from "lucide-react";
|
||||
import { Loader2, Save, User } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
SeededFormDiscardDialog,
|
||||
useSeededFormDismissal,
|
||||
@@ -15,7 +14,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { HeightFieldControl } from "@/components/profile/height-field-control";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
@@ -35,7 +33,6 @@ import { storeTimezone } from "@/lib/timezone-mirror";
|
||||
import { TimeFormatSelect } from "@/components/settings/time-format-select";
|
||||
import { DateFormatSelect } from "@/components/settings/date-format-select";
|
||||
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
|
||||
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/format";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
@@ -94,17 +91,6 @@ export function AccountSection() {
|
||||
"success" | "warning" | "error" | null
|
||||
>(null);
|
||||
|
||||
// Password change dialog state.
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
const [passwordMsg, setPasswordMsg] = useState<StatusMessage | null>(null);
|
||||
const [passwordMsgType, setPasswordMsgType] = useState<
|
||||
"success" | "error" | null
|
||||
>(null);
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
|
||||
// v1.23 — passkey + second-factor management moved to the dedicated
|
||||
// /settings/security hub so "how I secure my account" reads as one place.
|
||||
|
||||
@@ -298,52 +284,6 @@ export function AccountSection() {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setPasswordSaving(true);
|
||||
setPasswordMsg(null);
|
||||
setPasswordMsgType(null);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordMsg({ key: "settings.passwordMismatch" });
|
||||
setPasswordMsgType("error");
|
||||
setPasswordSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setPasswordMsg(
|
||||
json.error ? { text: json.error } : { key: "settings.savingError" },
|
||||
);
|
||||
setPasswordMsgType("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordMsg({ key: "settings.passwordUpdated" });
|
||||
setPasswordMsgType("success");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch {
|
||||
setPasswordMsg({ key: "common.networkError" });
|
||||
setPasswordMsgType("error");
|
||||
} finally {
|
||||
setPasswordSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
@@ -584,32 +524,12 @@ export function AccountSection() {
|
||||
provider-configuration screen. */}
|
||||
<AboutMeSection isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this
|
||||
lets any account opt in (or opt out) before the gated /cycle page is
|
||||
reachable. */}
|
||||
<CycleTrackingCard isAuthenticated={isAuthenticated} />
|
||||
{/* Cycle tracking is a module, and the Modules hub carries its switch.
|
||||
The second enable surface that used to sit here asked the same
|
||||
question in a second place. */}
|
||||
|
||||
{/* Password card. The action is this card's only one, so it sits in
|
||||
the card's action row like every other primary. It stays full-width
|
||||
under `sm` — the German "Passwort ändern" is long enough that a
|
||||
right-hugging button used to break through the card's right border
|
||||
on a narrow phone. */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Shield}
|
||||
title={t("settings.passwordReset")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
/>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setPasswordDialogOpen(true)}
|
||||
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
{/* Changing the password is a security control, so the card lives in
|
||||
Settings → Security beside the second factors and the passkeys. */}
|
||||
|
||||
{/* v1.25.7 — active-session management + the security-activity feed
|
||||
live only under Settings → Data & Privacy now; the duplicate cards
|
||||
@@ -619,85 +539,6 @@ export function AccountSection() {
|
||||
Erweitert. It is a maintenance / reset action, not a profile or
|
||||
security control, so it sits beside Research Mode + the danger zone. */}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={passwordDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setPasswordDialogOpen(open);
|
||||
if (!open) {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setPasswordMsg(null);
|
||||
setPasswordMsgType(null);
|
||||
}
|
||||
}}
|
||||
title={t("settings.passwordReset")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
className="sm:max-w-xl"
|
||||
>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">
|
||||
{t("settings.currentPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">{t("settings.newPassword")}</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("settings.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{passwordMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${
|
||||
passwordMsgType === "success"
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{statusText(passwordMsg, t)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={passwordSaving}
|
||||
>
|
||||
{passwordSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
<SeededFormDiscardDialog
|
||||
open={profileDismissal.discardDialogOpen}
|
||||
onConfirm={profileDismissal.confirmDiscard}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, Save, Shield } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
statusText,
|
||||
type StatusMessage,
|
||||
} from "@/components/settings/account-section/account-section-utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
|
||||
/**
|
||||
* Change-password card.
|
||||
*
|
||||
* It used to sit at the bottom of Settings → Account, under the profile form
|
||||
* and the identity fields. Changing a password is not a profile edit — it is
|
||||
* the same decision as enrolling a second factor or adding a passkey, and
|
||||
* those live in Settings → Security. The card moved to sit with them so
|
||||
* "how I get into this account" reads as one place.
|
||||
*
|
||||
* The status hint stores the i18n KEY, not the translated string, so a locale
|
||||
* switch re-renders it in the new language — the settings-wide convention
|
||||
* `statusText` exists for.
|
||||
*/
|
||||
export function PasswordCard() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<StatusMessage | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setMsg({ key: "settings.passwordMismatch" });
|
||||
setMsgType("error");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setMsg(
|
||||
json.error ? { text: json.error } : { key: "settings.savingError" },
|
||||
);
|
||||
setMsgType("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setMsg({ key: "settings.passwordUpdated" });
|
||||
setMsgType("success");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch {
|
||||
setMsg({ key: "common.networkError" });
|
||||
setMsgType("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The action is this card's only one, so it sits in the card's action
|
||||
row like every other primary. It stays full-width under `sm` — the
|
||||
German "Passwort ändern" is long enough that a right-hugging button
|
||||
used to break through the card's right border on a narrow phone. */}
|
||||
<SettingsCard data-slot="settings-password-card">
|
||||
<SettingsCardHeader
|
||||
icon={Shield}
|
||||
title={t("settings.passwordReset")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
/>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
|
||||
>
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
}
|
||||
}}
|
||||
title={t("settings.passwordReset")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
className="sm:max-w-xl"
|
||||
>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">
|
||||
{t("settings.currentPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">{t("settings.newPassword")}</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("settings.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${
|
||||
msgType === "success" ? "text-success" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{statusText(msg, t)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { PasswordCard } from "@/components/settings/password-card";
|
||||
import { TotpCard } from "./totp-card";
|
||||
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
|
||||
import { PasskeyListSection } from "./passkey-list-section";
|
||||
@@ -45,28 +46,35 @@ export function SecuritySection() {
|
||||
passkeys != null &&
|
||||
passkeys.length === 0;
|
||||
|
||||
if (isLoading || !status) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{showNudge && <PasskeyUpgradeNudge />}
|
||||
|
||||
<TotpCard
|
||||
enabled={status.totp.enabled}
|
||||
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
||||
/>
|
||||
{/* The password leads, and it renders before the factor status has
|
||||
landed — it depends on nothing this page fetches. It is also the
|
||||
only way to change a password now that the Account card is gone, so
|
||||
a `/api/auth/me/mfa` that never answers must not take it down with
|
||||
the cards that do need it. */}
|
||||
<PasswordCard />
|
||||
|
||||
<SecurityKeysCard keys={status.webauthn} />
|
||||
{isLoading || !status ? (
|
||||
<>
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TotpCard
|
||||
enabled={status.totp.enabled}
|
||||
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
||||
/>
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
<SecurityKeysCard keys={status.webauthn} />
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,8 +63,10 @@ export function SettingsCard<E extends React.ElementType = typeof Card>({
|
||||
// Mirror the ui Card shell so a semantic landmark card paints
|
||||
// identically to the default <Card>-backed one.
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col rounded-xl border shadow-sm",
|
||||
flush ? SETTINGS_CARD_FLUSH_SHELL : "gap-4 p-4 md:gap-6 md:p-6",
|
||||
"bg-card text-card-foreground rounded-xl border shadow-sm",
|
||||
flush
|
||||
? SETTINGS_CARD_FLUSH_SHELL
|
||||
: "flex flex-col gap-4 p-4 md:gap-6 md:p-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -203,12 +203,9 @@ export const MODULE_REGISTRY: Readonly<Record<ModuleKey, ModuleDefinition>> =
|
||||
descriptionKey: "modules.cycle.description",
|
||||
category: "tracking",
|
||||
delegatesTo: "cycle",
|
||||
// The hub switch drives `cycleTrackingEnabled`; the fuller cycle-tracking
|
||||
// card (goal, predictions, cycle lengths) lives in the Account section.
|
||||
managedAt: {
|
||||
href: "/settings/account#cycle-tracking",
|
||||
labelKey: "settings.sections.account.title",
|
||||
},
|
||||
// No `managedAt`. The switch here drives `cycleTrackingEnabled`, and
|
||||
// that is the whole of the decision; the goal, predictions and cycle
|
||||
// lengths live on the /cycle page itself, not in Settings.
|
||||
},
|
||||
mood: {
|
||||
key: "mood",
|
||||
|
||||
Reference in new issue
Block a user