mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
An account created through OIDC holds no password, passkey or second factor, so the recent-proof gate in front of token minting, MCP connections, exports and the admin reset had nothing to ask it for and the web could only say "sign in again" (#1296). GET /api/auth/oidc/reproof sends the browser back to the configured provider with prompt=login and max_age=0, its own state, nonce and PKCE verifier, and the signed-in account and session sealed into the state cookie. The shared callback branches on that intent: it refuses unless the browser still carries the bound session (checked before the code is redeemed), the ID token names the account's linked issuer and subject, and auth_time lies inside the gate's five-minute window. It then stamps Session.reproofAt exactly as a password re-proof does, creates no session, and returns to the page it left with ?reproof=done|failed. recentProofMethods offers "oidc" only to an account without a second factor whose identity is linked while a provider is configured, and the fresh-factor gate never lists it. GET /api/auth/me/mfa publishes the list as proofMethods, so the passkey card offers what the account can actually give, and register-options takes { method: "oidc" } on a fresh provider re-proof, so such an account can add a passkey as a second way to confirm. Both re-proof dialogs offer the provider and a toast reports the outcome on return. The callback's missing-state branch and the passkey card's enrollment flow move into their own functions to keep both handlers under the size ratchet.
101 lines
3.5 KiB
TypeScript
101 lines
3.5 KiB
TypeScript
"use client";
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { apiGet } from "@/lib/api/api-fetch";
|
|
import { PasswordCard } from "@/components/settings/password-card";
|
|
import { SecurityActivityCard } from "@/components/settings/security-activity-card";
|
|
import { SecuritySessionsCard } from "@/components/settings/security-sessions-card";
|
|
import { TrustedDevicesCard } from "@/components/settings/trusted-devices-card";
|
|
import { TotpCard } from "./totp-card";
|
|
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
|
|
import { PasskeyListSection } from "./passkey-list-section";
|
|
import { PasskeyUpgradeNudge } from "./passkey-upgrade-nudge";
|
|
|
|
interface MfaStatus {
|
|
totp: { enabled: boolean };
|
|
recoveryCodesRemaining: number;
|
|
webauthn: WebauthnKeyInfo[];
|
|
passkeyNudgeDismissed: boolean;
|
|
proofMethods: string[];
|
|
}
|
|
|
|
interface PasskeyInfo {
|
|
id: string;
|
|
}
|
|
|
|
export function SecuritySection() {
|
|
const { isAuthenticated } = useAuth();
|
|
|
|
const { data: status, isLoading } = useQuery({
|
|
queryKey: queryKeys.mfaStatus(),
|
|
queryFn: async () => apiGet<MfaStatus>("/api/auth/me/mfa"),
|
|
enabled: isAuthenticated,
|
|
});
|
|
|
|
// Passkey count drives whether the upgrade nudge shows. Shares the cached
|
|
// `passkeys()` key with the list below, so this is not an extra round-trip.
|
|
const { data: passkeys } = useQuery({
|
|
queryKey: queryKeys.passkeys(),
|
|
queryFn: async () => apiGet<PasskeyInfo[]>("/api/auth/passkeys"),
|
|
enabled: isAuthenticated,
|
|
});
|
|
|
|
const showNudge =
|
|
status != null &&
|
|
!status.passkeyNudgeDismissed &&
|
|
passkeys != null &&
|
|
passkeys.length === 0;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{showNudge && <PasskeyUpgradeNudge />}
|
|
|
|
{isLoading || !status ? (
|
|
<>
|
|
<Skeleton className="h-40 w-full rounded-xl" />
|
|
<Skeleton className="h-40 w-full rounded-xl" />
|
|
<Skeleton className="h-40 w-full rounded-xl" />
|
|
</>
|
|
) : (
|
|
<>
|
|
<TotpCard
|
|
enabled={status.totp.enabled}
|
|
recoveryCodesRemaining={status.recoveryCodesRemaining}
|
|
/>
|
|
|
|
<SecurityKeysCard
|
|
keys={status.webauthn}
|
|
totpEnabled={status.totp.enabled}
|
|
/>
|
|
|
|
<PasskeyListSection
|
|
isAuthenticated={isAuthenticated}
|
|
proofMethods={status.proofMethods}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* The password comes last: authenticator, then security keys, then
|
|
passkeys, then the password. It sits OUTSIDE the status conditional
|
|
on purpose — it depends on nothing this page fetches and is the only
|
|
way to change a password now that the Account card is gone, so a
|
|
`/api/auth/me/mfa` that never answers must not take it down with the
|
|
cards that do need the status. */}
|
|
<PasswordCard />
|
|
|
|
{/* v1.44 — who is signed in and what happened, beside the controls
|
|
that decide how one signs in. These lived on the privacy page;
|
|
`/settings/privacy#sessions` (and the other two anchors) forward
|
|
here. Outside the status conditional for the same reason as the
|
|
password: they read their own routes. */}
|
|
<SecuritySessionsCard isAuthenticated={isAuthenticated} />
|
|
<TrustedDevicesCard isAuthenticated={isAuthenticated} />
|
|
<SecurityActivityCard isAuthenticated={isAuthenticated} />
|
|
</div>
|
|
);
|
|
}
|