Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/node_modules
/.pnp
.pnp.js
.pnpm-store/

# testing
/coverage
Expand Down
17 changes: 17 additions & 0 deletions web/app/two-factor/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Suspense } from "react";
import { TwoFactorChallengePage } from "@/components/auth/two-factor-challenge-page";
import { Spinner } from "@/components/ui/spinner";

export default function Page() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center">
<Spinner className="size-6" />
</div>
}
>
<TwoFactorChallengePage />
</Suspense>
);
}
16 changes: 12 additions & 4 deletions web/components/auth/sign-in-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { signIn, useSession } from "@/lib/auth-client";
import { getSafeAuthRedirect } from "@/lib/two-factor";

export function SignInPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { data: session, isPending } = useSession();
const redirectTo = searchParams.get("redirect") || "/dashboard";
const redirectTo = getSafeAuthRedirect(searchParams.get("redirect"));

useEffect(() => {
if (!isPending && session) {
Expand All @@ -40,15 +41,22 @@ export function SignInPage() {
setError("");
setLoading(true);

const { error } = await signIn.email({
const response = await signIn.email({
email,
password,
});

setLoading(false);

if (error) {
setError(error.message || "Failed to sign in");
if (response.error) {
setError(response.error.message || "Failed to sign in");
return;
}

if (
(response.data as { twoFactorRedirect?: boolean } | null)
?.twoFactorRedirect
) {
return;
}

Expand Down
192 changes: 192 additions & 0 deletions web/components/auth/two-factor-challenge-page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
"use client";

import Image from "next/image";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { authClient, useSession } from "@/lib/auth-client";
import { getSafeAuthRedirect } from "@/lib/two-factor";

type AuthClientError = {
message?: string;
error_description?: string;
} | null;

function getErrorMessage(error: AuthClientError, fallback: string) {
return error?.message || error?.error_description || fallback;
}

function normalizeCode(value: string) {
return value.replace(/\s/g, "");
}

export function TwoFactorChallengePage() {
const router = useRouter();
const searchParams = useSearchParams();
const { data: session, isPending } = useSession();
const redirectTo = getSafeAuthRedirect(searchParams.get("redirect"));
const [mode, setMode] = useState<"totp" | "backup">("totp");
const [code, setCode] = useState("");
const [trustDevice, setTrustDevice] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const normalizedCode = useMemo(() => normalizeCode(code), [code]);

useEffect(() => {
if (!isPending && session) {
router.replace(redirectTo);
}
}, [isPending, redirectTo, router, session]);

async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
setLoading(true);

const response =
mode === "totp"
? await authClient.twoFactor.verifyTotp({
code: normalizedCode,
trustDevice,
})
: await authClient.twoFactor.verifyBackupCode({
code: normalizedCode,
trustDevice,
});

setLoading(false);

if (response.error) {
setError(
getErrorMessage(
response.error,
mode === "totp"
? "Invalid authenticator code"
: "Invalid backup code",
),
);
return;
}

router.push(redirectTo);
}

if (isPending || session) {
return (
<div className="min-h-screen flex items-center justify-center">
<Spinner className="size-6" />
</div>
);
}

return (
<div className="min-h-screen flex flex-col items-center justify-center p-4">
<Image
src="/logo.png"
alt="Logo"
width={48}
height={48}
className="mb-6"
/>
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Two-Factor Authentication</CardTitle>
<CardDescription>
Enter the code from your authenticator app to continue
</CardDescription>
</CardHeader>
<form onSubmit={(event) => void handleSubmit(event)}>
<CardContent className="space-y-4 pb-4">
{error && (
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
{error}
</div>
)}
<div className="grid grid-cols-2 gap-2 rounded-lg bg-muted p-1">
<Button
type="button"
variant={mode === "totp" ? "secondary" : "ghost"}
size="sm"
onClick={() => {
setMode("totp");
setCode("");
setError("");
}}
>
App code
</Button>
<Button
type="button"
variant={mode === "backup" ? "secondary" : "ghost"}
size="sm"
onClick={() => {
setMode("backup");
setCode("");
setError("");
}}
>
Backup code
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="two-factor-code">
{mode === "totp" ? "Authenticator code" : "Backup code"}
</Label>
<Input
id="two-factor-code"
inputMode={mode === "totp" ? "numeric" : "text"}
autoComplete="one-time-code"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder={mode === "totp" ? "123456" : "XXXX-XXXX"}
required
autoFocus
/>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div>
<Label htmlFor="trust-device">Trust this device</Label>
<p className="text-xs text-muted-foreground">
Skip 2FA on this browser for 30 days.
</p>
</div>
<Switch
id="trust-device"
checked={trustDevice}
onCheckedChange={setTrustDevice}
/>
</div>
</CardContent>
<CardFooter className="flex-col gap-4">
<Button
type="submit"
className="w-full"
disabled={loading || normalizedCode.length === 0}
>
{loading ? <Spinner className="size-4" /> : null}
Verify
</Button>
<p className="text-sm text-muted-foreground">
<Link href="/" className="text-primary hover:underline">
Back to sign in
</Link>
</p>
</CardFooter>
</form>
</Card>
</div>
);
}
8 changes: 8 additions & 0 deletions web/components/settings/global-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { ApiKeySettings } from "@/components/settings/api-key-settings";
import { EmailSettings } from "@/components/settings/email-settings";
import { MemberSettings } from "@/components/settings/member-settings";
import { TwoFactorSettings } from "@/components/settings/two-factor-settings";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Expand Down Expand Up @@ -323,6 +324,9 @@ export function GlobalSettings({
<TabsTrigger value="email" className="px-4 shrink-0">
Email
</TabsTrigger>
<TabsTrigger value="security" className="px-4 shrink-0">
Security
</TabsTrigger>
<TabsTrigger value="api-keys" className="px-4 shrink-0">
API Keys
</TabsTrigger>
Expand Down Expand Up @@ -537,6 +541,10 @@ export function GlobalSettings({
/>
</TabsContent>

<TabsContent value="security" className="space-y-6 pt-4">
<TwoFactorSettings />
</TabsContent>

<TabsContent value="api-keys" className="space-y-6 pt-4">
<ApiKeySettings />
</TabsContent>
Expand Down
Loading
Loading