mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge branch 'fix/v14311-settings' into release/v1.43.11
# Conflicts: # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/ko.json # messages/nl.json # messages/pl.json
This commit is contained in:
commit
eabfeaef08
74 files changed
+2545
-436
No files matched your search
@@ -187,6 +187,7 @@ export function AiServerKeySection() {
|
||||
{configured && (
|
||||
<ConfirmButton
|
||||
slot="admin-ai-server-key-remove"
|
||||
destructive
|
||||
variant="outline"
|
||||
size="sm"
|
||||
label={t("admin.aiServerKey.remove")}
|
||||
|
||||
@@ -196,6 +196,7 @@ export function CentralCodexSection() {
|
||||
<SettingsCardActions>
|
||||
<ConfirmButton
|
||||
slot="admin-central-codex-disconnect"
|
||||
destructive
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
|
||||
@@ -880,6 +880,7 @@ export function LogDaySheet({
|
||||
{currentRowId ? (
|
||||
<ConfirmButton
|
||||
slot="cycle-day-delete"
|
||||
destructive
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive mr-auto"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
@@ -1471,6 +1472,7 @@ export function SymptomRow({
|
||||
</button>
|
||||
<ConfirmDialog
|
||||
slot="cycle-custom-symptom-remove"
|
||||
destructive
|
||||
open={confirmRemove}
|
||||
onOpenChange={setConfirmRemove}
|
||||
title={t("cycle.symptom.custom.removeTitle")}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* View / sort / order persist client-side via `useModuleListPrefs("labs")`.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import {
|
||||
ArrowDownUp,
|
||||
FlaskConical,
|
||||
@@ -29,6 +30,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ModuleViewToggle } from "@/components/module-list/module-view-toggle";
|
||||
@@ -139,7 +141,16 @@ export function LabsSettings() {
|
||||
<Switch
|
||||
checked={localOcr.data?.labsLocalOcrEnabled ?? false}
|
||||
disabled={localOcr.isLoading || updateLocalOcr.isPending}
|
||||
onCheckedChange={(checked) => updateLocalOcr.mutate(checked)}
|
||||
onCheckedChange={(checked) =>
|
||||
updateLocalOcr.mutate(checked, {
|
||||
onSuccess: () =>
|
||||
toastWrittenOutcome("success", t("settings.saved")),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.savingError"), {
|
||||
cause: failure,
|
||||
}),
|
||||
})
|
||||
}
|
||||
aria-label={t("labs.localOcr.heading")}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* reminder PATCH route.
|
||||
*/
|
||||
import { ArrowUpDown, LayoutGrid, ListChecks } from "lucide-react";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from "@/hooks/use-measurement-reminders";
|
||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
function resolveLabel(
|
||||
reminder: MeasurementReminder,
|
||||
@@ -127,10 +129,20 @@ export function VorsorgeSettings() {
|
||||
<Switch
|
||||
checked={reminder.enabled}
|
||||
onCheckedChange={(next) =>
|
||||
update.mutate({
|
||||
id: reminder.id,
|
||||
body: { enabled: next },
|
||||
})
|
||||
update.mutate(
|
||||
{ id: reminder.id, body: { enabled: next } },
|
||||
{
|
||||
onSuccess: () =>
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
t("settings.saved"),
|
||||
),
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.savingError"), {
|
||||
cause: failure,
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
disabled={update.isPending}
|
||||
// Label each row's Switch by its own name span so SR
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
@@ -30,7 +33,14 @@ vi.mock("@/lib/api/api-fetch", () => ({ apiGet: vi.fn(), apiPatch: vi.fn() }));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { EmergencyProfileManager } from "../emergency-profile-manager";
|
||||
import {
|
||||
emergencyProfileUpdateSchema,
|
||||
type EmergencyProfileDto,
|
||||
} from "@/lib/validations/emergency-profile";
|
||||
import {
|
||||
EmergencyProfileManager,
|
||||
emergencyPatchBody,
|
||||
} from "../emergency-profile-manager";
|
||||
|
||||
describe("<EmergencyProfileManager> Save", () => {
|
||||
it("stays disabled until a field differs from the stored profile", () => {
|
||||
@@ -43,3 +53,71 @@ describe("<EmergencyProfileManager> Save", () => {
|
||||
expect(row).toMatch(/<button[^>]*disabled=""[^>]*>[^]*?Save<\/button>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emergency selects can be taken back to not recorded", () => {
|
||||
// A blood type, donor status or directive, once chosen, could be changed
|
||||
// to another value but never cleared: the select offered no empty option,
|
||||
// and the save left out an empty select, so even a cleared draft would not
|
||||
// have reached the column.
|
||||
const stored: EmergencyProfileDto = {
|
||||
bloodType: "A_POS",
|
||||
organDonor: "YES",
|
||||
advanceDirective: null,
|
||||
contacts: "A contact",
|
||||
contactsUnreadable: false,
|
||||
implants: null,
|
||||
implantsUnreadable: false,
|
||||
note: null,
|
||||
noteUnreadable: false,
|
||||
};
|
||||
const draft = {
|
||||
bloodType: "A_POS",
|
||||
organDonor: "YES",
|
||||
advanceDirective: "",
|
||||
contacts: "A contact",
|
||||
implants: "",
|
||||
note: "",
|
||||
};
|
||||
|
||||
it("sends a cleared select as null, which clears the column", () => {
|
||||
expect(
|
||||
emergencyPatchBody({ ...draft, bloodType: "" }, stored),
|
||||
).toMatchObject({ bloodType: null });
|
||||
});
|
||||
|
||||
it("leaves an untouched select out of the save", () => {
|
||||
const body = emergencyPatchBody(draft, stored);
|
||||
expect(body).not.toHaveProperty("bloodType");
|
||||
expect(body).not.toHaveProperty("organDonor");
|
||||
expect(body).not.toHaveProperty("advanceDirective");
|
||||
});
|
||||
|
||||
it("sends a changed select's new value", () => {
|
||||
expect(
|
||||
emergencyPatchBody({ ...draft, advanceDirective: "YES" }, stored),
|
||||
).toMatchObject({ advanceDirective: "YES" });
|
||||
});
|
||||
|
||||
it("the server accepts what the card sends", () => {
|
||||
const body = emergencyPatchBody(
|
||||
{ ...draft, bloodType: "", organDonor: "NO" },
|
||||
stored,
|
||||
);
|
||||
const parsed = emergencyProfileUpdateSchema.safeParse(body);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data).toMatchObject({ bloodType: null, organDonor: "NO" });
|
||||
});
|
||||
|
||||
it("offers a not-recorded option in every select", () => {
|
||||
// The options live in the popover, which a static render never opens;
|
||||
// read from the source that each select carries the option.
|
||||
const source = readFileSync(
|
||||
resolve(__dirname, "../emergency-profile-manager.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/<SelectItem value=\{NOT_RECORDED\}>\s*\{t\("records\.emergency\.notRecorded"\)\}/,
|
||||
);
|
||||
expect(source).toMatch(/next === NOT_RECORDED \? "" : next/);
|
||||
});
|
||||
});
|
||||
@@ -194,6 +194,7 @@ export function AboutMeNoteManager() {
|
||||
{hasNote && (
|
||||
<ConfirmButton
|
||||
slot="settings-about-me-clear"
|
||||
destructive
|
||||
label={t("settings.ai.aboutMe.clear")}
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
variant="outline"
|
||||
|
||||
@@ -176,6 +176,7 @@ export function AuditCPanel({
|
||||
className="text-destructive size-11 shrink-0 sm:size-9"
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
slot={`audit-c-remove-${entry.id}`}
|
||||
destructive
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
@@ -214,6 +215,7 @@ export function AuditCPanel({
|
||||
className="text-destructive size-11 shrink-0 sm:size-9"
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
slot="audit-c-legacy-remove"
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
type EmergencyProfileDto,
|
||||
} from "@/lib/validations/emergency-profile";
|
||||
|
||||
interface Draft {
|
||||
export interface Draft {
|
||||
bloodType: string;
|
||||
organDonor: string;
|
||||
advanceDirective: string;
|
||||
@@ -40,6 +40,45 @@ interface Draft {
|
||||
note: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The select value that stands for "not recorded". Radix refuses an empty
|
||||
* string as an item value, so the cleared state needs a sentinel of its own;
|
||||
* the draft keeps "" and only the select sees this.
|
||||
*/
|
||||
const NOT_RECORDED = "__none__";
|
||||
|
||||
type EnumKey = "bloodType" | "organDonor" | "advanceDirective";
|
||||
const ENUM_KEYS: readonly EnumKey[] = [
|
||||
"bloodType",
|
||||
"organDonor",
|
||||
"advanceDirective",
|
||||
];
|
||||
|
||||
/**
|
||||
* The PATCH body for a save. A select rides only when it changed, so a save
|
||||
* leaves a column it did not touch alone; a changed select that is now empty
|
||||
* rides as `null`, which is what clears it. The free-text fields always ride,
|
||||
* an emptied one clearing its column server-side.
|
||||
*
|
||||
* An empty select used to be omitted always, and the select had no empty
|
||||
* option to begin with: a blood type, once chosen, could be changed to
|
||||
* another one but never taken back to "not recorded".
|
||||
*/
|
||||
export function emergencyPatchBody(
|
||||
draft: Draft,
|
||||
stored: EmergencyProfileDto,
|
||||
): Record<string, string | null> {
|
||||
const body: Record<string, string | null> = {};
|
||||
for (const key of ENUM_KEYS) {
|
||||
const next = draft[key] || null;
|
||||
if (next !== (stored[key] ?? null)) body[key] = next;
|
||||
}
|
||||
body.contacts = draft.contacts;
|
||||
body.implants = draft.implants;
|
||||
body.note = draft.note;
|
||||
return body;
|
||||
}
|
||||
|
||||
function draftFromDto(dto: EmergencyProfileDto): Draft {
|
||||
return {
|
||||
bloodType: dto.bloodType ?? "",
|
||||
@@ -66,19 +105,10 @@ export function EmergencyProfileManager() {
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.emergencyProfile(),
|
||||
mutationFn: (input: Draft) =>
|
||||
apiPatch<EmergencyProfileDto>("/api/anamnesis/emergency", {
|
||||
// A select left at "" is "not recorded" and is omitted so a partial
|
||||
// save leaves the column untouched; the free-text fields always ride,
|
||||
// with an emptied field clearing its column server-side.
|
||||
...(input.bloodType ? { bloodType: input.bloodType } : {}),
|
||||
...(input.organDonor ? { organDonor: input.organDonor } : {}),
|
||||
...(input.advanceDirective
|
||||
? { advanceDirective: input.advanceDirective }
|
||||
: {}),
|
||||
contacts: input.contacts,
|
||||
implants: input.implants,
|
||||
note: input.note,
|
||||
}),
|
||||
apiPatch<EmergencyProfileDto>(
|
||||
"/api/anamnesis/emergency",
|
||||
emergencyPatchBody(input, query.data!),
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
setDraft(draftFromDto(data));
|
||||
queryClient.setQueryData(queryKeys.emergencyProfile(), data);
|
||||
@@ -110,7 +140,7 @@ export function EmergencyProfileManager() {
|
||||
setDraft((prev) => ({ ...(prev ?? draftFromDto(query.data!)), ...patch }));
|
||||
|
||||
const enumField = (
|
||||
key: "bloodType" | "organDonor" | "advanceDirective",
|
||||
key: EnumKey,
|
||||
values: readonly string[],
|
||||
valuePrefix: string,
|
||||
) => (
|
||||
@@ -119,14 +149,21 @@ export function EmergencyProfileManager() {
|
||||
{t(`records.emergency.${key}`)}
|
||||
</Label>
|
||||
<Select
|
||||
value={current?.[key] ?? ""}
|
||||
value={current?.[key] || NOT_RECORDED}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => set({ [key]: next } as Partial<Draft>)}
|
||||
onValueChange={(next) =>
|
||||
set({ [key]: next === NOT_RECORDED ? "" : next } as Partial<Draft>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`emergency-${key}`} className="w-full">
|
||||
<SelectValue placeholder={t("records.emergency.notRecorded")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* The way back to "not recorded", as every optional select in the
|
||||
records offers one. */}
|
||||
<SelectItem value={NOT_RECORDED}>
|
||||
{t("records.emergency.notRecorded")}
|
||||
</SelectItem>
|
||||
{values.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{t(`records.emergency.${valuePrefix}.${option}`)}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Settings actions say how they went.
|
||||
*
|
||||
* Each action below finished in silence on at least one side. Adding or
|
||||
* removing a passkey or a security key only redrew the list; disconnecting an
|
||||
* integration and revoking a share link fired from a dialog that had already
|
||||
* closed, so a refused request left everything as it was and said nothing;
|
||||
* the injection-site exclusions, the local-OCR switch and the preventive-care
|
||||
* reminder switches saved, or did not, without a word.
|
||||
*
|
||||
* They use the pair the rest of the app reports a write with —
|
||||
* `toastWrittenOutcome("success", …)`, the outcome module's one door to the
|
||||
* success toast, and `showErrorToast` (which carries the error id of a server
|
||||
* failure). The passkey and security-key cards keep their inline error line,
|
||||
* which also carries the step-up dialog's outcome, and gain the success half.
|
||||
*
|
||||
* Mutation callbacks only run after an interaction, so the wiring is read
|
||||
* from the source: per action, the success callback raises the success
|
||||
* outcome and, where the card has no inline error line, the error callback raises
|
||||
* `showErrorToast`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
interface Site {
|
||||
file: string;
|
||||
/** `const <name> = useMutation({…})`, or `<name>.mutate(…, {…})`. */
|
||||
name: string;
|
||||
shape: "definition" | "call";
|
||||
needsErrorToast: boolean;
|
||||
}
|
||||
|
||||
const SITES: Site[] = [
|
||||
{
|
||||
file: "src/components/settings/security-section/passkey-list-section.tsx",
|
||||
name: "add",
|
||||
shape: "definition",
|
||||
needsErrorToast: false,
|
||||
},
|
||||
{
|
||||
file: "src/components/settings/security-section/passkey-list-section.tsx",
|
||||
name: "remove",
|
||||
shape: "definition",
|
||||
needsErrorToast: false,
|
||||
},
|
||||
{
|
||||
file: "src/components/settings/security-section/security-keys-card.tsx",
|
||||
name: "add",
|
||||
shape: "definition",
|
||||
needsErrorToast: false,
|
||||
},
|
||||
{
|
||||
file: "src/components/settings/security-section/security-keys-card.tsx",
|
||||
name: "remove",
|
||||
shape: "definition",
|
||||
needsErrorToast: false,
|
||||
},
|
||||
{
|
||||
file: "src/components/settings/integrations/oauth-integration-card.tsx",
|
||||
name: "disconnect",
|
||||
shape: "definition",
|
||||
needsErrorToast: true,
|
||||
},
|
||||
{
|
||||
file: "src/components/settings/sharing-section.tsx",
|
||||
name: "revokeMutation",
|
||||
shape: "definition",
|
||||
needsErrorToast: true,
|
||||
},
|
||||
{
|
||||
file: "src/components/settings/injection-sites-card.tsx",
|
||||
name: "mutation",
|
||||
shape: "definition",
|
||||
needsErrorToast: true,
|
||||
},
|
||||
{
|
||||
file: "src/components/labs/labs-settings.tsx",
|
||||
name: "updateLocalOcr",
|
||||
shape: "call",
|
||||
needsErrorToast: true,
|
||||
},
|
||||
{
|
||||
file: "src/components/measurement-reminders/vorsorge-settings.tsx",
|
||||
name: "update",
|
||||
shape: "call",
|
||||
needsErrorToast: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** The `{ onSuccess, onError, … }` literal for a site, or null. */
|
||||
function callbacks(site: Site): ts.ObjectLiteralExpression | null {
|
||||
const text = readFileSync(join(process.cwd(), site.file), "utf8");
|
||||
const sf = ts.createSourceFile(site.file, text, ts.ScriptTarget.Latest, true);
|
||||
let found: ts.ObjectLiteralExpression | null = null;
|
||||
const visit = (node: ts.Node) => {
|
||||
if (found) return;
|
||||
if (
|
||||
site.shape === "definition" &&
|
||||
ts.isVariableDeclaration(node) &&
|
||||
node.name.getText(sf) === site.name &&
|
||||
node.initializer &&
|
||||
ts.isCallExpression(node.initializer) &&
|
||||
node.initializer.expression.getText(sf) === "useMutation"
|
||||
) {
|
||||
const arg = node.initializer.arguments[0];
|
||||
if (arg && ts.isObjectLiteralExpression(arg)) found = arg;
|
||||
}
|
||||
if (
|
||||
site.shape === "call" &&
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.getText(sf) === `${site.name}.mutate`
|
||||
) {
|
||||
const arg = node.arguments[1];
|
||||
if (arg && ts.isObjectLiteralExpression(arg)) found = arg;
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
return found;
|
||||
}
|
||||
|
||||
function property(obj: ts.ObjectLiteralExpression, name: string): string {
|
||||
const prop = obj.properties.find((p) => p.name && p.name.getText() === name);
|
||||
return prop ? prop.getText() : "";
|
||||
}
|
||||
|
||||
describe("settings actions report their outcome", () => {
|
||||
for (const site of SITES) {
|
||||
describe(`${site.file} → ${site.name}`, () => {
|
||||
const obj = callbacks(site);
|
||||
|
||||
it("is found", () => {
|
||||
expect(obj, "the mutation options were not found").not.toBeNull();
|
||||
});
|
||||
|
||||
it("raises a success toast", () => {
|
||||
expect(property(obj!, "onSuccess")).toMatch(
|
||||
/toastWrittenOutcome\(\s*"success"/,
|
||||
);
|
||||
});
|
||||
|
||||
if (site.needsErrorToast) {
|
||||
it("raises an error toast through showErrorToast", () => {
|
||||
expect(property(obj!, "onError")).toContain("showErrorToast(");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -216,12 +216,25 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
|
||||
expect(html).toContain("ChatGPT-Account (Codex)");
|
||||
// v1.4.33 — the active-provider heading and the parent section
|
||||
// both ship without the "KI"/"AI" prefix per the project-voice rule
|
||||
// ("Aktiver KI-Provider" → "Aktiver Provider"). The heading and the
|
||||
// chain summary now share the same phrase, so we assert against the
|
||||
// explicit `activeProviderLabel` slot below it ("Primärer Provider")
|
||||
// to keep the test pinned to the right surface.
|
||||
// ("Aktiver KI-Provider" → "Aktiver Provider"). The picker below it is
|
||||
// labelled for what it does: it chooses the form, not the order.
|
||||
expect(html).toContain("Aktiver Provider");
|
||||
expect(html).toContain("Primärer Provider");
|
||||
expect(html).toContain("Anbieter einrichten");
|
||||
});
|
||||
|
||||
it("labels the picker for what it does, not as the primary provider", () => {
|
||||
// The select picks which provider's key and model the form below edits.
|
||||
// It never wrote anything, yet read "Primary provider" under a card
|
||||
// that said it held "the provider HealthLog asks first": a person who
|
||||
// picked one expected it to answer first, and the chain order, which is
|
||||
// what decides that, stayed as it was. The copy now points at the chain.
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
expect(html).not.toContain("Primary provider");
|
||||
expect(html).toContain("Set up a provider");
|
||||
expect(html).toContain(
|
||||
"the order below decides which one HealthLog asks first",
|
||||
);
|
||||
});
|
||||
|
||||
// v1.18.0 — the Coach preference cards (disable toggle, preferences,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Ending a grant from the sessions card asks first.
|
||||
*
|
||||
* "Sign out everywhere" leaves accepted grants standing and lists them with a
|
||||
* one-click end. That end is the same act as the access card's revoke — it
|
||||
* takes another account's standing access to the record, permanently — but it
|
||||
* fired on the tap, while the access card asks with a dialog naming who loses
|
||||
* what. The row is state set after a mutation, out of reach of a static
|
||||
* render, so the wiring is read from the source: the revoke call may only sit
|
||||
* inside a `ConfirmButton`'s `onConfirm`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const FILE = "src/components/settings/security-sessions-card.tsx";
|
||||
|
||||
function revokeCallSites(): { attr: string | null; element: string | null }[] {
|
||||
const text = readFileSync(join(process.cwd(), FILE), "utf8");
|
||||
const sf = ts.createSourceFile(FILE, text, ts.ScriptTarget.Latest, true);
|
||||
const sites: { attr: string | null; element: string | null }[] = [];
|
||||
const visit = (node: ts.Node) => {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.getText(sf) === "revokeGrant.mutate"
|
||||
) {
|
||||
let attr: string | null = null;
|
||||
let element: string | null = null;
|
||||
for (let p: ts.Node | undefined = node.parent; p; p = p.parent) {
|
||||
if (ts.isJsxAttribute(p) && attr === null) attr = p.name.getText(sf);
|
||||
if (
|
||||
(ts.isJsxSelfClosingElement(p) || ts.isJsxOpeningElement(p)) &&
|
||||
attr !== null
|
||||
) {
|
||||
element = p.tagName.getText(sf);
|
||||
break;
|
||||
}
|
||||
}
|
||||
sites.push({ attr, element });
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sf);
|
||||
return sites;
|
||||
}
|
||||
|
||||
describe("kept-grant revoke on the sessions card", () => {
|
||||
it("fires only from a ConfirmButton's onConfirm", () => {
|
||||
const sites = revokeCallSites();
|
||||
expect(sites.length).toBeGreaterThan(0);
|
||||
for (const site of sites) {
|
||||
expect(site).toEqual({ attr: "onConfirm", element: "ConfirmButton" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* A settings save that raises its own pending flag lowers it in a `finally`.
|
||||
*
|
||||
* The cards that hand-roll a `saving` flag around a raw `fetch` set it, await
|
||||
* the request, and cleared it on the line after. A request that never reached
|
||||
* the server (offline, a dropped connection, a timeout) rejects instead of
|
||||
* returning a response, the line after never ran, and the card stayed in its
|
||||
* saving state for good: the Telegram form, the mood reminder, the low-stock
|
||||
* threshold, the lock-screen detail switch, the Coach nudges, the profile
|
||||
* form. Every one of them disabled its own control while "saving", so the
|
||||
* only way out was a reload.
|
||||
*
|
||||
* `useMutation` owns its `isPending` and cannot get stuck this way; this
|
||||
* guard covers the hand-rolled ones. For every async function under
|
||||
* `src/components/settings` that sets one of the pending flags below to
|
||||
* `true` and then awaits, it requires a `try` whose `finally` sets the same
|
||||
* flag back to `false` — or, the older shape that is equally safe, every
|
||||
* await inside a `try` that catches, so nothing can skip the reset after it.
|
||||
* What it cannot see: a flag under another name, or a function that hands
|
||||
* the await to a helper it does not await itself.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "@/__tests__/helpers/source-files";
|
||||
|
||||
const ROOT = join(process.cwd(), "src/components/settings");
|
||||
const PENDING_SETTER =
|
||||
/^set(Saving|Pending|Busy|Loading|Submitting|ActionLoading)$/;
|
||||
|
||||
interface Finding {
|
||||
where: string;
|
||||
setter: string;
|
||||
/** The flag is lowered in a `finally`, or no await can throw past the reset. */
|
||||
safe: boolean;
|
||||
}
|
||||
|
||||
/** Is this await inside the try block of a `try` that has a `catch`? */
|
||||
function caught(node: ts.Node, fn: ts.Node): boolean {
|
||||
for (let p = node.parent; p && p !== fn; p = p.parent) {
|
||||
if (
|
||||
ts.isBlock(p) &&
|
||||
ts.isTryStatement(p.parent) &&
|
||||
p.parent.tryBlock === p &&
|
||||
p.parent.catchClause
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type FunctionLike =
|
||||
| ts.FunctionDeclaration
|
||||
| ts.FunctionExpression
|
||||
| ts.ArrowFunction
|
||||
| ts.MethodDeclaration;
|
||||
|
||||
function isFunctionLike(node: ts.Node): node is FunctionLike {
|
||||
return (
|
||||
ts.isFunctionDeclaration(node) ||
|
||||
ts.isFunctionExpression(node) ||
|
||||
ts.isArrowFunction(node) ||
|
||||
ts.isMethodDeclaration(node)
|
||||
);
|
||||
}
|
||||
|
||||
/** Walk a function body without descending into nested functions. */
|
||||
function ownNodes(body: ts.Node, visit: (node: ts.Node) => void): void {
|
||||
const step = (node: ts.Node) => {
|
||||
visit(node);
|
||||
if (isFunctionLike(node)) return;
|
||||
ts.forEachChild(node, step);
|
||||
};
|
||||
ts.forEachChild(body, step);
|
||||
}
|
||||
|
||||
function setterCall(node: ts.Node, sf: ts.SourceFile, value: string) {
|
||||
return (
|
||||
ts.isCallExpression(node) &&
|
||||
ts.isIdentifier(node.expression) &&
|
||||
PENDING_SETTER.test(node.expression.text) &&
|
||||
node.arguments.length === 1 &&
|
||||
node.arguments[0].getText(sf) === value
|
||||
);
|
||||
}
|
||||
|
||||
function scan(): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
for (const rel of walkSourceFiles(ROOT, { floor: 150 })) {
|
||||
if (rel.includes("__tests__/")) continue;
|
||||
const text = readFileSync(join(ROOT, rel), "utf8");
|
||||
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true);
|
||||
const visitFn = (node: ts.Node) => {
|
||||
if (
|
||||
isFunctionLike(node) &&
|
||||
node.body &&
|
||||
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)
|
||||
) {
|
||||
const raised = new Set<string>();
|
||||
const awaits: ts.Node[] = [];
|
||||
ownNodes(node.body, (n) => {
|
||||
if (setterCall(n, sf, "true")) {
|
||||
raised.add(
|
||||
((n as ts.CallExpression).expression as ts.Identifier).text,
|
||||
);
|
||||
}
|
||||
if (ts.isAwaitExpression(n)) awaits.push(n);
|
||||
});
|
||||
const allCaught = awaits.every((a) => caught(a, node));
|
||||
if (awaits.length > 0) {
|
||||
for (const setter of raised) {
|
||||
let resetInFinally = false;
|
||||
ownNodes(node.body, (n) => {
|
||||
if (!ts.isTryStatement(n) || !n.finallyBlock) return;
|
||||
ownNodes(n.finallyBlock, (m) => {
|
||||
if (
|
||||
setterCall(m, sf, "false") &&
|
||||
((m as ts.CallExpression).expression as ts.Identifier)
|
||||
.text === setter
|
||||
) {
|
||||
resetInFinally = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
const line =
|
||||
sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
||||
findings.push({
|
||||
where: `${rel}:${line}`,
|
||||
setter,
|
||||
safe: resetInFinally || allCaught,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visitFn);
|
||||
};
|
||||
visitFn(sf);
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
describe("settings saves cannot stay pending after a failed request", () => {
|
||||
const findings = scan();
|
||||
|
||||
it("finds the hand-rolled saves it is meant to check", () => {
|
||||
// A matcher that found nothing would pass on any tree.
|
||||
expect(findings.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("every one lowers its flag in a finally", () => {
|
||||
const stuck = findings
|
||||
.filter((f) => !f.safe)
|
||||
.map((f) => `${f.where} (${f.setter})`);
|
||||
expect(
|
||||
stuck,
|
||||
"Move the reset into `finally`, so a request that rejects (offline, timeout) cannot leave the card saving.",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* The token revoke confirmation names the act.
|
||||
*
|
||||
* Both token lists (API tokens and MCP connections) labelled the dialog's
|
||||
* confirm button with `settings.tokenRevoked` — "Revoked", the status word the
|
||||
* admin overview shows for a token that is already gone — so the button that
|
||||
* performs the revoke read as if it had happened. The phone layout's trigger
|
||||
* carried the dialog title instead, question mark included.
|
||||
*
|
||||
* Read from the source because the dialog content only mounts while open, and
|
||||
* an SSR render never sees it. What is pinned: every `AlertDialogAction` that
|
||||
* calls `handleRevoke` is labelled with the action key, and no trigger or
|
||||
* action on these two surfaces uses the status word.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const FILES = [
|
||||
"src/components/settings/api-section.tsx",
|
||||
"src/components/settings/mcp-section.tsx",
|
||||
];
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(process.cwd(), rel), "utf8");
|
||||
}
|
||||
|
||||
/** The body of every `<AlertDialogAction …>…</AlertDialogAction>` block. */
|
||||
function actions(source: string): string[] {
|
||||
return [
|
||||
...source.matchAll(/<AlertDialogAction\b[\s\S]*?<\/AlertDialogAction>/g),
|
||||
].map((m) => m[0]);
|
||||
}
|
||||
|
||||
describe("token revoke labels", () => {
|
||||
for (const rel of FILES) {
|
||||
it(`${rel}: the revoke confirm says "Revoke token"`, () => {
|
||||
const revokes = actions(read(rel)).filter((a) =>
|
||||
a.includes("handleRevoke(tok.id)"),
|
||||
);
|
||||
expect(revokes.length).toBeGreaterThan(0);
|
||||
for (const action of revokes) {
|
||||
expect(action).toContain('t("settings.tokenRevokeAction")');
|
||||
}
|
||||
});
|
||||
|
||||
it(`${rel}: never labels a control with the status word`, () => {
|
||||
expect(read(rel)).not.toContain('t("settings.tokenRevoked")');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -127,6 +127,7 @@ export function GrantsGivenCard() {
|
||||
return (
|
||||
<ConfirmButton
|
||||
slot="grant-revoke"
|
||||
destructive
|
||||
size="sm"
|
||||
label={
|
||||
grant.state === "PENDING"
|
||||
|
||||
@@ -229,6 +229,7 @@ export function GrantsReceivedCard() {
|
||||
</Button>
|
||||
<ConfirmButton
|
||||
slot="grant-renounce"
|
||||
destructive
|
||||
size="sm"
|
||||
label={t("recordSharing.received.renounce")}
|
||||
title={t("recordSharing.received.renounceTitle", { name })}
|
||||
|
||||
@@ -188,6 +188,7 @@ function ManagedProfileRow({
|
||||
{guardians.data && (
|
||||
<ConfirmButton
|
||||
slot="managed-profile-delete"
|
||||
destructive
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
label={t("recordSharing.managed.delete")}
|
||||
|
||||
@@ -172,6 +172,7 @@ export function ManagedProfileGuardians({
|
||||
<div className="flex shrink-0 items-center">
|
||||
<ConfirmButton
|
||||
slot="managed-profile-guardian-remove"
|
||||
destructive
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
label={t("recordSharing.managed.guardianRemove")}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The display name can be changed after the setup flow.
|
||||
*
|
||||
* The setup flow asks for the name the greeting and the reports use, and the
|
||||
* profile route has always accepted `displayName` — but no settings surface
|
||||
* offered it, and `/api/auth/me` did not even publish it, so a name chosen on
|
||||
* the first day was the name for good. The profile card now carries the
|
||||
* field, seeded from the account payload, and sends it with the save.
|
||||
*
|
||||
* Both ends are pinned: the route publishes the field (read from its response
|
||||
* literal, the way `account-payload-consumer-guard` reads it), and the card
|
||||
* renders it seeded and posts it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
let displayName: string | null = "Sam";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/account",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "testuser",
|
||||
displayName,
|
||||
email: "user@example.com",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
role: "USER",
|
||||
unitPreference: "metric",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AccountSection } from "@/components/settings/account-section";
|
||||
|
||||
function render(): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AccountSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The `<input id="display-name" …>` tag, or null. */
|
||||
function displayNameInput(html: string): string | null {
|
||||
return /<input[^>]*id="display-name"[^>]*>/.exec(html)?.[0] ?? null;
|
||||
}
|
||||
|
||||
describe("display name on the profile card", () => {
|
||||
it("renders the field seeded with the stored name", () => {
|
||||
displayName = "Sam";
|
||||
const input = displayNameInput(render());
|
||||
expect(input).not.toBeNull();
|
||||
expect(input).toContain('value="Sam"');
|
||||
});
|
||||
|
||||
it("renders it empty for an account that never set one", () => {
|
||||
displayName = null;
|
||||
const input = displayNameInput(render());
|
||||
expect(input).not.toBeNull();
|
||||
expect(input).toContain('value=""');
|
||||
});
|
||||
|
||||
it("sends the field with the profile save", () => {
|
||||
const source = readFileSync(resolve(__dirname, "../index.tsx"), "utf8");
|
||||
expect(source).toMatch(
|
||||
/displayName:\s*savedProfile\.displayName\s*\|\|\s*null/,
|
||||
);
|
||||
});
|
||||
|
||||
it("is published on the account payload the card seeds from", () => {
|
||||
const route = readFileSync(
|
||||
join(process.cwd(), "src/app/api/auth/me/route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(route).toMatch(/\bdisplayName:\s*user\.displayName\s*\?\?\s*null/);
|
||||
});
|
||||
});
|
||||
@@ -154,6 +154,7 @@ export function AvatarSection() {
|
||||
{avatarUrl && (
|
||||
<ConfirmButton
|
||||
slot="settings-avatar-remove"
|
||||
destructive
|
||||
variant="ghost"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
|
||||
@@ -95,12 +95,16 @@ export function AccountSection() {
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
const [gender, setGender] = useState<string>("");
|
||||
const [timezone, setTimezone] = useState<string>(DEFAULT_TIMEZONE);
|
||||
// The name the app addresses the account by; set in the setup flow, and
|
||||
// until now not editable anywhere after it.
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
// v1.7.0 — optional patient-identity fields for the health-record export.
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [insurerName, setInsurerName] = useState("");
|
||||
const [insuranceNumber, setInsuranceNumber] = useState("");
|
||||
const [profileSeed, setProfileSeed] = useState({
|
||||
email: "",
|
||||
displayName: "",
|
||||
height: EMPTY_HEIGHT_DRAFT,
|
||||
dateOfBirth: "",
|
||||
gender: "",
|
||||
@@ -149,6 +153,7 @@ export function AccountSection() {
|
||||
if (user && user.id !== seededUserId) {
|
||||
const seed = {
|
||||
email: user.email ?? "",
|
||||
displayName: user.displayName ?? "",
|
||||
height: heightAdapter.toDraft(user.heightCm),
|
||||
dateOfBirth: user.dateOfBirth
|
||||
? dateOnlyKey(new Date(user.dateOfBirth))
|
||||
@@ -161,6 +166,7 @@ export function AccountSection() {
|
||||
};
|
||||
setSeededUserId(user.id);
|
||||
setEmail(seed.email);
|
||||
setDisplayName(seed.displayName);
|
||||
setHeight(seed.height);
|
||||
setDateOfBirth(seed.dateOfBirth);
|
||||
setGender(seed.gender);
|
||||
@@ -192,6 +198,7 @@ export function AccountSection() {
|
||||
seed: profileSeed,
|
||||
value: {
|
||||
email,
|
||||
displayName,
|
||||
height,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
@@ -253,132 +260,148 @@ export function AccountSection() {
|
||||
setSaveMsg(null);
|
||||
setSaveMsgType(null);
|
||||
setFieldErrors({});
|
||||
const savedProfile = {
|
||||
email: email.trim(),
|
||||
height,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
timezone,
|
||||
fullName: fullName.trim(),
|
||||
insurerName: insurerName.trim(),
|
||||
insuranceNumber: insuranceNumber.trim(),
|
||||
};
|
||||
try {
|
||||
const savedProfile = {
|
||||
email: email.trim(),
|
||||
displayName: displayName.trim(),
|
||||
height,
|
||||
dateOfBirth,
|
||||
gender,
|
||||
timezone,
|
||||
fullName: fullName.trim(),
|
||||
insurerName: insurerName.trim(),
|
||||
insuranceNumber: insuranceNumber.trim(),
|
||||
};
|
||||
|
||||
// The timezone is owned by a dedicated route (v1.4.25 W7) so the
|
||||
// resolver cache can be invalidated without piping the flag
|
||||
// through the bigger profile patch path. Run the two PUTs in
|
||||
// parallel — they're independent.
|
||||
const [profileRes, tzRes] = await Promise.all([
|
||||
apiFetchRaw("/api/auth/profile", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: savedProfile.email || null,
|
||||
...(emailChanged && emailPassword
|
||||
? { currentPassword: emailPassword }
|
||||
: {}),
|
||||
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
|
||||
dateOfBirth: savedProfile.dateOfBirth || null,
|
||||
gender: savedProfile.gender || null,
|
||||
fullName: savedProfile.fullName || null,
|
||||
insurerName: savedProfile.insurerName || null,
|
||||
insuranceNumber: savedProfile.insuranceNumber || null,
|
||||
// The timezone is owned by a dedicated route (v1.4.25 W7) so the
|
||||
// resolver cache can be invalidated without piping the flag
|
||||
// through the bigger profile patch path. Run the two PUTs in
|
||||
// parallel — they're independent.
|
||||
const [profileRes, tzRes] = await Promise.all([
|
||||
apiFetchRaw("/api/auth/profile", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: savedProfile.email || null,
|
||||
// An emptied field clears the name; the greeting falls back to
|
||||
// the username, as it does for an account that never set one.
|
||||
displayName: savedProfile.displayName || null,
|
||||
...(emailChanged && emailPassword
|
||||
? { currentPassword: emailPassword }
|
||||
: {}),
|
||||
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
|
||||
dateOfBirth: savedProfile.dateOfBirth || null,
|
||||
gender: savedProfile.gender || null,
|
||||
fullName: savedProfile.fullName || null,
|
||||
insurerName: savedProfile.insurerName || null,
|
||||
insuranceNumber: savedProfile.insuranceNumber || null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
user && timezone && timezone !== user.timezone
|
||||
? apiFetchRaw("/api/auth/me/timezone", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ timezone }),
|
||||
})
|
||||
: Promise.resolve({ ok: true } as Response),
|
||||
]);
|
||||
user && timezone && timezone !== user.timezone
|
||||
? apiFetchRaw("/api/auth/me/timezone", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ timezone }),
|
||||
})
|
||||
: Promise.resolve({ ok: true } as Response),
|
||||
]);
|
||||
|
||||
setEmailPassword("");
|
||||
setEmailPassword("");
|
||||
|
||||
if (profileRes.ok && tzRes.ok) {
|
||||
// Instant same-tab flip of every rendered timestamp (issue #490):
|
||||
// mirror the accepted zone before the `/me` refetch confirms it —
|
||||
// the same synchronous-update pattern the time/date-format selects
|
||||
// use. `refetch()` → `fetchMe` re-writes the mirror authoritatively.
|
||||
if (user && timezone && timezone !== user.timezone) {
|
||||
storeTimezone(timezone);
|
||||
}
|
||||
const profileJson = (await profileRes.json().catch(() => null)) as {
|
||||
data?: { rejectedFields?: RejectedProfileField[] };
|
||||
} | null;
|
||||
const rejected = profileJson?.data?.rejectedFields;
|
||||
if (profileRes.ok && tzRes.ok) {
|
||||
// Instant same-tab flip of every rendered timestamp (issue #490):
|
||||
// mirror the accepted zone before the `/me` refetch confirms it —
|
||||
// the same synchronous-update pattern the time/date-format selects
|
||||
// use. `refetch()` → `fetchMe` re-writes the mirror authoritatively.
|
||||
if (user && timezone && timezone !== user.timezone) {
|
||||
storeTimezone(timezone);
|
||||
}
|
||||
const profileJson = (await profileRes.json().catch(() => null)) as {
|
||||
data?: { rejectedFields?: RejectedProfileField[] };
|
||||
} | null;
|
||||
const rejected = profileJson?.data?.rejectedFields;
|
||||
|
||||
if (!afterFactor && needsSecondFactor(rejected, undefined)) {
|
||||
if (await proveFactorThenSave()) return;
|
||||
}
|
||||
if (!afterFactor && needsSecondFactor(rejected, undefined)) {
|
||||
if (await proveFactorThenSave()) return;
|
||||
}
|
||||
|
||||
if (rejected && rejected.length > 0) {
|
||||
// The write is field-independent: everything but the rejected
|
||||
// field(s) landed. Name the field instead of implying full
|
||||
// success, and reseed the form from what the server actually
|
||||
// persisted — the client's optimistic value for the rejected
|
||||
// field was never written, so it must not read as saved.
|
||||
setSaveMsg({
|
||||
key: "settings.profilePartiallySaved",
|
||||
params: { field: describeRejectedProfileField(rejected, t) ?? "" },
|
||||
});
|
||||
setSaveMsgType("warning");
|
||||
setFieldErrors(describeRejectedProfileFields(rejected, t));
|
||||
await refetch();
|
||||
setSeededUserId(null);
|
||||
if (rejected && rejected.length > 0) {
|
||||
// The write is field-independent: everything but the rejected
|
||||
// field(s) landed. Name the field instead of implying full
|
||||
// success, and reseed the form from what the server actually
|
||||
// persisted — the client's optimistic value for the rejected
|
||||
// field was never written, so it must not read as saved.
|
||||
setSaveMsg({
|
||||
key: "settings.profilePartiallySaved",
|
||||
params: { field: describeRejectedProfileField(rejected, t) ?? "" },
|
||||
});
|
||||
setSaveMsgType("warning");
|
||||
setFieldErrors(describeRejectedProfileFields(rejected, t));
|
||||
await refetch();
|
||||
setSeededUserId(null);
|
||||
} else {
|
||||
setSaveMsg({ key: "settings.profileSaved" });
|
||||
setSaveMsgType("success");
|
||||
setEmail(savedProfile.email);
|
||||
setDisplayName(savedProfile.displayName);
|
||||
setFullName(savedProfile.fullName);
|
||||
setInsurerName(savedProfile.insurerName);
|
||||
setInsuranceNumber(savedProfile.insuranceNumber);
|
||||
setProfileSeed(savedProfile);
|
||||
await refetch();
|
||||
}
|
||||
} else if (!tzRes.ok) {
|
||||
// The dedicated tz endpoint owns the IANA validation error
|
||||
// text. Surface its message verbatim so the user sees
|
||||
// "Not a valid IANA timezone." instead of the generic save
|
||||
// failure copy.
|
||||
try {
|
||||
const json = (await (tzRes as Response).json()) as { error?: string };
|
||||
setSaveMsg(
|
||||
json.error
|
||||
? { text: json.error }
|
||||
: { key: "settings.timezoneInvalid" },
|
||||
);
|
||||
} catch {
|
||||
setSaveMsg({ key: "settings.timezoneInvalid" });
|
||||
}
|
||||
setSaveMsgType("error");
|
||||
} else {
|
||||
setSaveMsg({ key: "settings.profileSaved" });
|
||||
setSaveMsgType("success");
|
||||
setEmail(savedProfile.email);
|
||||
setFullName(savedProfile.fullName);
|
||||
setInsurerName(savedProfile.insurerName);
|
||||
setInsuranceNumber(savedProfile.insuranceNumber);
|
||||
setProfileSeed(savedProfile);
|
||||
await refetch();
|
||||
}
|
||||
} else if (!tzRes.ok) {
|
||||
// The dedicated tz endpoint owns the IANA validation error
|
||||
// text. Surface its message verbatim so the user sees
|
||||
// "Not a valid IANA timezone." instead of the generic save
|
||||
// failure copy.
|
||||
try {
|
||||
const json = (await (tzRes as Response).json()) as { error?: string };
|
||||
setSaveMsg(
|
||||
json.error
|
||||
? { text: json.error }
|
||||
: { key: "settings.timezoneInvalid" },
|
||||
);
|
||||
} catch {
|
||||
setSaveMsg({ key: "settings.timezoneInvalid" });
|
||||
}
|
||||
setSaveMsgType("error");
|
||||
} else {
|
||||
// The server's top-level `error` string is intentionally generic
|
||||
// (never a raw validator message); resolve the specific, localized
|
||||
// reason through `meta.errorCode` instead of echoing it verbatim.
|
||||
const json = (await profileRes.json().catch(() => null)) as {
|
||||
meta?: { errorCode?: string };
|
||||
details?: { issues?: RejectedProfileField[] };
|
||||
} | null;
|
||||
const errorCode = json?.meta?.errorCode;
|
||||
if (!afterFactor && needsSecondFactor(undefined, errorCode)) {
|
||||
if (await proveFactorThenSave()) return;
|
||||
}
|
||||
const field = describeRejectedProfileField(json?.details?.issues, t);
|
||||
setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t));
|
||||
if (errorCode === "profile.update.nothingSaved" && field) {
|
||||
setSaveMsg({ key: "settings.profileNothingSaved", params: { field } });
|
||||
} else if (translateCodeOrNull(t, "apiErrors", errorCode) !== null) {
|
||||
// Only a code with a sentence of its own: every refusal carries a
|
||||
// code now, and the generic `http.*` family has no translation.
|
||||
setSaveMsg({ key: `apiErrors.${errorCode}` });
|
||||
} else {
|
||||
setSaveMsg({ key: "settings.savingError" });
|
||||
// The server's top-level `error` string is intentionally generic
|
||||
// (never a raw validator message); resolve the specific, localized
|
||||
// reason through `meta.errorCode` instead of echoing it verbatim.
|
||||
const json = (await profileRes.json().catch(() => null)) as {
|
||||
meta?: { errorCode?: string };
|
||||
details?: { issues?: RejectedProfileField[] };
|
||||
} | null;
|
||||
const errorCode = json?.meta?.errorCode;
|
||||
if (!afterFactor && needsSecondFactor(undefined, errorCode)) {
|
||||
if (await proveFactorThenSave()) return;
|
||||
}
|
||||
const field = describeRejectedProfileField(json?.details?.issues, t);
|
||||
setFieldErrors(describeRejectedProfileFields(json?.details?.issues, t));
|
||||
if (errorCode === "profile.update.nothingSaved" && field) {
|
||||
setSaveMsg({
|
||||
key: "settings.profileNothingSaved",
|
||||
params: { field },
|
||||
});
|
||||
} else if (translateCodeOrNull(t, "apiErrors", errorCode) !== null) {
|
||||
// Only a code with a sentence of its own: every refusal carries a
|
||||
// code now, and the generic `http.*` family has no translation.
|
||||
setSaveMsg({ key: `apiErrors.${errorCode}` });
|
||||
} else {
|
||||
setSaveMsg({ key: "settings.savingError" });
|
||||
}
|
||||
setSaveMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
// The request never reached the server (offline, a dropped
|
||||
// connection): a failed save, said as one, never a card left saving.
|
||||
setSaveMsg({ key: "settings.savingError" });
|
||||
setSaveMsgType("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
@@ -438,45 +461,81 @@ export function AccountSection() {
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
{/* The name the greeting and the reports use. The setup flow
|
||||
asks for it once; this is where it changes afterwards. */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||
<Label htmlFor="display-name">
|
||||
{t("onboarding.baseline.displayNameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
id="display-name"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
clearFieldError("email");
|
||||
setDisplayName(e.target.value);
|
||||
clearFieldError("displayName");
|
||||
}}
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
maxLength={320}
|
||||
autoComplete="email"
|
||||
placeholder={t("onboarding.baseline.displayNamePlaceholder")}
|
||||
maxLength={80}
|
||||
autoComplete="nickname"
|
||||
enterKeyHint="next"
|
||||
aria-invalid={fieldErrors.email ? true : undefined}
|
||||
aria-describedby={fieldErrors.email ? "email-error" : undefined}
|
||||
aria-invalid={fieldErrors.displayName ? true : undefined}
|
||||
aria-describedby={
|
||||
fieldErrors.displayName
|
||||
? "display-name-hint display-name-error"
|
||||
: "display-name-hint"
|
||||
}
|
||||
/>
|
||||
<p
|
||||
id="display-name-hint"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("onboarding.baseline.displayNameHint")}
|
||||
</p>
|
||||
<FieldError
|
||||
id="display-name-error"
|
||||
message={fieldErrors.displayName}
|
||||
/>
|
||||
<FieldError id="email-error" message={fieldErrors.email} />
|
||||
{emailChanged && !emailNeedsFactor && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Label htmlFor="email-current-password">
|
||||
{t("settings.emailChangePassword")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email-current-password"
|
||||
type="password"
|
||||
value={emailPassword}
|
||||
onChange={(e) => {
|
||||
setEmailPassword(e.target.value);
|
||||
clearFieldError("email");
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
maxLength={512}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
clearFieldError("email");
|
||||
}}
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
maxLength={320}
|
||||
autoComplete="email"
|
||||
enterKeyHint="next"
|
||||
aria-invalid={fieldErrors.email ? true : undefined}
|
||||
aria-describedby={fieldErrors.email ? "email-error" : undefined}
|
||||
/>
|
||||
<FieldError id="email-error" message={fieldErrors.email} />
|
||||
{emailChanged && !emailNeedsFactor && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Label htmlFor="email-current-password">
|
||||
{t("settings.emailChangePassword")}
|
||||
</Label>
|
||||
<Input
|
||||
id="email-current-password"
|
||||
type="password"
|
||||
value={emailPassword}
|
||||
onChange={(e) => {
|
||||
setEmailPassword(e.target.value);
|
||||
clearFieldError("email");
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
maxLength={512}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gender">{t("settings.gender")}</Label>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* A saved API key can be removed from every provider form that holds one.
|
||||
*
|
||||
* The forms showed "saved" beside the key field and nothing to take the key
|
||||
* back: an empty field on Save keeps the stored key by design, so a key once
|
||||
* entered stayed. The remove control renders on each of the four personal-key
|
||||
* forms exactly when that form's key is stored, and the body it sends clears
|
||||
* that one key and nothing beside it. The route's side (the column is cleared,
|
||||
* the other keys and the provider choice survive) is pinned against Postgres
|
||||
* in `tests/integration/ai-provider-key-removal.test.ts`.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
|
||||
|
||||
import { AnthropicProviderForm } from "../anthropic-provider-form";
|
||||
import { CompatProviderForm } from "../compat-provider-form";
|
||||
import { LocalProviderForm } from "../local-provider-form";
|
||||
import { OpenAIProviderForm } from "../openai-provider-form";
|
||||
import {
|
||||
removeSavedKeyBody,
|
||||
type SavedKeyField,
|
||||
} from "../remove-saved-key-button";
|
||||
import type { UserAIProvider } from "../shared";
|
||||
|
||||
function provider(overrides: Partial<UserAIProvider> = {}): UserAIProvider {
|
||||
return {
|
||||
provider: null,
|
||||
model: null,
|
||||
baseUrl: null,
|
||||
hasAnthropicKey: false,
|
||||
anthropicKeyPreview: null,
|
||||
hasLocalKey: false,
|
||||
hasOpenaiKey: false,
|
||||
openaiKeyPreview: null,
|
||||
compatBaseUrl: null,
|
||||
compatModel: null,
|
||||
hasCompatKey: false,
|
||||
responseTimeoutSeconds: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type Form = ComponentType<{ userProvider: UserAIProvider | null | undefined }>;
|
||||
|
||||
const FORMS: {
|
||||
name: string;
|
||||
Form: Form;
|
||||
field: SavedKeyField;
|
||||
saved: Partial<UserAIProvider>;
|
||||
}[] = [
|
||||
{
|
||||
name: "OpenAI",
|
||||
Form: OpenAIProviderForm,
|
||||
field: "openaiKey",
|
||||
saved: { hasOpenaiKey: true, openaiKeyPreview: "...abcd" },
|
||||
},
|
||||
{
|
||||
name: "Anthropic",
|
||||
Form: AnthropicProviderForm,
|
||||
field: "anthropicKey",
|
||||
saved: { hasAnthropicKey: true, anthropicKeyPreview: "...abcd" },
|
||||
},
|
||||
{
|
||||
name: "Local",
|
||||
Form: LocalProviderForm,
|
||||
field: "localKey",
|
||||
saved: { hasLocalKey: true },
|
||||
},
|
||||
{
|
||||
name: "OpenAI-compatible gateway",
|
||||
Form: CompatProviderForm,
|
||||
field: "compatKey",
|
||||
saved: { hasCompatKey: true },
|
||||
},
|
||||
];
|
||||
|
||||
function render(Form: Form, userProvider: UserAIProvider) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<Form userProvider={userProvider} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("remove a saved API key", () => {
|
||||
for (const { name, Form, field, saved } of FORMS) {
|
||||
describe(name, () => {
|
||||
const slot = `data-slot="settings-ai-remove-key-${field}"`;
|
||||
|
||||
it("offers the remove control while a key is stored", () => {
|
||||
const html = render(Form, provider(saved));
|
||||
expect(html).toContain(slot);
|
||||
expect(html).toContain("Remove API key");
|
||||
});
|
||||
|
||||
it("offers nothing to remove when no key is stored", () => {
|
||||
expect(render(Form, provider())).not.toContain(slot);
|
||||
});
|
||||
|
||||
it("sends a body that clears that key and nothing else", () => {
|
||||
const body = removeSavedKeyBody(field);
|
||||
expect(body).toEqual({ [field]: null });
|
||||
// The route reads the body through this schema; a field it did not
|
||||
// know would be dropped and the request answered "No valid fields".
|
||||
const parsed = aiProviderPatchSchema.safeParse(body);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data).toEqual({ [field]: null });
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -18,6 +18,8 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { RemoveSavedKeyButton } from "./remove-saved-key-button";
|
||||
|
||||
import {
|
||||
ANTHROPIC_MODEL_PRESETS,
|
||||
CUSTOM_MODEL_SENTINEL,
|
||||
@@ -169,6 +171,9 @@ export function AnthropicProviderForm({
|
||||
)}
|
||||
|
||||
<SettingsCardActions>
|
||||
{userProvider?.hasAnthropicKey && (
|
||||
<RemoveSavedKeyButton field="anthropicKey" />
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
|
||||
@@ -210,6 +210,7 @@ export function CodexProviderForm({
|
||||
) : isConnected ? (
|
||||
<ConfirmButton
|
||||
slot="settings-codex-disconnect"
|
||||
destructive
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11 shrink-0 sm:min-h-9"
|
||||
|
||||
@@ -20,6 +20,8 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { RemoveSavedKeyButton } from "./remove-saved-key-button";
|
||||
|
||||
import { ReasoningEffortField } from "./reasoning-effort-field";
|
||||
import {
|
||||
uiToLegacyProviderEnum,
|
||||
@@ -152,6 +154,9 @@ export function CompatProviderForm({
|
||||
)}
|
||||
|
||||
<SettingsCardActions>
|
||||
{userProvider?.hasCompatKey && (
|
||||
<RemoveSavedKeyButton field="compatKey" />
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
|
||||
@@ -18,6 +18,8 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { RemoveSavedKeyButton } from "./remove-saved-key-button";
|
||||
|
||||
import { ReasoningEffortField } from "./reasoning-effort-field";
|
||||
import {
|
||||
CUSTOM_MODEL_SENTINEL,
|
||||
@@ -188,6 +190,7 @@ export function LocalProviderForm({
|
||||
)}
|
||||
|
||||
<SettingsCardActions>
|
||||
{userProvider?.hasLocalKey && <RemoveSavedKeyButton field="localKey" />}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
|
||||
@@ -21,6 +21,8 @@ import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
import { RemoveSavedKeyButton } from "./remove-saved-key-button";
|
||||
|
||||
import {
|
||||
CUSTOM_MODEL_SENTINEL,
|
||||
OPENAI_MODEL_PRESETS,
|
||||
@@ -207,6 +209,9 @@ export function OpenAIProviderForm({
|
||||
)}
|
||||
|
||||
<SettingsCardActions>
|
||||
{userProvider?.hasOpenaiKey && (
|
||||
<RemoveSavedKeyButton field="openaiKey" />
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Removes the stored API key of one personal AI provider.
|
||||
*
|
||||
* Every provider form showed a saved key and offered no way to take it back:
|
||||
* saving with the key field empty keeps the stored key, by design, so a key
|
||||
* once entered stayed until the account was deleted. `PATCH
|
||||
* /api/user/ai-provider` has always cleared a key sent as `null`; this is the
|
||||
* control for it, shared by the four forms that hold a personal key so each
|
||||
* asks the same question and reports the same way.
|
||||
*/
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { apiPatch } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { aiInputDependentKeys, invalidateKeys } from "@/lib/query-keys";
|
||||
|
||||
/** The PATCH field that holds each provider's personal key. */
|
||||
export type SavedKeyField =
|
||||
"openaiKey" | "anthropicKey" | "localKey" | "compatKey";
|
||||
|
||||
/**
|
||||
* The body that clears exactly one key and touches nothing else: not the
|
||||
* provider choice, the model or the base URL, which stay as they were.
|
||||
*/
|
||||
export function removeSavedKeyBody(
|
||||
field: SavedKeyField,
|
||||
): Partial<Record<SavedKeyField, null>> {
|
||||
return { [field]: null };
|
||||
}
|
||||
|
||||
export function RemoveSavedKeyButton({ field }: { field: SavedKeyField }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPatch("/api/user/ai-provider", removeSavedKeyBody(field)),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(queryClient, aiInputDependentKeys);
|
||||
toastWrittenOutcome("success", t("settings.ai.removeKey.done"));
|
||||
},
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.ai.removeKey.failed"), { cause: failure }),
|
||||
});
|
||||
|
||||
return (
|
||||
<ConfirmButton
|
||||
slot={`settings-ai-remove-key-${field}`}
|
||||
destructive
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
label={t("settings.ai.removeKey.label")}
|
||||
title={t("settings.ai.removeKey.title")}
|
||||
body={t("settings.ai.removeKey.body")}
|
||||
confirmLabel={t("settings.ai.removeKey.confirm")}
|
||||
pending={remove.isPending}
|
||||
onConfirm={() => remove.mutate()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -619,7 +619,7 @@ function ApiTokensCard() {
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
{t("settings.tokenRevokeAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -698,7 +698,7 @@ function ApiTokensCard() {
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("settings.tokenRevoke")}
|
||||
{t("settings.tokenRevokeAction")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
@@ -718,7 +718,7 @@ function ApiTokensCard() {
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
{t("settings.tokenRevokeAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -228,6 +228,7 @@ function FactRow({
|
||||
accessible name and the confirmation carry the words. */}
|
||||
<ConfirmButton
|
||||
slot="settings-coach-memory-forget"
|
||||
destructive
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
|
||||
@@ -145,45 +145,51 @@ export function CoachNudgeCard({
|
||||
// v1.32.22 — echo the base token so a concurrent notification-prefs
|
||||
// write (the iOS client, another web card) 409s instead of reverting a
|
||||
// sibling category through the server-side deep-merge.
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
withBaseToken(
|
||||
{ coach: partial },
|
||||
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
||||
try {
|
||||
// A request that never reaches the server (offline, a dropped
|
||||
// connection) is a failed save like any other, not a stuck one: it
|
||||
// falls through to the error branch, and `finally` lowers the flag.
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
withBaseToken(
|
||||
{ coach: partial },
|
||||
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
if (res.ok) {
|
||||
if (toast) {
|
||||
setMsg(toast);
|
||||
setMsgType("success");
|
||||
if (res?.ok) {
|
||||
if (toast) {
|
||||
setMsg(toast);
|
||||
setMsgType("success");
|
||||
scheduleClear();
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setOptimistic({});
|
||||
} else if (res?.status === 409) {
|
||||
// The prefs advanced since they were loaded. Revert the optimistic knob
|
||||
// and refetch (the active query reseeds a fresh token), then nudge — the
|
||||
// change did not persist, so ask the user to re-apply it.
|
||||
setOptimistic({});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setMsg(t("common.conflictReloaded"));
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
} else {
|
||||
setOptimistic({});
|
||||
setMsg(t("notifications.coachNudge.saveError"));
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
}
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setOptimistic({});
|
||||
} else if (res.status === 409) {
|
||||
// The prefs advanced since they were loaded. Revert the optimistic knob
|
||||
// and refetch (the active query reseeds a fresh token), then nudge — the
|
||||
// change did not persist, so ask the user to re-apply it.
|
||||
setOptimistic({});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setMsg(t("common.conflictReloaded"));
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
} else {
|
||||
setOptimistic({});
|
||||
setMsg(t("notifications.coachNudge.saveError"));
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
|
||||
@@ -147,6 +147,7 @@ export function CoachRemindersSection({
|
||||
)}
|
||||
<ConfirmButton
|
||||
slot="coach-reminder-delete"
|
||||
destructive
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground min-h-9"
|
||||
|
||||
@@ -450,6 +450,7 @@ export function EnvironmentSection() {
|
||||
</span>
|
||||
<ConfirmButton
|
||||
slot="travel-remove"
|
||||
destructive
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Syringe } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import { apiFetchRaw, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
/**
|
||||
* v1.8.5 — Settings → global injection-site exclusion.
|
||||
@@ -56,7 +58,10 @@ export function InjectionSitesCard({
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.injectionSitePrefs(),
|
||||
});
|
||||
toastWrittenOutcome("success", t("settings.saved"));
|
||||
},
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.savingError"), { cause: failure }),
|
||||
});
|
||||
|
||||
function toggle(site: InjectionSiteKey, checked: boolean) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
|
||||
import { loadDocument } from "@/lib/navigation/load-document";
|
||||
@@ -311,7 +313,20 @@ export function OAuthIntegrationCardView({
|
||||
mutationFn: async () => {
|
||||
await apiPost(`/api/${provider}/disconnect`);
|
||||
},
|
||||
onSuccess: invalidateStatus,
|
||||
onSuccess: () => {
|
||||
invalidateStatus();
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
t("settings.integrationDisconnected", { provider: providerName }),
|
||||
);
|
||||
},
|
||||
// The confirmation dialog has closed by now; without this a refused
|
||||
// disconnect left the card connected and said nothing.
|
||||
onError: (failure) =>
|
||||
showErrorToast(
|
||||
t("settings.integrationDisconnectFailed", { provider: providerName }),
|
||||
{ cause: failure },
|
||||
),
|
||||
});
|
||||
|
||||
const resume = useMutation({
|
||||
|
||||
@@ -124,44 +124,52 @@ export function LowStockCard({
|
||||
// v1.32.22 — echo the base token so a concurrent notification-prefs
|
||||
// write (the iOS client, another web card) 409s instead of reverting a
|
||||
// sibling category through the server-side deep-merge.
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
withBaseToken(
|
||||
body,
|
||||
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
||||
let saved = false;
|
||||
try {
|
||||
// A request that never reaches the server (offline, a dropped
|
||||
// connection) is a failed save like any other, not a stuck one: it
|
||||
// falls through to the error branch, and `finally` lowers the flag.
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
withBaseToken(
|
||||
body,
|
||||
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
if (res.ok) {
|
||||
setMsg(successMsg);
|
||||
setMsgType("success");
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
saved = res?.ok === true;
|
||||
if (res?.ok) {
|
||||
setMsg(successMsg);
|
||||
setMsgType("success");
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
}),
|
||||
]);
|
||||
} else if (res?.status === 409) {
|
||||
// The prefs advanced since they were loaded. Refetch (the active query
|
||||
// reseeds a fresh token) and nudge — the caller reverts its optimistic
|
||||
// value, so the card snaps back to server truth for a re-apply.
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.settingsReminderThresholds(),
|
||||
}),
|
||||
]);
|
||||
} else if (res.status === 409) {
|
||||
// The prefs advanced since they were loaded. Refetch (the active query
|
||||
// reseeds a fresh token) and nudge — the caller reverts its optimistic
|
||||
// value, so the card snaps back to server truth for a re-apply.
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setMsg(t("common.conflictReloaded"));
|
||||
setMsgType("error");
|
||||
} else {
|
||||
setMsg(t("notifications.lowStock.saveError"));
|
||||
setMsgType("error");
|
||||
});
|
||||
setMsg(t("common.conflictReloaded"));
|
||||
setMsgType("error");
|
||||
} else {
|
||||
setMsg(t("notifications.lowStock.saveError"));
|
||||
setMsgType("error");
|
||||
}
|
||||
scheduleClear();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
scheduleClear();
|
||||
setSaving(false);
|
||||
return res.ok;
|
||||
return saved;
|
||||
}
|
||||
|
||||
async function persist(next: number | null, successMsg: string) {
|
||||
|
||||
@@ -516,7 +516,7 @@ function McpTokensCard() {
|
||||
className="min-h-11 w-full"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("settings.tokenRevoke")}
|
||||
{t("settings.tokenRevokeAction")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
@@ -536,7 +536,7 @@ function McpTokensCard() {
|
||||
variant="destructive"
|
||||
onClick={() => handleRevoke(tok.id)}
|
||||
>
|
||||
{t("settings.tokenRevoked")}
|
||||
{t("settings.tokenRevokeAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -99,31 +99,44 @@ export function MoodReminderCard({
|
||||
clearTimerRef.current = null;
|
||||
}
|
||||
|
||||
const res = await apiFetchRaw("/api/user/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ moodReminderEnabled: next }),
|
||||
});
|
||||
try {
|
||||
// A request that never reaches the server (offline, a dropped
|
||||
// connection) is a failed save like any other, not a stuck one: it
|
||||
// falls through to the error branch, and `finally` lowers the flag.
|
||||
const res = await apiFetchRaw("/api/user/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ moodReminderEnabled: next }),
|
||||
}).catch(() => null);
|
||||
|
||||
if (res.ok) {
|
||||
setMsg(
|
||||
next
|
||||
? t("notifications.moodReminder.enabledToast")
|
||||
: t("notifications.moodReminder.disabledToast"),
|
||||
);
|
||||
setMsgType("success");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userProfile(),
|
||||
});
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
} else {
|
||||
setOptimistic(null);
|
||||
setMsg(t("notifications.moodReminder.saveError"));
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
if (res?.ok) {
|
||||
setMsg(
|
||||
next
|
||||
? t("notifications.moodReminder.enabledToast")
|
||||
: t("notifications.moodReminder.disabledToast"),
|
||||
);
|
||||
setMsgType("success");
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.userProfile(),
|
||||
}),
|
||||
// The /notifications matrix shows the reminder's channel switches
|
||||
// from the server's `eventDefaults`, which follow this card.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.notificationsPreferences(),
|
||||
}),
|
||||
]);
|
||||
setOptimistic(null);
|
||||
scheduleClear();
|
||||
} else {
|
||||
setOptimistic(null);
|
||||
setMsg(t("notifications.moodReminder.saveError"));
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
async function handleHourChange(next: number) {
|
||||
@@ -139,43 +152,49 @@ export function MoodReminderCard({
|
||||
// v1.32.22 — echo the base token so a concurrent notification-prefs
|
||||
// write (the iOS client's `clientManaged` flag, another web card) 409s
|
||||
// instead of silently reverting a sibling category through the deep-merge.
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
withBaseToken(
|
||||
{ mood: { reminderHour: next } },
|
||||
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
||||
try {
|
||||
// A request that never reaches the server (offline, a dropped
|
||||
// connection) is a failed save like any other, not a stuck one: it
|
||||
// falls through to the error branch, and `finally` lowers the flag.
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
withBaseToken(
|
||||
{ mood: { reminderHour: next } },
|
||||
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
if (res.ok) {
|
||||
setMsg(t("notifications.moodReminder.hourSavedToast"));
|
||||
setMsgType("success");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setOptimisticHour(null);
|
||||
scheduleClear();
|
||||
} else if (res.status === 409) {
|
||||
// The prefs advanced since they were loaded. Revert the optimistic hour
|
||||
// and refetch (the active query reseeds a fresh token), then nudge — the
|
||||
// change did not persist, so ask the user to re-apply it.
|
||||
setOptimisticHour(null);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setMsg(t("common.conflictReloaded"));
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
} else {
|
||||
setOptimisticHour(null);
|
||||
setMsg(t("notifications.moodReminder.saveError"));
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
if (res?.ok) {
|
||||
setMsg(t("notifications.moodReminder.hourSavedToast"));
|
||||
setMsgType("success");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setOptimisticHour(null);
|
||||
scheduleClear();
|
||||
} else if (res?.status === 409) {
|
||||
// The prefs advanced since they were loaded. Revert the optimistic hour
|
||||
// and refetch (the active query reseeds a fresh token), then nudge — the
|
||||
// change did not persist, so ask the user to re-apply it.
|
||||
setOptimisticHour(null);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setMsg(t("common.conflictReloaded"));
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
} else {
|
||||
setOptimisticHour(null);
|
||||
setMsg(t("notifications.moodReminder.saveError"));
|
||||
setMsgType("error");
|
||||
scheduleClear();
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -69,37 +69,43 @@ export function NotificationContentCard({
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
withBaseToken(
|
||||
{ content: { detail: next ? "detailed" : "generic" } },
|
||||
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
||||
try {
|
||||
// A request that never reaches the server (offline, a dropped
|
||||
// connection) is a failed save like any other, not a stuck one: it
|
||||
// falls through to the error branch, and `finally` lowers the flag.
|
||||
const res = await apiFetchRaw("/api/auth/me/notification-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(
|
||||
withBaseToken(
|
||||
{ content: { detail: next ? "detailed" : "generic" } },
|
||||
readUpdatedAtToken(queryClient, queryKeys.authNotificationPrefs()),
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
}).catch(() => null);
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setOptimistic(null);
|
||||
if (res.ok) {
|
||||
setMsg(
|
||||
next
|
||||
? t("notifications.content.detailedToast")
|
||||
: t("notifications.content.genericToast"),
|
||||
);
|
||||
setMsgType("success");
|
||||
} else if (res.status === 409) {
|
||||
setMsg(t("common.conflictReloaded"));
|
||||
setMsgType("error");
|
||||
} else {
|
||||
setMsg(t("notifications.content.saveError"));
|
||||
setMsgType("error");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
});
|
||||
setOptimistic(null);
|
||||
if (res?.ok) {
|
||||
setMsg(
|
||||
next
|
||||
? t("notifications.content.detailedToast")
|
||||
: t("notifications.content.genericToast"),
|
||||
);
|
||||
setMsgType("success");
|
||||
} else if (res?.status === 409) {
|
||||
setMsg(t("common.conflictReloaded"));
|
||||
setMsgType("error");
|
||||
} else {
|
||||
setMsg(t("notifications.content.saveError"));
|
||||
setMsgType("error");
|
||||
}
|
||||
scheduleClear();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
scheduleClear();
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
@@ -142,6 +143,7 @@ export function PasskeyListSection({
|
||||
setReauthSecret("");
|
||||
setReauthMethod("password");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
toastWrittenOutcome("success", t("settings.passkeyAddedToast"));
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -178,6 +180,7 @@ export function PasskeyListSection({
|
||||
onSuccess: () => {
|
||||
setMsg(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
toastWrittenOutcome("success", t("settings.passkeyRemovedToast"));
|
||||
},
|
||||
// A refusal the dialog could not take up keeps its own sentence. The
|
||||
// server's prose says "second-factor", which is wrong for the passkey-only
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
@@ -133,6 +134,7 @@ export function SecurityKeysCard({
|
||||
setReauthOpen(false);
|
||||
setReauthError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
toastWrittenOutcome("success", t("settings.security.keys.addedToast"));
|
||||
},
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err) && nothingToReprove(err)) {
|
||||
@@ -192,6 +194,7 @@ export function SecurityKeysCard({
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
toastWrittenOutcome("success", t("settings.security.keys.removedToast"));
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
|
||||
@@ -16,7 +16,6 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
@@ -24,7 +23,12 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
|
||||
import { useRevokeGrant } from "@/lib/queries/use-account-grants";
|
||||
import {
|
||||
useAccountGrants,
|
||||
useRevokeGrant,
|
||||
} from "@/lib/queries/use-account-grants";
|
||||
import { revokeBody } from "@/components/settings/access/grants-given-card";
|
||||
import { accountLabel } from "@/lib/sharing/account-access-view";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** An accepted grant sign-out-everywhere left standing (`grantsKept`). */
|
||||
@@ -75,6 +79,10 @@ export function SecuritySessionsCard({
|
||||
// a one-click end instead. Held only until the card unmounts.
|
||||
const [keptGrants, setKeptGrants] = useState<KeptGrant[]>([]);
|
||||
const revokeGrant = useRevokeGrant();
|
||||
// Ending one of them is the same act as on the access card, so it asks the
|
||||
// same question with the same disclosure — and that names the audit window,
|
||||
// which rides the grant list. Read only once there is a row to end.
|
||||
const { data: grantList } = useAccountGrants(keptGrants.length > 0);
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.sessions(),
|
||||
@@ -216,6 +224,7 @@ export function SecuritySessionsCard({
|
||||
{!s.isCurrent && (
|
||||
<ConfirmButton
|
||||
slot="revoke-session"
|
||||
destructive
|
||||
size="sm"
|
||||
className="min-h-9 shrink-0"
|
||||
label={t("settings.security.revokeSession")}
|
||||
@@ -237,6 +246,7 @@ export function SecuritySessionsCard({
|
||||
<SettingsCardActions>
|
||||
<ConfirmButton
|
||||
slot="sign-out-everywhere"
|
||||
destructive
|
||||
className="min-h-11 sm:min-h-9"
|
||||
label={t("settings.security.signOutEverywhere")}
|
||||
title={t("settings.security.signOutEverywhereConfirmTitle")}
|
||||
@@ -293,32 +303,44 @@ export function SecuritySessionsCard({
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">
|
||||
{grant.account.displayName ?? grant.account.username}
|
||||
{accountLabel(grant.account)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(ACCESS_LABEL_KEY[grant.access])}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
disabled={revokeGrant.isPending}
|
||||
onClick={() =>
|
||||
revokeGrant.mutate(grant.id, {
|
||||
onSuccess: () =>
|
||||
setKeptGrants((current) =>
|
||||
current.filter((g) => g.id !== grant.id),
|
||||
),
|
||||
onError: () =>
|
||||
setStatus(
|
||||
t("recordSharing.actionError.revokeFailed"),
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("recordSharing.given.revoke")}
|
||||
</Button>
|
||||
{grantList ? (
|
||||
<ConfirmButton
|
||||
slot="kept-grant-revoke"
|
||||
destructive
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
label={t("recordSharing.given.revoke")}
|
||||
title={t("recordSharing.given.revokeTitle", {
|
||||
name: accountLabel(grant.account),
|
||||
})}
|
||||
body={revokeBody({
|
||||
t,
|
||||
access: grant.access,
|
||||
name: accountLabel(grant.account),
|
||||
retentionDays: grantList.retentionDays,
|
||||
})}
|
||||
confirmLabel={t("recordSharing.given.revokeConfirm")}
|
||||
pending={revokeGrant.isPending}
|
||||
onConfirm={() =>
|
||||
revokeGrant.mutate(grant.id, {
|
||||
onSuccess: () =>
|
||||
setKeptGrants((current) =>
|
||||
current.filter((g) => g.id !== grant.id),
|
||||
),
|
||||
onError: () =>
|
||||
setStatus(
|
||||
t("recordSharing.actionError.revokeFailed"),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { FileText, KeyRound, Link2, List, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -53,6 +54,7 @@ import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
export function SharingSection() {
|
||||
// v1.18.7 — the visible heading + subtitle come from the shared settings
|
||||
@@ -127,7 +129,12 @@ function ShareLinksCard() {
|
||||
mutationFn: (id: string) => apiDelete(`/api/share-links/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
|
||||
toastWrittenOutcome("success", t("settings.sharing.revokedToast"));
|
||||
},
|
||||
// A refused revoke leaves the link working; that has to be said, since
|
||||
// the dialog that asked has already closed.
|
||||
onError: (failure) =>
|
||||
showErrorToast(t("settings.sharing.revokeFailed"), { cause: failure }),
|
||||
});
|
||||
|
||||
const activeLinks = useMemo(
|
||||
|
||||
@@ -116,23 +116,30 @@ export function TelegramCard({
|
||||
if (botToken.trim()) body.botToken = botToken.trim();
|
||||
if (chatId !== (settings?.chatId ?? "")) body.chatId = chatId;
|
||||
|
||||
const res = await apiFetchRaw("/api/settings/telegram", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
try {
|
||||
// A request that never reaches the server (offline, a dropped
|
||||
// connection) is a failed save like any other, not a stuck one.
|
||||
const res = await apiFetchRaw("/api/settings/telegram", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => null);
|
||||
|
||||
if (res.ok) {
|
||||
setMsg(t("settings.saved"));
|
||||
setMsgType("success");
|
||||
setBotToken("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.telegram() });
|
||||
} else {
|
||||
const json = await res.json();
|
||||
setMsg(json.error || t("settings.savingError"));
|
||||
setMsgType("error");
|
||||
if (res?.ok) {
|
||||
setMsg(t("settings.saved"));
|
||||
setMsgType("success");
|
||||
setBotToken("");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.telegram() });
|
||||
} else {
|
||||
const json = (await res?.json().catch(() => null)) as {
|
||||
error?: string;
|
||||
} | null;
|
||||
setMsg(json?.error || t("settings.savingError"));
|
||||
setMsgType("error");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -137,6 +137,7 @@ export function TrustedDevicesCard({
|
||||
</div>
|
||||
<ConfirmButton
|
||||
slot="revoke-trusted-device"
|
||||
destructive
|
||||
size="sm"
|
||||
className="min-h-9 shrink-0"
|
||||
label={t("settings.security.trustedDevices.revoke")}
|
||||
@@ -157,6 +158,7 @@ export function TrustedDevicesCard({
|
||||
<SettingsCardActions>
|
||||
<ConfirmButton
|
||||
slot="revoke-all-trusted-devices"
|
||||
destructive
|
||||
className="min-h-11 sm:min-h-9"
|
||||
label={t("settings.security.trustedDevices.revokeAll")}
|
||||
title={t(
|
||||
|
||||
@@ -285,6 +285,7 @@ export function LifeEventForm({
|
||||
className="mr-auto min-h-11 sm:min-h-9"
|
||||
icon={<Trash2 className="size-4" aria-hidden="true" />}
|
||||
slot="life-event-delete"
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
{onCancel && (
|
||||
|
||||
@@ -160,6 +160,59 @@ describe("ConfirmBody — the action is the only path to the callback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the destructive style reaches the action from both shapes", () => {
|
||||
// `ConfirmButton` accepted `destructive` through its props type and dropped
|
||||
// it on the way to the dialog, so every delete confirmation built on it
|
||||
// offered a neutral primary where `ConfirmDialog` offered a red one.
|
||||
function actionVariant(element: ReactElement): unknown {
|
||||
const body = walk(element).find((el) => nameOf(el) === "ConfirmBody")!;
|
||||
const rendered = walk(
|
||||
(body.type as (p: unknown) => ReactElement)(body.props),
|
||||
);
|
||||
const action = rendered.find((el) => nameOf(el) === "AlertDialogAction")!;
|
||||
return (action.props as Record<string, unknown>).variant;
|
||||
}
|
||||
|
||||
it("ConfirmButton forwards destructive to the dialog's action", () => {
|
||||
expect(
|
||||
actionVariant(
|
||||
ConfirmButton({
|
||||
label: "Delete",
|
||||
onConfirm: vi.fn(),
|
||||
destructive: true,
|
||||
...COPY,
|
||||
}) as ReactElement,
|
||||
),
|
||||
).toBe("destructive");
|
||||
});
|
||||
|
||||
it("ConfirmButton stays neutral when the act does not destroy", () => {
|
||||
expect(
|
||||
actionVariant(
|
||||
ConfirmButton({
|
||||
label: "Reset",
|
||||
onConfirm: vi.fn(),
|
||||
...COPY,
|
||||
}) as ReactElement,
|
||||
),
|
||||
).toBe("default");
|
||||
});
|
||||
|
||||
it("ConfirmDialog forwards it the same way", () => {
|
||||
expect(
|
||||
actionVariant(
|
||||
ConfirmDialog({
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
destructive: true,
|
||||
...COPY,
|
||||
}) as ReactElement,
|
||||
),
|
||||
).toBe("destructive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ConfirmDialog> — the trigger-less half", () => {
|
||||
const onConfirm = vi.fn();
|
||||
const tree = walk(
|
||||
|
||||
@@ -114,6 +114,7 @@ export function ConfirmButton({
|
||||
ariaLabel,
|
||||
slot,
|
||||
extra,
|
||||
destructive = false,
|
||||
}: ConfirmCopy & {
|
||||
/** The control's own label, before the dialog opens. */
|
||||
label: string;
|
||||
@@ -156,6 +157,7 @@ export function ConfirmButton({
|
||||
pending={pending}
|
||||
slot={slot}
|
||||
extra={extra}
|
||||
destructive={destructive}
|
||||
/>
|
||||
</AlertDialog>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user