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(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 (
Account {isOrgAdmin && Organization}
Account Information Your account details
Date and Time Format Choose how dates and times are shown throughout the app

Preview: {formatDateTime(new Date())}

Change Password Update your password to keep your account secure
setCurrentPassword(e.target.value)} className="max-w-md" required />
setNewPassword(e.target.value)} className="max-w-md" required minLength={8} />

Must be at least 8 characters long

setConfirmPassword(e.target.value)} className="max-w-md" required minLength={8} />
Backup Recovery Key Download your recovery key for Restic backups

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.

Download Recovery Key {!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."}
Local password required {downloadBlockedMessage ?? RECOVERY_KEY_CREDENTIAL_REQUIRED_MESSAGE}
setDownloadPassword(e.target.value)} placeholder="Enter your password" required />
{isOrgAdmin && (
Organization Details Reference details for the active organization
Members Manage organization members and roles
Single Sign-On Configure OIDC provider settings
)}
); }