mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Start working on the profile refactoring.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user