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>
This commit is contained in:
Nico 2026-01-09 21:12:05 +01:00 committed by GitHub
parent d0dd74a626
commit d9b150b34e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2083 additions and 86 deletions

View file

@ -84,6 +84,7 @@ jobs:
with:
image: local/zerobyte:ci
fail-build: true
only-fixed: true
severity-cutoff: critical
- name: upload Anchore scan report

View file

@ -1,6 +1,4 @@
import type React from "react";
import { toast } from "sonner";
import { copyToClipboard } from "~/utils/clipboard";
interface CodeBlockProps {
code: string;
@ -9,11 +7,6 @@ interface CodeBlockProps {
}
export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
const handleCopy = async () => {
await copyToClipboard(code);
toast.success("Code copied to clipboard");
};
return (
<div className="overflow-hidden rounded-sm bg-card-header ring-1 ring-white/10">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-xs">
@ -23,16 +16,9 @@ export const CodeBlock: React.FC<CodeBlockProps> = ({ code, filename }) => {
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
{filename && <span className="ml-3 font-medium">{filename}</span>}
</div>
<button
type="button"
onClick={() => handleCopy()}
className="cursor-pointer rounded-md bg-white/5 px-2 py-1 text-[11px] font-medium ring-1 ring-inset ring-white/10 transition hover:bg-white/10 active:translate-y-px"
>
Copy
</button>
</div>
<pre className="text-xs m-0 px-4 py-2 bg-card-header">
<code className="text-white/80">{code}</code>
<code className="text-white/80 select-all">{code}</code>
</pre>
</div>
);

View file

@ -0,0 +1,66 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "~/client/lib/utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />;
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View file

@ -1,8 +1,8 @@
import { createAuthClient } from "better-auth/react";
import { usernameClient } from "better-auth/client/plugins";
import { twoFactorClient, usernameClient } from "better-auth/client/plugins";
import { inferAdditionalFields } from "better-auth/client/plugins";
import type { auth } from "~/lib/auth";
export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>(), usernameClient()],
plugins: [inferAdditionalFields<typeof auth>(), usernameClient(), twoFactorClient()],
});

View file

@ -1,7 +1,4 @@
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/client/components/ui/dialog";
import { copyToClipboard } from "~/utils/clipboard";
const RESET_PASSWORD_COMMAND = "docker exec -it zerobyte bun run cli reset-password";
@ -11,11 +8,6 @@ type ResetPasswordDialogProps = {
};
export const ResetPasswordDialog = ({ open, onOpenChange }: ResetPasswordDialogProps) => {
const handleCopy = async () => {
await copyToClipboard(RESET_PASSWORD_COMMAND);
toast.success("Command copied to clipboard");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
@ -26,13 +18,10 @@ export const ResetPasswordDialog = ({ open, onOpenChange }: ResetPasswordDialogP
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md bg-muted p-4 font-mono text-sm break-all">{RESET_PASSWORD_COMMAND}</div>
<div className="rounded-md bg-muted p-4 font-mono text-sm break-all select-all">{RESET_PASSWORD_COMMAND}</div>
<p className="text-sm text-muted-foreground">
This command will start an interactive session where you can enter a new password for your account.
</p>
<Button onClick={handleCopy} variant="outline" className="w-full">
Copy Command
</Button>
</div>
</DialogContent>
</Dialog>

View file

@ -8,10 +8,12 @@ import { AuthLayout } from "~/client/components/auth-layout";
import { Button } from "~/client/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
import { Input } from "~/client/components/ui/input";
import { authMiddleware } from "~/middleware/auth";
import type { Route } from "./+types/login";
import { ResetPasswordDialog } from "../components/reset-password-dialog";
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client";
import { authMiddleware } from "~/middleware/auth";
import { ResetPasswordDialog } from "../components/reset-password-dialog";
import type { Route } from "./+types/login";
export const clientMiddleware = [authMiddleware];
@ -36,6 +38,10 @@ export default function LoginPage() {
const navigate = useNavigate();
const [showResetDialog, setShowResetDialog] = useState(false);
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [requires2FA, setRequires2FA] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [isVerifying2FA, setIsVerifying2FA] = useState(false);
const [trustDevice, setTrustDevice] = useState(false);
const form = useForm<LoginFormValues>({
resolver: arktypeResolver(loginSchema),
@ -65,6 +71,11 @@ export default function LoginPage() {
return;
}
if ("twoFactorRedirect" in data && data.twoFactorRedirect) {
setRequires2FA(true);
return;
}
const d = await authClient.getSession();
if (data.user && !d.data?.user.hasDownloadedResticPassword) {
void navigate("/download-recovery-key");
@ -73,6 +84,117 @@ export default function LoginPage() {
}
};
const handleVerify2FA = async () => {
if (totpCode.length !== 6) {
toast.error("Please enter a 6-digit code");
return;
}
const { data, error } = await authClient.twoFactor.verifyTotp({
code: totpCode,
trustDevice,
fetchOptions: {
onRequest: () => {
setIsVerifying2FA(true);
},
onResponse: () => {
setIsVerifying2FA(false);
},
},
});
if (error) {
console.error(error);
toast.error("Verification failed", { description: error.message });
setTotpCode("");
return;
}
if (data) {
toast.success("Login successful");
const session = await authClient.getSession();
if (session.data?.user && !session.data.user.hasDownloadedResticPassword) {
void navigate("/download-recovery-key");
} else {
void navigate("/volumes");
}
}
};
const handleBackToLogin = () => {
setRequires2FA(false);
setTotpCode("");
setTrustDevice(false);
form.reset();
};
if (requires2FA) {
return (
<AuthLayout title="Two-Factor Authentication" description="Enter the 6-digit code from your authenticator app">
<div className="space-y-6">
<div className="space-y-4 flex flex-col items-center">
<Label htmlFor="totp-code">Authentication code</Label>
<div>
<InputOTP
maxLength={6}
value={totpCode}
onChange={setTotpCode}
onComplete={handleVerify2FA}
disabled={isVerifying2FA}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="trust-device"
checked={trustDevice}
onChange={(e) => setTrustDevice(e.target.checked)}
className="h-4 w-4"
/>
<label htmlFor="trust-device" className="text-sm text-muted-foreground cursor-pointer">
Trust this device for 30 days
</label>
</div>
<div className="space-y-2">
<Button
type="button"
className="w-full"
loading={isVerifying2FA}
onClick={handleVerify2FA}
disabled={totpCode.length !== 6}
>
Verify
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleBackToLogin}
disabled={isVerifying2FA}
>
Back to Login
</Button>
</div>
</div>
</AuthLayout>
);
}
return (
<AuthLayout title="Login to your account" description="Enter your credentials below to login to your account">
<Form {...form}>

View file

@ -0,0 +1,115 @@
import { useState } from "react";
import { toast } from "sonner";
import { RefreshCw } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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";
type BackupCodesDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export const BackupCodesDialog = ({ open, onOpenChange }: BackupCodesDialogProps) => {
const [password, setPassword] = useState("");
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const handleGenerate = async (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
toast.error("Password is required");
return;
}
const { data, error } = await authClient.twoFactor.generateBackupCodes({
password,
fetchOptions: {
onRequest: () => {
setIsGenerating(true);
},
onResponse: () => {
setIsGenerating(false);
},
},
});
if (error) {
console.error(error);
toast.error("Failed to generate backup codes", { description: error.message });
return;
}
setBackupCodes(data.backupCodes);
setPassword("");
toast.success("New backup codes generated successfully");
};
const handleClose = () => {
onOpenChange(false);
setTimeout(() => {
setBackupCodes([]);
setPassword("");
}, 200);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Backup Codes</DialogTitle>
<DialogDescription>
Use these codes to access your account if you lose access to your authenticator app. Each code can only be
used once.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{backupCodes.length > 0 ? (
<>
<div className="p-3 bg-muted rounded-md space-y-1 max-h-48 overflow-y-auto">
{backupCodes.map((code) => (
<div key={code} className="text-sm font-mono py-1">
<span className="select-all block w-full">{code}</span>
</div>
))}
</div>
</>
) : (
<form onSubmit={handleGenerate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="backup-codes-password">Your password</Label>
<Input
id="backup-codes-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
/>
</div>
<Button type="submit" loading={isGenerating} className="w-full">
<RefreshCw className="h-4 w-4 mr-2" />
Generate new codes
</Button>
</form>
)}
</div>
<DialogFooter>
<Button type="button" onClick={handleClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,97 @@
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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";
type TwoFactorDisableDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
};
export const TwoFactorDisableDialog = ({ open, onOpenChange, onSuccess }: TwoFactorDisableDialogProps) => {
const [password, setPassword] = useState("");
const [isDisabling, setIsDisabling] = useState(false);
const handleDisable = async (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
toast.error("Password is required");
return;
}
const { error } = await authClient.twoFactor.disable({
password,
fetchOptions: {
onRequest: () => {
setIsDisabling(true);
},
onResponse: () => {
setIsDisabling(false);
},
},
});
if (error) {
console.error(error);
toast.error("Failed to disable 2FA", { description: error.message });
return;
}
toast.success("Two-factor authentication disabled successfully");
handleClose();
onSuccess();
};
const handleClose = () => {
onOpenChange(false);
setPassword("");
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<form onSubmit={handleDisable}>
<DialogHeader>
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
<DialogDescription>
Are you sure you want to disable 2FA? Your account will be less secure. Enter your password to confirm.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="disable-password">Your password</Label>
<Input
id="disable-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" variant="destructive" loading={isDisabling}>
Disable 2FA
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,71 @@
import { useState } from "react";
import { Shield } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import { TwoFactorSetupDialog } from "./two-factor-setup-dialog";
import { TwoFactorDisableDialog } from "./two-factor-disable-dialog";
import { BackupCodesDialog } from "./backup-codes-dialog";
type TwoFactorSectionProps = {
twoFactorEnabled?: boolean | null;
};
export const TwoFactorSection = ({ twoFactorEnabled }: TwoFactorSectionProps) => {
const [setupDialogOpen, setSetupDialogOpen] = useState(false);
const [disableDialogOpen, setDisableDialogOpen] = useState(false);
const [backupCodesDialogOpen, setBackupCodesDialogOpen] = useState(false);
const handleSuccess = async () => {
window.location.reload();
};
return (
<>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Shield className="size-5" />
Two-Factor Authentication
</CardTitle>
<CardDescription className="mt-1.5">Add an extra layer of security to your account</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
Status:&nbsp;
{twoFactorEnabled ? (
<span className="text-green-500">Enabled</span>
) : (
<span className="text-muted-foreground">Disabled</span>
)}
</p>
<p className="text-xs text-muted-foreground max-w-xl">
Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app
in addition to your password.
</p>
</div>
<div className="flex gap-2">
{!twoFactorEnabled ? (
<Button onClick={() => setSetupDialogOpen(true)}>Enable 2FA</Button>
) : (
<div className="ml-2 flex flex-col @xl:flex-row gap-2">
<Button variant="outline" onClick={() => setBackupCodesDialogOpen(true)}>
Backup Codes
</Button>
<Button variant="destructive" onClick={() => setDisableDialogOpen(true)}>
Disable 2FA
</Button>
</div>
)}
</div>
</div>
</CardContent>
<TwoFactorSetupDialog open={setupDialogOpen} onOpenChange={setSetupDialogOpen} onSuccess={handleSuccess} />
<TwoFactorDisableDialog open={disableDialogOpen} onOpenChange={setDisableDialogOpen} onSuccess={handleSuccess} />
<BackupCodesDialog open={backupCodesDialogOpen} onOpenChange={setBackupCodesDialogOpen} />
</>
);
};

View file

@ -0,0 +1,237 @@
import { useState } from "react";
import { toast } from "sonner";
import { QRCodeCanvas } from "qrcode.react";
import { Button } from "~/client/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "~/client/components/ui/input-otp";
import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client";
type TwoFactorSetupDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
};
export const TwoFactorSetupDialog = ({ open, onOpenChange, onSuccess }: TwoFactorSetupDialogProps) => {
const [setupStep, setSetupStep] = useState<"password" | "qr" | "verify">("password");
const [password, setPassword] = useState("");
const [totpUri, setTotpUri] = useState<string | null>(null);
const [verificationCode, setVerificationCode] = useState("");
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [isEnabling2FA, setIsEnabling2FA] = useState(false);
const [isVerifying2FA, setIsVerifying2FA] = useState(false);
const handleEnable2FA = async (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
toast.error("Password is required");
return;
}
const { data, error } = await authClient.twoFactor.enable({
password,
issuer: "Zerobyte",
fetchOptions: {
onRequest: () => {
setIsEnabling2FA(true);
},
onResponse: () => {
setIsEnabling2FA(false);
},
},
});
if (error) {
console.error(error);
toast.error("Failed to enable 2FA", { description: error.message });
return;
}
setTotpUri(data.totpURI);
setBackupCodes(data.backupCodes);
setSetupStep("qr");
};
const handleVerify2FA = async () => {
if (verificationCode.length !== 6) {
toast.error("Please enter a 6-digit code");
return;
}
const { data, error } = await authClient.twoFactor.verifyTotp({
code: verificationCode,
fetchOptions: {
onRequest: () => {
setIsVerifying2FA(true);
},
onResponse: () => {
setIsVerifying2FA(false);
},
},
});
if (error) {
console.error(error);
toast.error("Verification failed", { description: error.message });
setVerificationCode("");
return;
}
if (data) {
toast.success("Two-factor authentication enabled successfully");
handleClose();
onSuccess();
}
};
const handleClose = () => {
onOpenChange(false);
setTimeout(() => {
setPassword("");
setTotpUri(null);
setVerificationCode("");
setBackupCodes([]);
setSetupStep("password");
}, 200);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
{setupStep === "password" && (
<form onSubmit={handleEnable2FA}>
<DialogHeader>
<DialogTitle>Enable Two-Factor Authentication</DialogTitle>
<DialogDescription>
Enter your password to generate a QR code for your authenticator app
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="setup-password">Your password</Label>
<Input
id="setup-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
required
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" loading={isEnabling2FA}>
Continue
</Button>
</DialogFooter>
</form>
)}
{setupStep === "qr" && totpUri && (
<>
<DialogHeader>
<DialogTitle>Scan QR Code</DialogTitle>
<DialogDescription>
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex justify-center p-4 bg-white rounded-lg">
<QRCodeCanvas value={totpUri} size={200} />
</div>
<div className="space-y-2">
<Label className="text-xs">Manual entry code</Label>
<div className="flex items-center gap-2">
<Input
value={totpUri.split("secret=")[1]?.split("&")[0] || ""}
readOnly
className="text-xs font-mono select-all"
/>
</div>
</div>
{backupCodes.length > 0 && (
<div className="space-y-2">
<Label className="text-xs">Backup codes (save these securely)</Label>
<div className="p-3 bg-muted rounded-md space-y-1 max-h-32 overflow-y-auto">
{backupCodes.map((code) => (
<div key={code} className="text-xs font-mono py-1">
<span className="select-all block w-full">{code}</span>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button type="button" onClick={() => setSetupStep("verify")}>
Continue
</Button>
</DialogFooter>
</>
)}
{setupStep === "verify" && (
<>
<DialogHeader>
<DialogTitle>Verify setup</DialogTitle>
<DialogDescription>
Enter the 6-digit code from your authenticator app to complete setup
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2 flex flex-col items-center">
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={verificationCode}
onChange={setVerificationCode}
onComplete={handleVerify2FA}
disabled={isVerifying2FA}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setSetupStep("qr")}>
Back
</Button>
<Button
type="button"
onClick={handleVerify2FA}
loading={isVerifying2FA}
disabled={verificationCode.length !== 6}
>
Verify
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
};

View file

@ -3,6 +3,7 @@ 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 {
@ -16,10 +17,10 @@ import {
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { appContext } from "~/context";
import type { Route } from "./+types/settings";
import { downloadResticPasswordMutation } from "~/client/api-client/@tanstack/react-query.gen";
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" }],
@ -47,6 +48,7 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false);
const [downloadPassword, setDownloadPassword] = useState("");
const [isChangingPassword, setIsChangingPassword] = useState(false);
const navigate = useNavigate();
const handleLogout = async () => {
@ -81,7 +83,9 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
setDownloadPassword("");
},
onError: (error) => {
toast.error("Failed to download Restic password", { description: error.message });
toast.error("Failed to download Restic password", {
description: error.message,
});
},
});
@ -109,8 +113,10 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
void handleLogout();
}, 1500);
},
onError: (error) => {
toast.error("Failed to change password", { description: error.error.message });
onError: ({ error }) => {
toast.error("Failed to change password", {
description: error.message,
});
},
onRequest: () => {
setIsChangingPassword(true);
@ -151,6 +157,10 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
<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">
@ -268,6 +278,8 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
</DialogContent>
</Dialog>
</CardContent>
<TwoFactorSection twoFactorEnabled={loaderData.user?.twoFactorEnabled} />
</Card>
);
}

View file

@ -5,6 +5,7 @@ type User = {
email: string;
username: string;
hasDownloadedResticPassword: boolean;
twoFactorEnabled?: boolean | null;
};
type AppContext = {

View file

@ -0,0 +1,12 @@
CREATE TABLE `two_factor` (
`id` text PRIMARY KEY NOT NULL,
`secret` text NOT NULL,
`backup_codes` text NOT NULL,
`user_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `twoFactor_secret_idx` ON `two_factor` (`secret`);--> statement-breakpoint
CREATE INDEX `twoFactor_userId_idx` ON `two_factor` (`user_id`);--> statement-breakpoint
ALTER TABLE `users_table` ADD `two_factor_enabled` integer DEFAULT false NOT NULL;--> statement-breakpoint
CREATE INDEX `sessionsTable_userId_idx` ON `sessions_table` (`user_id`);

File diff suppressed because it is too large Load diff

View file

@ -218,6 +218,13 @@
"when": 1767821088612,
"tag": "0030_lower-trim-username",
"breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1767863951955,
"tag": "0031_graceful_squadron_supreme",
"breakpoints": true
}
]
}

View file

@ -6,7 +6,7 @@ import {
type MiddlewareOptions,
} from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthMiddleware, username } from "better-auth/plugins";
import { createAuthMiddleware, twoFactor, username } from "better-auth/plugins";
import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user";
import { cryptoUtils } from "~/server/utils/crypto";
import { db } from "~/server/db/db";
@ -46,7 +46,15 @@ const createBetterAuth = (secret: string) =>
session: {
modelName: "sessionsTable",
},
plugins: [username({})],
plugins: [
username(),
twoFactor({
backupCodeOptions: {
storeBackupCodes: "encrypted",
amount: 5,
},
}),
],
advanced: {
disableOriginCheck: true,
},

View file

@ -0,0 +1,74 @@
import { select } from "@inquirer/prompts";
import { Command } from "commander";
import { eq } from "drizzle-orm";
import { toMessage } from "~/server/utils/errors";
import { db } from "../../db/db";
import { twoFactor, usersTable } from "../../db/schema";
const listUsers = () => {
return db
.select({ id: usersTable.id, username: usersTable.username })
.from(usersTable);
};
const disable2FA = async (username: string) => {
const [user] = await db
.select()
.from(usersTable)
.where(eq(usersTable.username, username));
if (!user) {
throw new Error(`User "${username}" not found`);
}
if (!user.twoFactorEnabled) {
throw new Error(`User "${username}" does not have 2FA enabled`);
}
await db.transaction(async (tx) => {
await tx
.update(usersTable)
.set({ twoFactorEnabled: false })
.where(eq(usersTable.id, user.id));
await tx.delete(twoFactor).where(eq(twoFactor.userId, user.id));
});
};
export const disable2FACommand = new Command("disable-2fa")
.description("Disable two-factor authentication for a user")
.option("-u, --username <username>", "Username of the account")
.action(async (options) => {
console.log("\n🔐 Zerobyte 2FA Disable\n");
let username = options.username;
if (!username) {
const users = await listUsers();
if (users.length === 0) {
console.error("❌ No users found in the database.");
console.log(
" Please create a user first by starting the application.",
);
process.exit(1);
}
username = await select({
message: "Select user to disable 2FA for:",
choices: users.map((u) => ({ name: u.username, value: u.username })),
});
}
try {
await disable2FA(username);
console.log(
`\n✅ Two-factor authentication has been disabled for user "${username}".`,
);
console.log(" The user can re-enable 2FA from their account settings.");
} catch (error) {
console.error(`\n❌ Failed to disable 2FA: ${toMessage(error)}`);
process.exit(1);
}
process.exit(0);
});

View file

@ -1,10 +1,12 @@
import { Command } from "commander";
import { disable2FACommand } from "./commands/disable-2fa";
import { resetPasswordCommand } from "./commands/reset-password";
const program = new Command();
program.name("zerobyte").description("Zerobyte CLI - Backup automation tool built on top of Restic").version("1.0.0");
program.addCommand(resetPasswordCommand);
program.addCommand(disable2FACommand);
export async function runCLI(argv: string[]): Promise<boolean> {
const args = argv.slice(2);

View file

@ -50,26 +50,31 @@ export const usersTable = sqliteTable("users_table", {
emailVerified: integer("email_verified", { mode: "boolean" }).default(false).notNull(),
image: text("image"),
displayUsername: text("display_username"),
twoFactorEnabled: integer("two_factor_enabled", { mode: "boolean" }).notNull().default(false),
});
export type User = typeof usersTable.$inferSelect;
export const sessionsTable = sqliteTable("sessions_table", {
id: text().primaryKey(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: int("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: int("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$onUpdate(() => new Date())
.default(sql`(unixepoch() * 1000)`),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
});
export const sessionsTable = sqliteTable(
"sessions_table",
{
id: text().primaryKey(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: int("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: int("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$onUpdate(() => new Date())
.default(sql`(unixepoch() * 1000)`),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
},
(table) => [index("sessionsTable_userId_idx").on(table.userId)],
);
export type Session = typeof sessionsTable.$inferSelect;
export const account = sqliteTable(
@ -109,12 +114,12 @@ export const verification = sqliteTable(
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at", { mode: "number" }).notNull(),
createdAt: integer("created_at", { mode: "number" })
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
updatedAt: integer("updated_at", { mode: "number" })
.$onUpdate(() => Date.now())
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.$onUpdate(() => new Date())
.notNull()
.default(sql`(unixepoch() * 1000)`),
},
@ -124,6 +129,7 @@ export const verification = sqliteTable(
export const userRelations = relations(usersTable, ({ many }) => ({
sessions: many(sessionsTable),
accounts: many(account),
twoFactors: many(twoFactor),
}));
export const sessionRelations = relations(sessionsTable, ({ one }) => ({
@ -326,3 +332,16 @@ export const appMetadataTable = sqliteTable("app_metadata", {
.default(sql`(unixepoch() * 1000)`),
});
export type AppMetadata = typeof appMetadataTable.$inferSelect;
export const twoFactor = sqliteTable(
"two_factor",
{
id: text("id").primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
},
(table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
);

View file

@ -1,25 +0,0 @@
export async function copyToClipboard(textToCopy: string) {
// Navigator clipboard api needs a secure context (https)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(textToCopy);
} else {
// Use the 'out of viewport hidden text area' trick
const textArea = document.createElement("textarea");
textArea.value = textToCopy;
// Move textarea out of the viewport so it's not visible
textArea.style.position = "absolute";
textArea.style.left = "-999999px";
document.body.prepend(textArea);
textArea.select();
try {
document.execCommand("copy");
} catch (error) {
console.error(error);
} finally {
textArea.remove();
}
}
}

View file

@ -44,10 +44,12 @@
"hono-openapi": "^1.1.1",
"hono-rate-limiter": "^0.5.3",
"http-errors-enhanced": "^4.0.2",
"input-otp": "^1.4.2",
"isbot": "^5.1.32",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"qrcode.react": "^4.2.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.70.0",
@ -956,6 +958,8 @@
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
@ -1260,6 +1264,8 @@
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],

View file

@ -59,10 +59,12 @@
"hono-openapi": "^1.1.1",
"hono-rate-limiter": "^0.5.3",
"http-errors-enhanced": "^4.0.2",
"input-otp": "^1.4.2",
"isbot": "^5.1.32",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"qrcode.react": "^4.2.0",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.70.0",