zerobyte/app/client/modules/settings/routes/settings.tsx
Nico d9b150b34e
feat: add 2FA (TOTP) support (#326)
* feat: setup better-auth with 2fa

* feat(totp): frontend

* refactor: split dialogs into components

* feat: disable 2fa cli

* chore: fix liniting issues

* chore(deps): bump the minor-patch group across 1 directory with 19 updates (#327)

* chore(deps): bump the minor-patch group across 1 directory with 19 updates

Bumps the minor-patch group with 19 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) | `7.11.0` | `7.12.0` |
| [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) | `7.11.0` | `7.12.0` |
| [@scalar/hono-api-reference](https://github.com/scalar/scalar/tree/HEAD/integrations/hono) | `0.9.30` | `0.9.32` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.90.12` | `5.90.16` |
| [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) | `0.44.7` | `0.45.1` |
| [hono](https://github.com/honojs/hono) | `4.10.5` | `4.11.3` |
| [hono-rate-limiter](https://github.com/rhinobase/hono-rate-limiter) | `0.5.1` | `0.5.3` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.555.0` | `0.562.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.69.0` | `7.70.0` |
| [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) | `7.11.0` | `7.12.0` |
| [recharts](https://github.com/recharts/recharts) | `3.5.1` | `3.6.0` |
| [@faker-js/faker](https://github.com/faker-js/faker) | `10.1.0` | `10.2.0` |
| [@happy-dom/global-registrator](https://github.com/capricorn86/happy-dom) | `20.0.11` | `20.1.0` |
| [@hey-api/openapi-ts](https://github.com/hey-api/openapi-ts) | `0.88.2` | `0.90.2` |
| [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) | `7.11.0` | `7.12.0` |
| [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools) | `5.91.1` | `5.91.2` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) | `0.22.0` | `0.23.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) | `1.36.0` | `1.38.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.0` | `7.3.1` |

Updates `@react-router/node` from 7.11.0 to 7.12.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.12.0/packages/react-router-node)

Updates `@react-router/serve` from 7.11.0 to 7.12.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.12.0/packages/react-router-serve)

Updates `@scalar/hono-api-reference` from 0.9.30 to 0.9.32
- [Release notes](https://github.com/scalar/scalar/releases)
- [Changelog](https://github.com/scalar/scalar/blob/main/integrations/hono/CHANGELOG.md)
- [Commits](https://github.com/scalar/scalar/commits/HEAD/integrations/hono)

Updates `@tanstack/react-query` from 5.90.12 to 5.90.16
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.90.16/packages/react-query)

Updates `drizzle-orm` from 0.44.7 to 0.45.1
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.44.7...0.45.1)

Updates `hono` from 4.10.5 to 4.11.3
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.10.5...v4.11.3)

Updates `hono-rate-limiter` from 0.5.1 to 0.5.3
- [Release notes](https://github.com/rhinobase/hono-rate-limiter/releases)
- [Commits](https://github.com/rhinobase/hono-rate-limiter/compare/v0.5.1...v0.5.3)

Updates `lucide-react` from 0.555.0 to 0.562.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.562.0/packages/lucide-react)

Updates `react-hook-form` from 7.69.0 to 7.70.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.69.0...v7.70.0)

Updates `react-router` from 7.11.0 to 7.12.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.12.0/packages/react-router)

Updates `recharts` from 3.5.1 to 3.6.0
- [Release notes](https://github.com/recharts/recharts/releases)
- [Changelog](https://github.com/recharts/recharts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/recharts/recharts/compare/v3.5.1...v3.6.0)

Updates `@faker-js/faker` from 10.1.0 to 10.2.0
- [Release notes](https://github.com/faker-js/faker/releases)
- [Changelog](https://github.com/faker-js/faker/blob/next/CHANGELOG.md)
- [Commits](https://github.com/faker-js/faker/compare/v10.1.0...v10.2.0)

Updates `@happy-dom/global-registrator` from 20.0.11 to 20.1.0
- [Release notes](https://github.com/capricorn86/happy-dom/releases)
- [Commits](https://github.com/capricorn86/happy-dom/compare/v20.0.11...v20.1.0)

Updates `@hey-api/openapi-ts` from 0.88.2 to 0.90.2
- [Release notes](https://github.com/hey-api/openapi-ts/releases)
- [Changelog](https://github.com/hey-api/openapi-ts/blob/main/docs/CHANGELOG.md)
- [Commits](https://github.com/hey-api/openapi-ts/compare/@hey-api/openapi-ts@0.88.2...@hey-api/openapi-ts@0.90.2)

Updates `@react-router/dev` from 7.11.0 to 7.12.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.12.0/packages/react-router-dev)

Updates `@tanstack/react-query-devtools` from 5.91.1 to 5.91.2
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.91.2/packages/react-query-devtools)

Updates `oxfmt` from 0.22.0 to 0.23.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxfmt_v0.23.0/npm/oxfmt)

Updates `oxlint` from 1.36.0 to 1.38.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.38.0/npm/oxlint)

Updates `vite` from 7.3.0 to 7.3.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.1/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.1/packages/vite)

---
updated-dependencies:
- dependency-name: "@react-router/node"
  dependency-version: 7.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@react-router/serve"
  dependency-version: 7.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@scalar/hono-api-reference"
  dependency-version: 0.9.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.90.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: drizzle-orm
  dependency-version: 0.45.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: hono
  dependency-version: 4.11.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: hono-rate-limiter
  dependency-version: 0.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: lucide-react
  dependency-version: 0.562.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: react-hook-form
  dependency-version: 7.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: react-router
  dependency-version: 7.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: recharts
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@faker-js/faker"
  dependency-version: 10.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@happy-dom/global-registrator"
  dependency-version: 20.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@hey-api/openapi-ts"
  dependency-version: 0.90.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@react-router/dev"
  dependency-version: 7.12.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.91.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
- dependency-name: oxfmt
  dependency-version: 0.23.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: oxlint
  dependency-version: 1.38.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-patch
- dependency-name: vite
  dependency-version: 7.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: downgrade hono

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nicolas Meienberger <github@thisprops.com>

* chore: force hono version in transitive deps

* refactor: remove copy to clipboard everywhere as it's not possible on http

* chore: pr feedbacks

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-09 21:12:05 +01:00

285 lines
8.4 KiB
TypeScript

import { useMutation } from "@tanstack/react-query";
import { Download, KeyRound, User, X } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { Card, CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
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 { authClient } from "~/client/lib/auth-client";
import { appContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
import type { Route } from "./+types/settings";
export const handle = {
breadcrumb: () => [{ label: "Settings" }],
};
export function meta(_: Route.MetaArgs) {
return [
{ title: "Zerobyte - Settings" },
{
name: "description",
content: "Manage your account settings and preferences.",
},
];
}
export async function clientLoader({ context }: Route.LoaderArgs) {
const ctx = context.get(appContext);
return ctx;
}
export default function Settings({ loaderData }: Route.ComponentProps) {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [downloadPassword, setDownloadPassword] = useState("");
const [isChangingPassword, setIsChangingPassword] = useState(false);
const navigate = useNavigate();
const handleLogout = async () => {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
void navigate("/login", { replace: true });
},
onError: ({ error }) => {
console.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.URL.revokeObjectURL(url);
toast.success("Restic password file downloaded successfully");
setDownloadDialogOpen(false);
setDownloadPassword("");
},
onError: (error) => {
toast.error("Failed to download Restic password", {
description: error.message,
});
},
});
const handleChangePassword = async (e: React.FormEvent) => {
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.FormEvent) => {
e.preventDefault();
if (!downloadPassword) {
toast.error("Password is required");
return;
}
downloadResticPassword.mutate({
body: {
password: downloadPassword,
},
});
};
return (
<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>Username</Label>
<Input value={loaderData.user?.username || ""} disabled className="max-w-md" />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input value={loaderData.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">
<KeyRound className="size-5" />
Change Password
</CardTitle>
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription>
</div>
<CardContent className="p-6">
<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>
For security reasons, please enter your account password to download the recovery key file.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<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}>
<Download className="h-4 w-4 mr-2" />
Download
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardContent>
<TwoFactorSection twoFactorEnabled={loaderData.user?.twoFactorEnabled} />
</Card>
);
}