diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51750219..7e87d09f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,6 +84,7 @@ jobs: with: image: local/zerobyte:ci fail-build: true + only-fixed: true severity-cutoff: critical - name: upload Anchore scan report diff --git a/app/client/components/ui/code-block.tsx b/app/client/components/ui/code-block.tsx index 1ae2ce56..c8529a0d 100644 --- a/app/client/components/ui/code-block.tsx +++ b/app/client/components/ui/code-block.tsx @@ -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 = ({ code, filename }) => { - const handleCopy = async () => { - await copyToClipboard(code); - toast.success("Code copied to clipboard"); - }; - return (
@@ -23,16 +16,9 @@ export const CodeBlock: React.FC = ({ code, filename }) => { {filename && {filename}}
-
-				{code}
+				{code}
 			
); diff --git a/app/client/components/ui/input-otp.tsx b/app/client/components/ui/input-otp.tsx new file mode 100644 index 00000000..11575d92 --- /dev/null +++ b/app/client/components/ui/input-otp.tsx @@ -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 & { + containerClassName?: string; +}) { + return ( + + ); +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function InputOTPSlot({ + index, + className, + ...props +}: React.ComponentProps<"div"> & { + index: number; +}) { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( +
+ +
+ ); +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/app/client/lib/auth-client.ts b/app/client/lib/auth-client.ts index 8bb832ad..8dfd12a5 100644 --- a/app/client/lib/auth-client.ts +++ b/app/client/lib/auth-client.ts @@ -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(), usernameClient()], + plugins: [inferAdditionalFields(), usernameClient(), twoFactorClient()], }); diff --git a/app/client/modules/auth/components/reset-password-dialog.tsx b/app/client/modules/auth/components/reset-password-dialog.tsx index d110b3b9..af91e7f5 100644 --- a/app/client/modules/auth/components/reset-password-dialog.tsx +++ b/app/client/modules/auth/components/reset-password-dialog.tsx @@ -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 ( @@ -26,13 +18,10 @@ export const ResetPasswordDialog = ({ open, onOpenChange }: ResetPasswordDialogP
-
{RESET_PASSWORD_COMMAND}
+
{RESET_PASSWORD_COMMAND}

This command will start an interactive session where you can enter a new password for your account.

-
diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index fb089eb6..555ce544 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -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({ 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 ( + +
+
+ +
+ + + + + + + + + + + + + +
+
+ +
+ setTrustDevice(e.target.checked)} + className="h-4 w-4" + /> + +
+ +
+ + +
+
+
+ ); + } + return (
diff --git a/app/client/modules/settings/components/backup-codes-dialog.tsx b/app/client/modules/settings/components/backup-codes-dialog.tsx new file mode 100644 index 00000000..6f111332 --- /dev/null +++ b/app/client/modules/settings/components/backup-codes-dialog.tsx @@ -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([]); + 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 ( + + + + Backup Codes + + Use these codes to access your account if you lose access to your authenticator app. Each code can only be + used once. + + +
+ {backupCodes.length > 0 ? ( + <> +
+ {backupCodes.map((code) => ( +
+ {code} +
+ ))} +
+ + ) : ( + +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + /> +
+ + + )} +
+ + + +
+
+ ); +}; diff --git a/app/client/modules/settings/components/two-factor-disable-dialog.tsx b/app/client/modules/settings/components/two-factor-disable-dialog.tsx new file mode 100644 index 00000000..738e672b --- /dev/null +++ b/app/client/modules/settings/components/two-factor-disable-dialog.tsx @@ -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 ( + + +
+ + Disable Two-Factor Authentication + + Are you sure you want to disable 2FA? Your account will be less secure. Enter your password to confirm. + + +
+
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + /> +
+
+ + + + +
+
+
+ ); +}; diff --git a/app/client/modules/settings/components/two-factor-section.tsx b/app/client/modules/settings/components/two-factor-section.tsx new file mode 100644 index 00000000..7f8b5c8f --- /dev/null +++ b/app/client/modules/settings/components/two-factor-section.tsx @@ -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 ( + <> +
+ + + Two-Factor Authentication + + Add an extra layer of security to your account +
+ +
+
+

+ Status:  + {twoFactorEnabled ? ( + Enabled + ) : ( + Disabled + )} +

+

+ Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app + in addition to your password. +

+
+
+ {!twoFactorEnabled ? ( + + ) : ( +
+ + +
+ )} +
+
+
+ + + + + + + + ); +}; diff --git a/app/client/modules/settings/components/two-factor-setup-dialog.tsx b/app/client/modules/settings/components/two-factor-setup-dialog.tsx new file mode 100644 index 00000000..5266fdc9 --- /dev/null +++ b/app/client/modules/settings/components/two-factor-setup-dialog.tsx @@ -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(null); + const [verificationCode, setVerificationCode] = useState(""); + const [backupCodes, setBackupCodes] = useState([]); + 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 ( + + + {setupStep === "password" && ( +
+ + Enable Two-Factor Authentication + + Enter your password to generate a QR code for your authenticator app + + +
+
+ + setPassword(e.target.value)} + placeholder="Enter your password" + required + /> +
+
+ + + + +
+ )} + + {setupStep === "qr" && totpUri && ( + <> + + Scan QR Code + + Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.) + + +
+
+ +
+
+ +
+ +
+
+ {backupCodes.length > 0 && ( +
+ +
+ {backupCodes.map((code) => ( +
+ {code} +
+ ))} +
+
+ )} +
+ + + + + )} + + {setupStep === "verify" && ( + <> + + Verify setup + + Enter the 6-digit code from your authenticator app to complete setup + + +
+
+
+ + + + + + + + + + + + + +
+
+
+ + + + + + )} +
+
+ ); +}; diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index 70a2fe8a..4bcecd6d 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -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) {
+
+ + +
@@ -268,6 +278,8 @@ export default function Settings({ loaderData }: Route.ComponentProps) { + + ); } diff --git a/app/context.ts b/app/context.ts index 686511ad..17a8ce0e 100644 --- a/app/context.ts +++ b/app/context.ts @@ -5,6 +5,7 @@ type User = { email: string; username: string; hasDownloadedResticPassword: boolean; + twoFactorEnabled?: boolean | null; }; type AppContext = { diff --git a/app/drizzle/0031_graceful_squadron_supreme.sql b/app/drizzle/0031_graceful_squadron_supreme.sql new file mode 100644 index 00000000..cd2ceb2a --- /dev/null +++ b/app/drizzle/0031_graceful_squadron_supreme.sql @@ -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`); \ No newline at end of file diff --git a/app/drizzle/meta/0031_snapshot.json b/app/drizzle/meta/0031_snapshot.json new file mode 100644 index 00000000..2d09d017 --- /dev/null +++ b/app/drizzle/meta/0031_snapshot.json @@ -0,0 +1,1195 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ae0e3a39-ef4d-4d39-bb97-d67d49193bc7", + "prevId": "37737391-3263-4d25-b4d3-417d7f981eb1", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_users_table_id_fk": { + "name": "account_user_id_users_table_id_fk", + "tableFrom": "account", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_metadata": { + "name": "app_metadata", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedule_mirrors_table": { + "name": "backup_schedule_mirrors_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "last_copy_at": { + "name": "last_copy_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_copy_status": { + "name": "last_copy_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_copy_error": { + "name": "last_copy_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "backup_schedule_mirrors_table_schedule_id_repository_id_unique": { + "name": "backup_schedule_mirrors_table_schedule_id_repository_id_unique", + "columns": [ + "schedule_id", + "repository_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk": { + "name": "backup_schedule_mirrors_table_schedule_id_backup_schedules_table_id_fk", + "tableFrom": "backup_schedule_mirrors_table", + "tableTo": "backup_schedules_table", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedule_mirrors_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedule_mirrors_table", + "tableTo": "repositories_table", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedule_notifications_table": { + "name": "backup_schedule_notifications_table", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination_id": { + "name": "destination_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notify_on_start": { + "name": "notify_on_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_on_success": { + "name": "notify_on_success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_on_warning": { + "name": "notify_on_warning", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_on_failure": { + "name": "notify_on_failure", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": { + "name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk", + "tableFrom": "backup_schedule_notifications_table", + "tableTo": "backup_schedules_table", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": { + "name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk", + "tableFrom": "backup_schedule_notifications_table", + "tableTo": "notification_destinations_table", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "backup_schedule_notifications_table_schedule_id_destination_id_pk": { + "columns": [ + "schedule_id", + "destination_id" + ], + "name": "backup_schedule_notifications_table_schedule_id_destination_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedules_table": { + "name": "backup_schedules_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "volume_id": { + "name": "volume_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_policy": { + "name": "retention_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "exclude_if_present": { + "name": "exclude_if_present", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "include_patterns": { + "name": "include_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_status": { + "name": "last_backup_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_error": { + "name": "last_backup_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_backup_at": { + "name": "next_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "one_file_system": { + "name": "one_file_system", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "backup_schedules_table_short_id_unique": { + "name": "backup_schedules_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "backup_schedules_table_name_unique": { + "name": "backup_schedules_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "backup_schedules_table_volume_id_volumes_table_id_fk": { + "name": "backup_schedules_table_volume_id_volumes_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "volumes_table", + "columnsFrom": [ + "volume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedules_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "repositories_table", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_destinations_table": { + "name": "notification_destinations_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "notification_destinations_table_name_unique": { + "name": "notification_destinations_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories_table": { + "name": "repositories_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compression_mode": { + "name": "compression_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'auto'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "repositories_table_short_id_unique": { + "name": "repositories_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions_table": { + "name": "sessions_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_table_token_unique": { + "name": "sessions_table_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "sessionsTable_userId_idx": { + "name": "sessionsTable_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_table_user_id_users_table_id_fk": { + "name": "sessions_table_user_id_users_table_id_fk", + "tableFrom": "sessions_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "two_factor": { + "name": "two_factor", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "twoFactor_secret_idx": { + "name": "twoFactor_secret_idx", + "columns": [ + "secret" + ], + "isUnique": false + }, + "twoFactor_userId_idx": { + "name": "twoFactor_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "two_factor_user_id_users_table_id_fk": { + "name": "two_factor_user_id_users_table_id_fk", + "tableFrom": "two_factor", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_downloaded_restic_password": { + "name": "has_downloaded_restic_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "users_table_email_unique": { + "name": "users_table_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "volumes_table": { + "name": "volumes_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unmounted'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch() * 1000)" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_remount": { + "name": "auto_remount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "volumes_table_short_id_unique": { + "name": "volumes_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "volumes_table_name_unique": { + "name": "volumes_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/app/drizzle/meta/_journal.json b/app/drizzle/meta/_journal.json index ac6eb8f9..f7fbfcbb 100644 --- a/app/drizzle/meta/_journal.json +++ b/app/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/app/lib/auth.ts b/app/lib/auth.ts index d2f60308..20631681 100644 --- a/app/lib/auth.ts +++ b/app/lib/auth.ts @@ -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, }, diff --git a/app/server/cli/commands/disable-2fa.ts b/app/server/cli/commands/disable-2fa.ts new file mode 100644 index 00000000..1e0f1413 --- /dev/null +++ b/app/server/cli/commands/disable-2fa.ts @@ -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 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); + }); diff --git a/app/server/cli/index.ts b/app/server/cli/index.ts index 9a4be586..74a6d074 100644 --- a/app/server/cli/index.ts +++ b/app/server/cli/index.ts @@ -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 { const args = argv.slice(2); diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index aeeaccf1..647a2f4e 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -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)], +); diff --git a/app/utils/clipboard.ts b/app/utils/clipboard.ts deleted file mode 100644 index 95868893..00000000 --- a/app/utils/clipboard.ts +++ /dev/null @@ -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(); - } - } -} diff --git a/bun.lock b/bun.lock index 4bd10f04..898a4dbb 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index 90988078..1a4cdab1 100644 --- a/package.json +++ b/package.json @@ -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",