Start working on the profile refactoring.

This commit is contained in:
charlesgauthereau
2025-12-21 16:55:12 +01:00
parent 428782e8a4
commit 116229a29e
77 changed files with 5076 additions and 509 deletions
@@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { z } from "zod";
import { Loader2, ShieldX } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { authClient } from "@/lib/auth/auth-client";
import TwoFactorForm from "../form/2fa-form";
import { zPassword } from "@/lib/zod";
import {PasswordInput} from "@/components/ui/password-input";
const PasswordSchema = z.object({
password: zPassword(),
});
type Password = z.infer<typeof PasswordSchema>;
type Disable2FAModalProps = {
onOpenChange: (open: boolean) => void;
open: boolean;
};
export function Disable2FAProfileProviderModal({ onOpenChange, open }: Disable2FAModalProps) {
const router = useRouter();
const [step, setStep] = useState<"OTP" | "PASSWORD">("OTP");
const passwordForm = useZodForm({
schema: PasswordSchema,
defaultValues: {
password: "",
},
});
const { mutate: disable2FA, isPending: isDisabling } = useMutation({
mutationFn: async (values: Password) => {
const { data, error } = await authClient.twoFactor.disable({
password: values.password,
});
if (error) throw error;
return data;
},
onSuccess: () => {
router.refresh();
toast.success("Two-factor authentication disabled successfully.");
onOpenChange(false);
setStep("OTP");
passwordForm.reset();
},
onError: () => {
toast.error("Failed to disable two-factor authentication.");
},
});
const handleClose = () => {
passwordForm.reset();
setStep("OTP");
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<ShieldX className="w-4 h-4 mr-2" />
Disable Two-Factor
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
<DialogDescription>Are you sure you want to disable two-factor authentication? This will reduce the security of your account.</DialogDescription>
</DialogHeader>
{step === "OTP" && (
<TwoFactorForm
onSuccess={(success) => {
if (success) {
setStep("PASSWORD");
}
}}
/>
)}
{step === "PASSWORD" && (
<Form form={passwordForm} onSubmit={async (values) => disable2FA(values)}>
<FormField
control={passwordForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<PasswordInput placeholder={"Fill your current Password"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-between items-center pt-2">
<Button type="button" variant="ghost" onClick={() => setStep("OTP")} disabled={isDisabling}>
Cancel
</Button>
<Button type="submit" disabled={isDisabling || !passwordForm.formState.isDirty}>
{isDisabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Disable
</Button>
</div>
</Form>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,31 @@
"use client";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import ResetPasswordForm from "../form/reset-password-form";
type ResetPasswordModalProps = {
onOpenChange: (open: boolean) => void;
open: boolean;
};
export function ResetPasswordProfileProviderModal({ onOpenChange, open }: ResetPasswordModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
Reset Password
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset Password</DialogTitle>
<DialogDescription>Enter a new password for your account below.</DialogDescription>
</DialogHeader>
<ResetPasswordForm onSuccess={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,32 @@
"use client";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import SetPasswordForm from "../form/set-password-form";
type SetPasswordModalProps = {
onOpenChange: (open: boolean) => void;
open: boolean;
};
export function SetPasswordProfileProviderModal({ onOpenChange, open }: SetPasswordModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button variant="default" size="sm">
Set Password
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Set Password</DialogTitle>
<DialogDescription>Create a password for your account to enable password-based login.</DialogDescription>
</DialogHeader>
<SetPasswordForm onSuccess={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,253 @@
"use client";
import {useState} from "react";
import {Button} from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Loader2, Copy, CheckCircle2, ShieldCheck} from "lucide-react";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {Setup2FASecuritySchema, Setup2FASecuritySchemaType} from "../schemas/security.schema";
import {toast} from "sonner";
import {authClient} from "@/lib/auth/auth-client";
import {Alert, AlertDescription} from "@/components/ui/alert";
import {InputOTP, InputOTPGroup, InputOTPSlot} from "@/components/ui/input-otp";
import QRCode from "react-qr-code";
import z from "zod";
import {zPassword} from "@/lib/zod";
import {BackupCodesList} from "../components/backup-codes-list";
import {PasswordInput} from "@/components/ui/password-input";
const PasswordSchema = z.object({
password: zPassword(),
});
type Password = z.infer<typeof PasswordSchema>;
type Setup2FAModalProps = {
onOpenChange: (open: boolean) => void;
open: boolean;
};
export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModalProps) {
const router = useRouter();
const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD");
const [totpURI, setTotpURI] = useState<string>("");
const [secret, setSecret] = useState<string>("");
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const form = useZodForm({
schema: Setup2FASecuritySchema,
defaultValues: {
code: "",
},
});
const passwordForm = useZodForm({
schema: PasswordSchema,
defaultValues: {
password: "",
},
});
const {mutate: enable2FA, isPending: isEnabling} = useMutation({
mutationFn: async (values: Password) => {
const {data, error} = await authClient.twoFactor.enable({
password: values.password,
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
setTotpURI(data.totpURI);
setSecret(data.totpURI.split("secret=")[1].split("&")[0]);
setBackupCodes(data.backupCodes || []);
setStep("QR");
},
onError: () => {
toast.error("Failed to enable two-factor authentication.");
},
});
const {mutate: verify2FA, isPending: isVerifying} = useMutation({
mutationFn: async (values: Setup2FASecuritySchemaType) => {
const {data, error} = await authClient.twoFactor.verifyTotp({
code: values.code,
trustDevice: true,
});
if (error) throw error;
return data;
},
onSuccess: () => {
toast.success("Two-factor authentication enabled successfully.");
setStep("BACKUP");
},
onError: () => {
toast.error("The provided code is invalid.");
form.reset();
},
});
const handleCopySecret = () => {
navigator.clipboard.writeText(secret);
toast.success("Secret copied to clipboard");
};
const handleClose = () => {
router.refresh();
onOpenChange(false);
setStep("PASSWORD");
form.reset();
passwordForm.reset();
};
return (
<Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<ShieldCheck className="w-4 h-4 mr-2"/>
Enable Two-Factor
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Enable Two-Factor Authentication</DialogTitle>
<DialogDescription>
{step === "PASSWORD" && ""}
{step === "QR" && "Scan the QR code below with your authentication app or enter the secret key manually."}
{step === "BACKUP" && "Save these backup codes in a secure location. They can be used to access your account if you lose access to your authentication device."}
</DialogDescription>
</DialogHeader>
{step === "PASSWORD" && (
<Form form={passwordForm} onSubmit={async (values) => enable2FA(values)}>
<div className="space-y-4 py-4">
<div className="space-y-2">
<FormField
control={passwordForm.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<PasswordInput placeholder="Fill your current password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isEnabling || !passwordForm.formState.isDirty}>
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
Continue
</Button>
</div>
</div>
</Form>
)}
{step === "QR" && (
<Form
form={form}
onSubmit={async (values) => {
verify2FA(values);
}}
>
<div className="flex flex-col items-center justify-center space-y-6 py-4">
<div className="p-4 bg-white rounded-xl shadow-sm border">
{totpURI && (
<QRCode value={totpURI} size={180}
style={{height: "auto", maxWidth: "100%", width: "100%"}}
viewBox={`0 0 256 256`}/>
)}
</div>
<div className="w-full space-y-2">
<p className="text-xs text-muted-foreground text-center">If you are unable to scan the QR code, you can manually enter the secret key into your authentication app :</p>
<div className="flex items-center gap-2">
<code
className="flex-1 bg-muted p-2 rounded text-xs font-mono break-all text-center">{secret}</code>
<Button type="button" size="icon" variant="ghost" onClick={handleCopySecret}>
<Copy className="h-4 w-4"/>
</Button>
</div>
</div>
<div className="w-full border-t pt-4">
<FormField
control={form.control}
name="code"
render={({field}) => (
<FormItem className="flex flex-col items-center">
<FormLabel className="mb-2">Verification Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
autoFocus
onChange={(value) => {
field.onChange(value);
if (value.length === 6) {
verify2FA(form.getValues());
}
}}
>
<InputOTPGroup>
<InputOTPSlot index={0}/>
<InputOTPSlot index={1}/>
<InputOTPSlot index={2}/>
<InputOTPSlot index={3}/>
<InputOTPSlot index={4}/>
<InputOTPSlot index={5}/>
</InputOTPGroup>
</InputOTP>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex justify-end w-full">
<Button disabled={isVerifying} type="submit">
{isVerifying && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
I've Configured My App
</Button>
</div>
</div>
</Form>
)}
{step === "BACKUP" && (
<div className="space-y-6 py-4">
<Alert variant="default"
className="border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-900">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400"/>
<AlertDescription
className="text-green-700 dark:text-green-400">Two Factor Authentication is now enabled on your account.</AlertDescription>
</Alert>
<BackupCodesList codes={backupCodes}/>
<div className="flex justify-end pt-2">
<Button onClick={handleClose}>Finish Setup</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { Loader2, FileKey2, RefreshCw, AlertTriangle, Download } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { authClient } from "@/lib/auth/auth-client";
import { toast } from "sonner";
import { z } from "zod";
import { zPassword } from "@/lib/zod";
import { BackupCodesList } from "../components/backup-codes-list";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {PasswordInput} from "@/components/ui/password-input";
const PasswordSchema = z.object({
password: zPassword(),
});
type Password = z.infer<typeof PasswordSchema>;
type ViewBackupCodesModalProps = {
onOpenChange: (open: boolean) => void;
open: boolean;
};
export function ViewBackupCodesModal({ onOpenChange, open }: ViewBackupCodesModalProps) {
const [step, setStep] = useState<"PASSWORD" | "CODES">("PASSWORD");
const [codes, setCodes] = useState<string[]>([]);
const form = useZodForm({
schema: PasswordSchema,
defaultValues: {
password: "",
},
});
const { mutate: generateCodes, isPending } = useMutation({
mutationFn: async (values: Password) => {
const { data, error } = await authClient.twoFactor.generateBackupCodes({
password: values.password,
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
if (data?.backupCodes) {
setCodes(data.backupCodes);
setStep("CODES");
toast.success("New backup codes generated successfully.");
}
},
onError: () => {
toast.error("Failed to generate backup codes. Your password may be incorrect.");
},
});
const handleClose = () => {
form.reset();
setStep("PASSWORD");
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<FileKey2 className="w-4 h-4 mr-2" />
Regenerate Backup Codes
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Backup Codes</DialogTitle>
<DialogDescription>{step === "PASSWORD" ? "For security reasons, existing codes are hidden. You must generate a new set to view them." : "Save these codes securely. They will not be shown again once you close this window."}</DialogDescription>
</DialogHeader>
{step === "PASSWORD" && (
<Form form={form} onSubmit={(values) => generateCodes(values)}>
<div className="space-y-4 py-2">
<Alert variant="destructive" className="py-3 w-fit">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="text-sm font-semibold ml-2">Important</AlertTitle>
<AlertDescription className="text-xs ml-2 mt-1">Generating new codes will invalidate your existing ones. Make sure to save the new codes securely.</AlertDescription>
</Alert>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<PasswordInput placeholder={"Fill your current Password"} {...field} autoFocus />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-2">
<Button type="submit" disabled={isPending} variant="default">
{isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
Generate New Codes
</Button>
</div>
</div>
</Form>
)}
{step === "CODES" && (
<div className="space-y-6 py-2 animate-in fade-in zoom-in-95 duration-200">
<BackupCodesList codes={codes} />
<div className="flex flex-col sm:flex-row justify-between gap-2 pt-2">
<Button onClick={() => handleClose()} className="w-full sm:w-auto">
I Have Saved My Codes
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}