mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge branch 'fix/v1393-review-security' into release/v1.39.3
This commit is contained in:
commit
3cbfe7f7f9
76 files changed
+2751
-409
No files matched your search
@@ -70,15 +70,13 @@ describe("grant invitation lapse date", () => {
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const onError = source.slice(
|
||||
source.indexOf("onError: (err) => setError"),
|
||||
source.indexOf(
|
||||
" );\n };",
|
||||
source.indexOf("onError: (err) => setError"),
|
||||
),
|
||||
);
|
||||
// The failure arm of the submit: everything from its `catch` to the end
|
||||
// of the handler.
|
||||
const start = source.indexOf("} catch (err) {");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const onError = source.slice(start, source.indexOf("\n };", start));
|
||||
|
||||
expect(onError).toContain("setError(t(inviteErrorKey(err)))");
|
||||
expect(onError).toContain("t(inviteErrorKey(err))");
|
||||
expect(onError).not.toContain("setIdentifier");
|
||||
expect(onError).not.toContain("setExpiresOn");
|
||||
expect(source).toContain('role="alert"');
|
||||
|
||||
@@ -18,6 +18,10 @@ import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInviteGrant } from "@/lib/queries/use-account-grants";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
|
||||
import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format";
|
||||
import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
@@ -79,6 +83,7 @@ import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
export function GrantInviteCard() {
|
||||
const { t } = useTranslations();
|
||||
const invite = useInviteGrant();
|
||||
const recentProof = useRecentProof();
|
||||
const ownerTimezone = useInvitationOwnerTimezone();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [expiresOn, setExpiresOn] = useState("");
|
||||
@@ -118,31 +123,40 @@ export function GrantInviteCard() {
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const value = identifier.trim();
|
||||
if (value.length === 0 || choice.blocked) return;
|
||||
setError(null);
|
||||
setInvited(null);
|
||||
invite.mutate(
|
||||
{
|
||||
identifier: value,
|
||||
access,
|
||||
scope: choice.scope,
|
||||
expiresAt: endOfDayIso(expiresOn, ownerTimezone),
|
||||
},
|
||||
{
|
||||
onSuccess: (grant) => {
|
||||
setIdentifier("");
|
||||
setExpiresOn("");
|
||||
setAccess("READ");
|
||||
setNarrowed(null);
|
||||
setSections([]);
|
||||
setInvited(grant.account.username);
|
||||
},
|
||||
onError: (err) => setError(t(inviteErrorKey(err))),
|
||||
},
|
||||
);
|
||||
// Offering access hands somebody a standing way into the record, so the
|
||||
// server asks for a fresh proof unless the session signed in or re-proved
|
||||
// within five minutes; the dialog collects it and the invitation is sent
|
||||
// again.
|
||||
try {
|
||||
const grant = await recentProof.run(() =>
|
||||
invite.mutateAsync({
|
||||
identifier: value,
|
||||
access,
|
||||
scope: choice.scope,
|
||||
expiresAt: endOfDayIso(expiresOn, ownerTimezone),
|
||||
}),
|
||||
);
|
||||
setIdentifier("");
|
||||
setExpiresOn("");
|
||||
setAccess("READ");
|
||||
setNarrowed(null);
|
||||
setSections([]);
|
||||
setInvited(grant.account.username);
|
||||
} catch (err) {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === "auth.reproof.sign_in_again"
|
||||
? err.message
|
||||
: t(inviteErrorKey(err)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -159,7 +173,7 @@ export function GrantInviteCard() {
|
||||
"recordSharing.invite.needsAccount",
|
||||
)}`}
|
||||
/>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<form onSubmit={(event) => void submit(event)} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="grant-invite-identifier">
|
||||
{t("recordSharing.invite.identifierLabel")}
|
||||
@@ -373,6 +387,7 @@ export function GrantInviteCard() {
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
{recentProof.dialog}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,11 @@ import { DateFormatSelect } from "@/components/settings/date-format-select";
|
||||
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
|
||||
import { GlucoseUnitSelect } from "@/components/settings/glucose-unit-select";
|
||||
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import {
|
||||
describeRejectedProfileField,
|
||||
describeRejectedProfileFields,
|
||||
@@ -75,6 +79,12 @@ export function AccountSection() {
|
||||
// in within the last few minutes; the server decides and names the field if
|
||||
// it wanted one. Never stored beyond this form.
|
||||
const [emailPassword, setEmailPassword] = useState("");
|
||||
// On an account with a second factor the password does not move the
|
||||
// address: the server asks for the factor instead (`second_factor_required`)
|
||||
// and the save runs once more after the re-proof dialog. Once that is
|
||||
// known the password field is not offered, since it could only be refused.
|
||||
const [emailNeedsFactor, setEmailNeedsFactor] = useState(false);
|
||||
const recentProof = useRecentProof();
|
||||
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
const [gender, setGender] = useState<string>("");
|
||||
@@ -193,8 +203,46 @@ export function AccountSection() {
|
||||
email.trim() !== "" &&
|
||||
email.trim().toLowerCase() !== profileSeed.email.trim().toLowerCase();
|
||||
|
||||
async function handleSaveProfile(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
/**
|
||||
* Did the server keep the new address back until a second factor is
|
||||
* proved? Either as the one rejected field of a partial save, or as the
|
||||
* whole answer when the address was all that changed.
|
||||
*/
|
||||
function needsSecondFactor(
|
||||
rejected: RejectedProfileField[] | undefined,
|
||||
errorCode: string | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
errorCode === "profile.update.emailSecondFactorRequired" ||
|
||||
(rejected ?? []).some(
|
||||
(r) => r.path === "email" && r.code === "second_factor_required",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for the second factor (or a passkey) through the shared re-proof
|
||||
* dialog, then save once more. Returns false when the person closed the
|
||||
* dialog or could not confirm, leaving the message to show.
|
||||
*/
|
||||
async function proveFactorThenSave(): Promise<boolean> {
|
||||
setEmailNeedsFactor(true);
|
||||
try {
|
||||
await recentProof.run(() => apiGet("/api/auth/reproof"));
|
||||
} catch (err) {
|
||||
const message = recentProofErrorMessage(err, "");
|
||||
if (message) {
|
||||
setSaveMsg({ text: message });
|
||||
setSaveMsgType("error");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
await handleSaveProfile(undefined, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleSaveProfile(e?: React.FormEvent, afterFactor = false) {
|
||||
e?.preventDefault();
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
setSaveMsgType(null);
|
||||
@@ -255,6 +303,10 @@ export function AccountSection() {
|
||||
} | null;
|
||||
const rejected = profileJson?.data?.rejectedFields;
|
||||
|
||||
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
|
||||
@@ -304,6 +356,9 @@ export function AccountSection() {
|
||||
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) {
|
||||
@@ -350,7 +405,10 @@ export function AccountSection() {
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader icon={User} title={t("settings.profile")} />
|
||||
<form onSubmit={handleSaveProfile} className="space-y-4">
|
||||
<form
|
||||
onSubmit={(event) => void handleSaveProfile(event)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">{t("settings.username")}</Label>
|
||||
@@ -386,7 +444,7 @@ export function AccountSection() {
|
||||
aria-describedby={fieldErrors.email ? "email-error" : undefined}
|
||||
/>
|
||||
<FieldError id="email-error" message={fieldErrors.email} />
|
||||
{emailChanged && (
|
||||
{emailChanged && !emailNeedsFactor && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<Label htmlFor="email-current-password">
|
||||
{t("settings.emailChangePassword")}
|
||||
@@ -636,6 +694,7 @@ export function AccountSection() {
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
{recentProof.dialog}
|
||||
</SettingsCard>
|
||||
|
||||
{/* v1.18.0 (S5) — injection-site exclusions moved to the dedicated
|
||||
|
||||
@@ -48,6 +48,11 @@ import { ReportVisitOffer } from "./report-visit-offer";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
throwIfReproofRequired,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -67,6 +72,9 @@ type ExportFormat = "pdf" | "fhir" | "package";
|
||||
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
|
||||
const PRESET_RANGES = [30, 90, 180, 365] as const;
|
||||
|
||||
/** A refusal already worded for the panel; shown as it is. */
|
||||
class ReportRequestError extends Error {}
|
||||
|
||||
export function HealthRecordExportPanel() {
|
||||
const { t, locale } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
@@ -119,18 +127,40 @@ export function HealthRecordExportPanel() {
|
||||
onSelect: (index) => setFormat(EXPORT_FORMATS[index]!),
|
||||
});
|
||||
|
||||
// The report is built from the whole record, so it asks for a fresh proof
|
||||
// unless the session signed in or re-proved within five minutes.
|
||||
const recentProof = useRecentProof();
|
||||
|
||||
async function handleGenerate() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const range =
|
||||
customRange && startDate && endDate
|
||||
? {
|
||||
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
|
||||
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
|
||||
}
|
||||
: { days };
|
||||
const res = await apiFetchRaw("/api/export/health-record", {
|
||||
await recentProof.run(generateOnce);
|
||||
} catch (err) {
|
||||
if (err instanceof ReportRequestError) {
|
||||
setError(err.message);
|
||||
return;
|
||||
}
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
if (message) setError(message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateOnce() {
|
||||
const range =
|
||||
customRange && startDate && endDate
|
||||
? {
|
||||
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
|
||||
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
|
||||
}
|
||||
: { days };
|
||||
const res = await throwIfReproofRequired(
|
||||
await apiFetchRaw("/api/export/health-record", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
@@ -144,38 +174,33 @@ export function HealthRecordExportPanel() {
|
||||
includeCharts,
|
||||
selection: { v: 2, leaves: orderLeaves(selected) },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
res.status === 429
|
||||
? t("settings.healthRecord.errorRateLimit")
|
||||
: res.status === 403
|
||||
? t("settings.healthRecord.errorModuleDisabled")
|
||||
: t("settings.healthRecord.errorGeneric"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `healthlog-health-record-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
// The route just persisted the practice name and the selection, so the
|
||||
// cached `/me` payload is stale. Refresh it, or a remount would seed from
|
||||
// the previous values.
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}),
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new ReportRequestError(
|
||||
res.status === 429
|
||||
? t("settings.healthRecord.errorRateLimit")
|
||||
: res.status === 403
|
||||
? t("settings.healthRecord.errorModuleDisabled")
|
||||
: t("settings.healthRecord.errorGeneric"),
|
||||
);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `healthlog-health-record-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
// The route just persisted the practice name and the selection, so the
|
||||
// cached `/me` payload is stale. Refresh it, or a remount would seed from
|
||||
// the previous values.
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -397,6 +422,7 @@ export function HealthRecordExportPanel() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{recentProof.dialog}
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,28 @@ export function isReproofRequired(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
const REAUTH_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
];
|
||||
|
||||
/**
|
||||
* The proofs the refusal says this account can give, or null when it names
|
||||
* none. On an account with a second factor the server leaves the password out,
|
||||
* so the dialog does not offer a choice that can only be refused.
|
||||
*/
|
||||
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
|
||||
if (!(err instanceof ApiError)) return null;
|
||||
const raw = err.meta?.methods;
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const offered = raw.filter((m): m is ReauthMethod =>
|
||||
REAUTH_METHODS.includes(m as ReauthMethod),
|
||||
);
|
||||
return offered.length > 0 ? offered : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a fresh proof of a credential the account already holds, before a
|
||||
* new factor is added. A passkey or security-key assertion begins at the
|
||||
|
||||
@@ -36,6 +36,7 @@ import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
offeredReauthMethods,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
@@ -98,8 +99,14 @@ export function SecurityKeysCard({
|
||||
// options call is retried with it from the dialog.
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string | null>(null);
|
||||
const reauthMethods: ReauthMethod[] = [
|
||||
"password",
|
||||
// An account with a second factor confirms with that factor or a passkey;
|
||||
// the server refuses a password there and names what it will take.
|
||||
const hasSecondFactor = totpEnabled || keys.length > 0;
|
||||
const [offeredMethods, setOfferedMethods] = useState<ReauthMethod[] | null>(
|
||||
null,
|
||||
);
|
||||
const reauthMethods: ReauthMethod[] = offeredMethods ?? [
|
||||
...(hasSecondFactor ? [] : (["password"] as const)),
|
||||
...(totpEnabled ? (["totp"] as const) : []),
|
||||
"passkey",
|
||||
...(keys.length > 0 ? (["webauthn"] as const) : []),
|
||||
@@ -126,6 +133,7 @@ export function SecurityKeysCard({
|
||||
},
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err)) {
|
||||
setOfferedMethods(offeredReauthMethods(err));
|
||||
setError(null);
|
||||
setReauthError(null);
|
||||
setReauthOpen(true);
|
||||
|
||||
@@ -36,7 +36,9 @@ import { RecoveryCodesPanel } from "./recovery-codes-panel";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
offeredReauthMethods,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
|
||||
interface SetupData {
|
||||
@@ -128,6 +130,11 @@ export function TotpCard({
|
||||
// call is retried with it from the dialog.
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthError, setReauthError] = useState<string | null>(null);
|
||||
const [reauthMethods, setReauthMethods] = useState<ReauthMethod[]>([
|
||||
"password",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
]);
|
||||
|
||||
const beginSetup = useMutation({
|
||||
mutationFn: async (proof?: ExistingFactorProof) => {
|
||||
@@ -148,6 +155,8 @@ export function TotpCard({
|
||||
},
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err)) {
|
||||
const offered = offeredReauthMethods(err);
|
||||
if (offered) setReauthMethods(offered);
|
||||
setError(null);
|
||||
setReauthError(null);
|
||||
setReauthOpen(true);
|
||||
@@ -278,7 +287,7 @@ export function TotpCard({
|
||||
setReauthOpen(open);
|
||||
if (!open) setReauthError(null);
|
||||
}}
|
||||
methods={["password", "passkey", "webauthn"]}
|
||||
methods={reauthMethods}
|
||||
pending={beginSetup.isPending}
|
||||
error={reauthError}
|
||||
onProof={(proof) => beginSetup.mutate(proof)}
|
||||
|
||||
@@ -16,6 +16,7 @@ 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";
|
||||
@@ -23,8 +24,22 @@ 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 { cn } from "@/lib/utils";
|
||||
|
||||
/** An accepted grant sign-out-everywhere left standing (`grantsKept`). */
|
||||
interface KeptGrant {
|
||||
id: string;
|
||||
account: { id: string; username: string; displayName: string | null };
|
||||
access: "READ" | "WRITE" | "MANAGE";
|
||||
}
|
||||
|
||||
const ACCESS_LABEL_KEY: Record<KeptGrant["access"], string> = {
|
||||
READ: "recordSharing.invite.accessReadLabel",
|
||||
WRITE: "recordSharing.invite.accessWriteLabel",
|
||||
MANAGE: "recordSharing.invite.accessManageLabel",
|
||||
};
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
device: string;
|
||||
@@ -54,6 +69,12 @@ export function SecuritySessionsCard({
|
||||
// state; nothing is persisted across reloads.
|
||||
const [open, setOpen] = useState(false);
|
||||
const regionId = useId();
|
||||
// The people who can still read this record after "sign out everywhere".
|
||||
// Their access is not ended by it (a carer who still needs the record would
|
||||
// be cut off by a button labelled "sign out"), so they are shown here with
|
||||
// a one-click end instead. Held only until the card unmounts.
|
||||
const [keptGrants, setKeptGrants] = useState<KeptGrant[]>([]);
|
||||
const revokeGrant = useRevokeGrant();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.sessions(),
|
||||
@@ -75,17 +96,32 @@ export function SecuritySessionsCard({
|
||||
|
||||
const revokeOthers = useMutation({
|
||||
mutationFn: () =>
|
||||
apiDelete<{ sessionsRevoked: number }>(
|
||||
apiDelete<{
|
||||
sessionsRevoked: number;
|
||||
pendingInvitesRevoked?: number;
|
||||
grantsKept?: KeptGrant[];
|
||||
}>(
|
||||
endShareLinks
|
||||
? "/api/auth/me/sessions"
|
||||
: "/api/auth/me/sessions?keepShareLinks=1",
|
||||
),
|
||||
onSuccess: (res) => {
|
||||
const invites = res?.pendingInvitesRevoked ?? 0;
|
||||
setStatus(
|
||||
t("settings.security.signOutEverywhereDone", {
|
||||
count: res?.sessionsRevoked ?? 0,
|
||||
}),
|
||||
[
|
||||
t("settings.security.signOutEverywhereDone", {
|
||||
count: res?.sessionsRevoked ?? 0,
|
||||
}),
|
||||
invites > 0
|
||||
? t("settings.security.signOutEverywhereInvitesDone", {
|
||||
count: invites,
|
||||
})
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
setKeptGrants(res?.grantsKept ?? []);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.sessions() });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.securityActivity(),
|
||||
@@ -206,17 +242,24 @@ export function SecuritySessionsCard({
|
||||
onConfirm={() => revokeOthers.mutate()}
|
||||
pending={revokeOthers.isPending}
|
||||
extra={
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
data-testid="sign-out-everywhere-share-links"
|
||||
checked={endShareLinks}
|
||||
onCheckedChange={(next) => setEndShareLinks(next === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{t("settings.security.signOutEverywhereShareLinks")}
|
||||
</span>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
data-testid="sign-out-everywhere-share-links"
|
||||
checked={endShareLinks}
|
||||
onCheckedChange={(next) =>
|
||||
setEndShareLinks(next === true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{t("settings.security.signOutEverywhereShareLinks")}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-sm">
|
||||
{t("settings.security.signOutEverywhereInvites")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</SettingsCardActions>
|
||||
@@ -227,6 +270,57 @@ export function SecuritySessionsCard({
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{keptGrants.length > 0 && (
|
||||
<div
|
||||
data-slot="sign-out-everywhere-grants-kept"
|
||||
className="space-y-2 border-t pt-4"
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.security.grantsKeptTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.security.grantsKeptBody")}
|
||||
</p>
|
||||
<ul className="divide-y">
|
||||
{keptGrants.map((grant) => (
|
||||
<li
|
||||
key={grant.id}
|
||||
className="flex items-center justify-between gap-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">
|
||||
{grant.account.displayName ?? grant.account.username}
|
||||
</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>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user