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:
parent
d0dd74a626
commit
d9b150b34e
22 changed files with 2083 additions and 86 deletions
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
|
|
@ -84,6 +84,7 @@ jobs:
|
|||
with:
|
||||
image: local/zerobyte:ci
|
||||
fail-build: true
|
||||
only-fixed: true
|
||||
severity-cutoff: critical
|
||||
|
||||
- name: upload Anchore scan report
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
66
app/client/components/ui/input-otp.tsx
Normal file
66
app/client/components/ui/input-otp.tsx
Normal 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 };
|
||||
|
|
@ -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()],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
115
app/client/modules/settings/components/backup-codes-dialog.tsx
Normal file
115
app/client/modules/settings/components/backup-codes-dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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:
|
||||
{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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ type User = {
|
|||
email: string;
|
||||
username: string;
|
||||
hasDownloadedResticPassword: boolean;
|
||||
twoFactorEnabled?: boolean | null;
|
||||
};
|
||||
|
||||
type AppContext = {
|
||||
|
|
|
|||
12
app/drizzle/0031_graceful_squadron_supreme.sql
Normal file
12
app/drizzle/0031_graceful_squadron_supreme.sql
Normal 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`);
|
||||
1195
app/drizzle/meta/0031_snapshot.json
Normal file
1195
app/drizzle/meta/0031_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
74
app/server/cli/commands/disable-2fa.ts
Normal file
74
app/server/cli/commands/disable-2fa.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
6
bun.lock
6
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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue