Files
zerobyte/app/client/modules/settings/routes/settings.tsx
Nico 98338e80c3 Add passkey authentication support (#845)
* feat(auth): add passkey authentication support

* fix: implement AI review feedback

* fix: use non-unique index for passkey_credentialID_idx in migration

* refactor(passkeys): use TanStack mutations for passkey CRUD operations

* chore: restore lockfile from main and add @better-auth/passkey

* chore: fix conflicts

* refactor(passkey-login): simplify passkey autofill event

* refactor(settings-passkeys): ux improvements

---------

Co-authored-by: Nicolas Meienberger <github@thisprops.com>
2026-05-21 21:18:46 +02:00

529 lines
17 KiB
TypeScript

import { useMutation } from "@tanstack/react-query";
import {
AlertTriangle,
Download,
Fingerprint,
KeyRound,
User,
X,
Settings as SettingsIcon,
Building2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
import type { GetOrgMembersResponse, GetSsoSettingsResponse } from "~/client/api-client/types.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/client/components/ui/tabs";
import { useRootLoaderData } from "~/client/hooks/use-root-loader-data";
import { authClient } from "~/client/lib/auth-client";
import {
DATE_FORMATS,
type DateFormatPreference,
TIME_FORMATS,
type TimeFormatPreference,
useTimeFormat,
} from "~/client/lib/datetime";
import { logger } from "~/client/lib/logger";
import { parseError } from "~/client/lib/errors";
import { type AppContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
import { PasskeysSection } from "../components/passkeys-section";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
import { OrgMembersSection } from "../components/org-members-section";
import { useOrganizationContext } from "~/client/hooks/use-org-context";
import { cn } from "~/client/lib/utils";
const RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE =
"Downloading the recovery key requires a local credential password. Ask an operator to run `docker exec -it zerobyte bun run cli reset-password` for your user, then sign in with that password and try again.";
type Props = {
appContext: AppContext;
initialMembers?: GetOrgMembersResponse;
initialSsoSettings?: GetSsoSettingsResponse;
initialOrigin?: string;
};
export function SettingsPage({ appContext, initialMembers, initialSsoSettings, initialOrigin }: Props) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [downloadPassword, setDownloadPassword] = useState("");
const [downloadBlockedMessage, setDownloadBlockedMessage] = useState<string | null>(null);
const [isChangingPassword, setIsChangingPassword] = useState(false);
const { dateFormat, timeFormat } = useRootLoaderData();
const { tab } = useSearch({ from: "/(dashboard)/settings/" });
const activeTab = tab || "account";
const navigate = useNavigate();
const { activeMember, activeOrganization } = useOrganizationContext();
const isOrgAdmin = activeMember?.role === "owner" || activeMember?.role === "admin";
const { formatDateTime } = useTimeFormat();
const hasCredentialPassword = appContext.user?.hasCredentialPassword !== false;
const handleLogout = async () => {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
void navigate({ to: "/login", replace: true });
},
onError: ({ error }) => {
logger.error(error);
toast.error("Logout failed", { description: error.message });
},
},
});
};
const downloadResticPassword = useMutation({
...downloadResticPasswordMutation(),
onSuccess: (data) => {
const blob = new Blob([data], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "restic.pass";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
toast.success("Restic password file downloaded successfully");
setDownloadDialogOpen(false);
setDownloadPassword("");
setDownloadBlockedMessage(null);
},
onError: (error) => {
const message = parseError(error)?.message;
setDownloadBlockedMessage(message?.includes("credential password") ? message : null);
toast.error("Failed to download Restic password", {
description: message,
});
},
});
const handleChangePassword = async (e: React.SubmitEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast.error("Passwords do not match");
return;
}
if (newPassword.length < 8) {
toast.error("Password must be at least 8 characters long");
return;
}
await authClient.changePassword({
newPassword,
currentPassword: currentPassword,
revokeOtherSessions: true,
fetchOptions: {
onSuccess: () => {
toast.success("Password changed successfully. You will be logged out.");
setTimeout(() => {
void handleLogout();
}, 1500);
},
onError: ({ error }) => {
toast.error("Failed to change password", {
description: error.message,
});
},
onRequest: () => {
setIsChangingPassword(true);
},
onResponse: () => {
setIsChangingPassword(false);
},
},
});
};
const handleDownloadResticPassword = (e: React.SubmitEvent) => {
e.preventDefault();
if (!downloadPassword) {
toast.error("Password is required");
return;
}
setDownloadBlockedMessage(null);
downloadResticPassword.mutate({
body: {
password: downloadPassword,
},
});
};
const handleDateTimeFormatChange = async (
nextDateFormat: DateFormatPreference,
nextTimeFormat: TimeFormatPreference,
) => {
await authClient.updateUser({
dateFormat: nextDateFormat,
timeFormat: nextTimeFormat,
fetchOptions: {
onError: ({ error }) => {
toast.error("Failed to update date and time format", {
description: error.message,
});
},
onSuccess: () => {
window.location.reload();
},
},
});
};
const handleDateFormatChange = async (nextDateFormat: DateFormatPreference) => {
if (nextDateFormat === dateFormat) {
return;
}
await handleDateTimeFormatChange(nextDateFormat, timeFormat);
};
const handleTimeFormatChange = async (nextTimeFormat: TimeFormatPreference) => {
if (nextTimeFormat === timeFormat) {
return;
}
await handleDateTimeFormatChange(dateFormat, nextTimeFormat);
};
const onTabChange = (value: string) => {
void navigate({ to: ".", search: () => ({ tab: value }) });
};
return (
<div className="space-y-6">
<Tabs value={activeTab} onValueChange={onTabChange} className="w-full">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
{isOrgAdmin && <TabsTrigger value="organization">Organization</TabsTrigger>}
</TabsList>
<div className="mt-2">
<TabsContent value="account" className="mt-0">
<Card className="p-0 gap-0">
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<User className="size-5" />
Account Information
</CardTitle>
<CardDescription className="mt-1.5">Your account details</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={appContext.user?.username}
disabled
className="max-w-md"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={appContext.user?.email}
disabled
className="max-w-md"
/>
</div>
</CardContent>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<SettingsIcon className="size-5" />
Date and Time Format
</CardTitle>
<CardDescription className="mt-1.5">
Choose how dates and times are shown throughout the app
</CardDescription>
</div>
<CardContent className="p-6">
<div className="space-y-4 max-w-2xl">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="date-format">Date format</Label>
<Select
value={dateFormat}
onValueChange={(value) =>
void handleDateFormatChange(value as DateFormatPreference)
}
>
<SelectTrigger id="date-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMATS.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="time-format">Time format</Label>
<Select
value={timeFormat}
onValueChange={(value) =>
void handleTimeFormatChange(value as TimeFormatPreference)
}
>
<SelectTrigger id="time-format">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIME_FORMATS.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p className="text-sm text-muted-foreground">
Preview: {formatDateTime(new Date())}
</p>
</div>
</CardContent>
<div
className={cn("border-t border-border/50 bg-card-header p-6", {
hidden: !hasCredentialPassword,
})}
>
<CardTitle className="flex items-center gap-2">
<KeyRound className="size-5" />
Change Password
</CardTitle>
<CardDescription className="mt-1.5">
Update your password to keep your account secure
</CardDescription>
</div>
<CardContent className={cn("p-6", { hidden: !hasCredentialPassword })}>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="max-w-md"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="max-w-md"
required
minLength={8}
/>
<p className="text-xs text-muted-foreground">
Must be at least 8 characters long
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="max-w-md"
required
minLength={8}
/>
</div>
<Button type="submit" loading={isChangingPassword} className="mt-4">
<KeyRound className="h-4 w-4 mr-2" />
Change Password
</Button>
</form>
</CardContent>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Download className="size-5" />
Backup Recovery Key
</CardTitle>
<CardDescription className="mt-1.5">
Download your recovery key for Restic backups
</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<p className="text-sm text-muted-foreground max-w-2xl">
This file contains the encryption password used by Restic to secure your backups.
Store it in a safe place (like a password manager or encrypted storage). If you lose
access to this server, you'll need this file to recover your backup data.
</p>
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<Download size={16} className="mr-2" />
Download recovery key
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleDownloadResticPassword}>
<DialogHeader>
<DialogTitle>Download Recovery Key</DialogTitle>
<DialogDescription>
{!hasCredentialPassword
? "A local credential password is required before this recovery key can be downloaded."
: "For security reasons, please enter your account password to download the recovery key file."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Alert
variant="warning"
className={cn({
hidden: hasCredentialPassword && !downloadBlockedMessage,
})}
>
<AlertTriangle className="size-5" />
<AlertTitle>Local password required</AlertTitle>
<AlertDescription>
{downloadBlockedMessage ??
RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
</AlertDescription>
</Alert>
<div className={cn("space-y-2", { hidden: !hasCredentialPassword })}>
<Label htmlFor="download-password">Your Password</Label>
<Input
id="download-password"
type="password"
value={downloadPassword}
onChange={(e) => setDownloadPassword(e.target.value)}
placeholder="Enter your password"
required
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setDownloadDialogOpen(false);
setDownloadPassword("");
}}
>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button
type="submit"
loading={downloadResticPassword.isPending}
className={cn({ hidden: !hasCredentialPassword })}
>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardContent>
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />
<PasskeysSection />
</Card>
</TabsContent>
{isOrgAdmin && (
<TabsContent value="organization" className="mt-0 space-y-4">
<Card className="p-0 gap-0">
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5" />
Organization Details
</CardTitle>
<CardDescription className="mt-1.5">
Reference details for the active organization
</CardDescription>
</div>
<CardContent className="p-6 space-y-2">
<Label htmlFor="organization-id">Organization ID</Label>
<Input
id="organization-id"
value={activeOrganization.id}
readOnly
className="max-w-xl font-mono text-sm"
/>
</CardContent>
</Card>
<Card className="p-0 gap-0">
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Building2 className="size-5" />
Members
</CardTitle>
<CardDescription className="mt-1.5">
Manage organization members and roles
</CardDescription>
</div>
<CardContent className="p-6">
<OrgMembersSection initialMembers={initialMembers} />
</CardContent>
</Card>
<Card className="p-0 gap-0">
<div className="border-b border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<SettingsIcon className="size-5" />
Single Sign-On
</CardTitle>
<CardDescription className="mt-1.5">
Configure OIDC provider settings
</CardDescription>
</div>
<CardContent className="p-6">
<SsoSettingsSection
initialSettings={initialSsoSettings}
initialOrigin={initialOrigin}
/>
</CardContent>
</Card>
</TabsContent>
)}
</div>
</Tabs>
</div>
);
}