398 lines
17 KiB
TypeScript
398 lines
17 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { useLocation, Link } from "wouter";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { insertUserSchema, InsertUser, loginSchema, registerSchema, LoginUser } from "@shared/schema";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from "@/components/ui/form";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { BrainCircuit } from "lucide-react";
|
|
|
|
export default function AuthPage() {
|
|
const { t } = useTranslation();
|
|
const { toast } = useToast();
|
|
const [, setLocation] = useLocation();
|
|
const queryClient = useQueryClient();
|
|
const [activeTab, setActiveTab] = useState("login");
|
|
|
|
// 2FA State
|
|
const [is2FARequired, setIs2FARequired] = useState(false);
|
|
const [twoFAUserId, setTwoFAUserId] = useState<string | null>(null);
|
|
const [twoFAEmail, setTwoFAEmail] = useState<string | null>(null);
|
|
const [otpCode, setOtpCode] = useState("");
|
|
|
|
const { data: settings } = useQuery<{ registration_enabled: boolean }>({
|
|
queryKey: ["/api/settings/public"],
|
|
queryFn: async () => {
|
|
const res = await fetch("/api/settings/public");
|
|
if (!res.ok) {
|
|
throw new Error("Failed to fetch settings");
|
|
}
|
|
return res.json();
|
|
},
|
|
});
|
|
|
|
const loginMutation = useMutation({
|
|
mutationFn: async (data: LoginUser) => {
|
|
const res = await fetch("/api/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
// Handle 200 OK could be user or 2fa_required
|
|
const json = await res.json();
|
|
|
|
if (!res.ok) {
|
|
throw new Error(json.message || "Invalid username or password");
|
|
}
|
|
return json;
|
|
},
|
|
onSuccess: (data) => {
|
|
if (data.message === "2fa_required") {
|
|
setIs2FARequired(true);
|
|
setTwoFAUserId(data.userId);
|
|
setTwoFAEmail(data.email);
|
|
toast({ title: t('auth.2faCodeSent'), description: t('auth.checkEmail') });
|
|
} else {
|
|
queryClient.setQueryData(["/api/user"], data);
|
|
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
|
toast({ title: "Welcome back!" });
|
|
setLocation('/');
|
|
}
|
|
},
|
|
onError: (error: Error) => {
|
|
toast({
|
|
title: "Login failed",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const verify2FAMutation = useMutation({
|
|
mutationFn: async (data: { userId: string, code: string }) => {
|
|
const res = await fetch("/api/auth/verify-2fa", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!res.ok) throw new Error(await res.text());
|
|
return res.json();
|
|
},
|
|
onSuccess: (user) => {
|
|
queryClient.setQueryData(["/api/user"], user);
|
|
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
|
toast({ title: "Verification Successful", description: "Welcome back!" });
|
|
setLocation('/');
|
|
},
|
|
onError: (error: Error) => {
|
|
toast({
|
|
title: "Verification Failed",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
});
|
|
|
|
const handleVerify2FA = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (twoFAUserId && otpCode) {
|
|
verify2FAMutation.mutate({ userId: twoFAUserId, code: otpCode });
|
|
}
|
|
};
|
|
|
|
const registerMutation = useMutation({
|
|
mutationFn: async (data: InsertUser) => {
|
|
const res = await fetch("/api/register", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(text || "Registration failed");
|
|
}
|
|
return res.json();
|
|
},
|
|
onSuccess: (data) => {
|
|
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
|
queryClient.setQueryData(["/api/user"], data);
|
|
toast({ title: "Account created!" });
|
|
setLocation('/');
|
|
},
|
|
// Error handling is done in the form submission handler to set field errors
|
|
});
|
|
|
|
return (
|
|
<div className="min-h-screen grid lg:grid-cols-2 dark">
|
|
<div className="hidden lg:flex flex-col justify-center items-center bg-zinc-900 p-12 text-white">
|
|
<div className="max-w-md space-y-4 text-center">
|
|
<div className="mb-8">
|
|
<img src="/favicon.png" alt="Logo" className="w-24 h-24" />
|
|
</div>
|
|
<h1 className="text-4xl font-bold tracking-tight">{t('auth.heroTitle')}</h1>
|
|
<p className="text-lg text-zinc-400">
|
|
{t('auth.heroSubtitle')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-center p-4 bg-background">
|
|
{is2FARequired ? (
|
|
<Card className="w-full max-w-md shadow-xl border-border/50">
|
|
<CardHeader className="text-center space-y-2">
|
|
<div className="lg:hidden mx-auto mb-4">
|
|
<img src="/favicon.png" alt="Logo" className="w-12 h-12 mx-auto" />
|
|
</div>
|
|
<CardTitle className="text-2xl font-bold">{t('auth.2faVerification')}</CardTitle>
|
|
<CardDescription>
|
|
{t('auth.enterCodeSentTo')} <span className="font-medium text-foreground">{twoFAUserId && twoFAEmail ? twoFAEmail : 'your email'}</span>
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleVerify2FA} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Input
|
|
placeholder="123456"
|
|
className="text-center text-2xl tracking-widest"
|
|
maxLength={6}
|
|
value={otpCode}
|
|
onChange={(e) => setOtpCode(e.target.value.replace(/\D/g, ''))}
|
|
/>
|
|
<p className="text-xs text-muted-foreground text-center">{t('auth.codeExpiresIn10')}</p>
|
|
</div>
|
|
<Button className="w-full" type="submit" disabled={verify2FAMutation.isPending || otpCode.length !== 6}>
|
|
{verify2FAMutation.isPending ? t('common.verifying') : t('auth.verify')}
|
|
</Button>
|
|
<Button variant="ghost" className="w-full" type="button" onClick={() => setIs2FARequired(false)}>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card className="w-full max-w-md shadow-xl border-border/50">
|
|
<CardHeader className="text-center space-y-2">
|
|
<div className="lg:hidden mx-auto mb-4">
|
|
<img src="/favicon.png" alt="Logo" className="w-12 h-12 mx-auto" />
|
|
</div>
|
|
<CardTitle className="text-2xl font-bold">{t('auth.welcomeBack')}</CardTitle>
|
|
<CardDescription>
|
|
{settings?.registration_enabled
|
|
? t('auth.signInDesc')
|
|
: t('auth.signInDescNoReg')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex w-full mb-6 bg-zinc-100 dark:bg-zinc-800 p-1 rounded-lg">
|
|
<button
|
|
onClick={() => setActiveTab("login")}
|
|
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${activeTab === "login"
|
|
? "bg-white dark:bg-zinc-950 shadow-sm text-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{t('auth.login')}
|
|
</button>
|
|
{settings?.registration_enabled && (
|
|
<button
|
|
onClick={() => setActiveTab("register")}
|
|
className={`flex-1 py-2 text-sm font-medium rounded-md transition-all ${activeTab === "register"
|
|
? "bg-white dark:bg-zinc-950 shadow-sm text-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{t('auth.register')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{activeTab === "login" ? (
|
|
<AuthForm
|
|
mode="login"
|
|
onSubmit={(data) => loginMutation.mutate(data)}
|
|
isLoading={loginMutation.isPending}
|
|
/>
|
|
) : (
|
|
settings?.registration_enabled ? (
|
|
<AuthForm
|
|
mode="register"
|
|
onSubmit={(data) => {
|
|
registerMutation.mutate(data as InsertUser, {
|
|
onError: (error) => {
|
|
// Handled in form
|
|
}
|
|
})
|
|
}}
|
|
isLoading={registerMutation.isPending}
|
|
registerMutation={registerMutation}
|
|
/>
|
|
) : (
|
|
<div className="p-4 text-center text-muted-foreground">{t('auth.registrationDisabled')}</div>
|
|
)
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AuthForm({
|
|
mode,
|
|
onSubmit,
|
|
isLoading,
|
|
registerMutation,
|
|
}: {
|
|
mode: "login" | "register";
|
|
onSubmit: (data: InsertUser | LoginUser) => void;
|
|
isLoading: boolean;
|
|
registerMutation?: any; // Type accurately if possible, but 'any' for quick fix avoids generic complexities
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { toast } = useToast();
|
|
const form = useForm<any>({
|
|
resolver: zodResolver(mode === "login" ? loginSchema : registerSchema),
|
|
defaultValues: {
|
|
username: "",
|
|
email: "",
|
|
password: "",
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (data: InsertUser | LoginUser) => {
|
|
if (mode === 'register' && registerMutation) {
|
|
registerMutation.mutate(data, {
|
|
onError: (error: Error) => {
|
|
const msg = error.message.toLowerCase();
|
|
if (msg.includes("username")) {
|
|
form.setError("username", { type: "manual", message: "Username already exists" });
|
|
} else if (msg.includes("email")) {
|
|
form.setError("email", { type: "manual", message: "Email already exists" });
|
|
} else {
|
|
toast({
|
|
title: "Registration failed",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
})
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
onSubmit(data);
|
|
}
|
|
};
|
|
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
|
<FormField
|
|
control={form.control}
|
|
name="username"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{mode === 'login' ? t('auth.usernameOrEmail') : t('auth.username')}</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder={mode === 'login' ? t('auth.enterUsernameOrEmail') : t('auth.chooseUsername')} {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{mode === "register" && (
|
|
<FormField
|
|
control={form.control}
|
|
name="email"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('auth.email')}</FormLabel>
|
|
<FormControl>
|
|
<Input type="email" placeholder={t('auth.enterEmail')} {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="password"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('auth.password')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type="password"
|
|
placeholder={t('auth.enterPassword')}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{mode === "login" && (
|
|
<div className="flex items-center justify-between">
|
|
<FormField
|
|
control={form.control}
|
|
name="rememberMe"
|
|
render={({ field }) => (
|
|
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
|
<FormControl>
|
|
<Checkbox
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
<FormLabel className="font-normal cursor-pointer">
|
|
{t("auth.rememberMe")}
|
|
</FormLabel>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<Link href="/forgot-password">
|
|
<Button variant="link" className="px-0 font-normal mt-0 h-auto text-muted-foreground hover:text-primary" type="button">
|
|
{t("auth.forgotPassword")}
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
<Button className="w-full" type="submit" disabled={isLoading}>
|
|
{isLoading
|
|
? mode === "login"
|
|
? t('auth.loggingIn')
|
|
: t('auth.creatingAccount')
|
|
: mode === "login"
|
|
? t('auth.signIn')
|
|
: t('auth.createAccount')}
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
);
|
|
}
|