fix: feature-base-refactoring (#297)

* docs: add AGENTS.md with feature-based architecture conventions

Defines the target src/ structure, feature list, naming conventions
(kebab-case, file type patterns), import direction rules, and
feature boundary guidelines for the planned big-bang refactor.

* docs: add features/channel/ to architecture conventions

Generic channel UI (card, modal, form) goes to features/channel/.
Provider-specific sub-folders: channel/notifications/ and channel/storages/.

* refactor: move event emitter to lib/, project service to db/services/, dedup use-mobile hook

* fix: remove unused auth import in db/services/project.ts

* refactor: migrate wrappers/common to components/common (flat)

* fix: remove dead commented-out code from button-with-loading.tsx

* refactor: merge features/browser into features/theme, flatten upload/updates, migrate statistics

* refactor: migrate wrappers/auth to features/auth

Move all auth components and forms from src/components/wrappers/auth/
into src/features/auth/ with flat kebab-case naming. Handle extra
reset-password-form.tsx and differing reset-password-schema.ts by
copying them as reset-password-page-form.tsx and reset-password-page.schema.ts.

* refactor: remove dead auth files and fix zPassword schema

Delete reset-password-section.tsx and reset-password-page-form.tsx (zero consumers).
Fix reset-password-page.schema.ts to import zPassword from @/lib/zod.

* refactor: migrate wrappers/dashboard/common to features/layout, rename Header.tsx to header.tsx

* fix: decouple layout from theme/updates cross-feature imports

Use slot props (actions, updateNotification) so app/ composes
ModeToggle and UpdateNotification into Header and AppSidebar.
Delete dead side-bar-logo.tsx (zero consumers).

* refactor: merge features/keys into features/agents, flatten sub-dirs, migrate agent wrappers

- Merge src/features/keys/keys.action.ts into src/features/agents/
- Flatten src/features/agents/components/ and hooks/ into feature root
- Migrate all src/components/wrappers/dashboard/agent/ files into src/features/agents/
- Update all import paths across app/ and src/ accordingly
- Fix broken code-snippet import in agent-key-modal.tsx (was pointing to non-existent wrappers path)

* fix: move github-releases to lib/, remove dead code and unused imports

Move GitHub release fetch logic from features/updates/github.ts to
lib/github-releases.ts so both features/updates and features/agents
can import it without cross-feature imports.
Remove commented-out impersonation block from agent-database-card.tsx.
Remove unused imports (CopyButton, Terminal, Key, Info).

* refactor: flatten features/projects/components, migrate project wrappers

Move project.dialog/form out of sub-dir, migrate project-card, project-database-card, and delete-project button/action from wrappers into features/projects/. Update all import paths across src/ and app/. Leave wrappers/dashboard/projects/database/ untouched for Task 10.

* fix: extract DatabaseCard to components/common, remove cross-feature import

DatabaseCard was imported by both features/projects and features/agents,
violating the no-cross-feature-import rule. Extract to components/common/
database-card.tsx so both features import from the shared layer.
Also tighten extendedProps type and remove commented-out maxCount prop.

* refactor: migrate all database/backup/restore/health UI to features/database

Consolidates ~40 files from 5 source locations (features/dashboard/backup,
features/dashboard/restore, wrappers/dashboard/backup, wrappers/dashboard/database,
wrappers/dashboard/health, wrappers/dashboard/projects/database) into the new
flat features/database directory with kebab-case filenames and updated @/ imports.

* fix: remove dead restore-form files and console.log from health-grid

Delete restore-form.tsx (186 lines all commented-out, zero consumers)
and restore-form.schema.ts (only a commented import).
Remove console.log(logs) from getOldestLog helper in health-grid.tsx.

Cross-feature imports (database→storages for dispatchStorage/storeBackupFiles)
are accepted as known architectural coupling — storage dispatch is called from
database server actions at runtime and cannot route through app/ props.

* refactor: migrate wrappers/admin/users to features/users, fix table-colums typo

* fix: fix relative imports, string interpolation, and console.error in users feature

Replace relative imports with @/ aliases in user-actions-cell.tsx and
admin-user-edit-modal.tsx. Fix string interpolation bug in dialog title
(was regular string, now template literal). Remove console.error from
admin-user-change-role-modal.tsx.

* refactor: migrate wrappers/profile to features/profile, fix profile-apperance typo

* fix: remove console.error calls and ts-ignore in profile feature

Remove console.error from profile-account.tsx, 2fa-form.tsx (x2).
Replace @ts-ignore on fileInput.onchange with typed cast in avatar-with-upload.tsx.

* refactor: rename features/organization to features/organizations, migrate all org wrappers

Moves 37 org-related files from features/organization/, features/dashboard/,
hooks/, wrappers/admin/organizations/, wrappers/admin/channels/organization/,
and wrappers/dashboard/organization/ into a flat features/organizations/ feature
directory. Updates all import paths codebase-wide. Fixes @ts-ignore and removes
unused @better-fetch/fetch import in admin-organization-form.

* fix: remove console.error, @ts-ignore, and unused destructures in features/organizations

* refactor: migrate wrappers/organization/migration to features/migration

Move all 5 migration files to src/features/migration/, update internal
imports to @/features/migration/ absolute paths, remove console.log,
and delete src/components/wrappers/dashboard/organization/ entirely.

* refactor: migrate wrappers/admin/settings to features/settings

Move all 14 settings files (email, notification, storage, s3) from
wrappers/dashboard/admin/settings/ into features/settings/, rewriting
imports, replacing @ts-ignore with @ts-expect-error, and renaming error
params to _error where applicable.

* refactor: rename notifications files to kebab convention, migrate notification logs

- Rename dispatch.ts/helpers.ts/types.ts to notifications.*.ts
- Migrate notification logs components from wrappers/dashboard/admin/notifications/logs/ to features/notifications/
- Fix all import paths across codebase and in providers/
- Remove unused useRouter import, replace @ts-ignore with @ts-expect-error
- Delete src/components/wrappers/dashboard/admin/notifications/

* refactor: rename storages dispatch/helpers/types to kebab convention

- Rename dispatch.ts/helpers.ts/types.ts to storages.*.ts
- Fix all import paths across codebase and in providers/
- Fix relative ../types and ../../types imports in all storage providers to absolute @/ paths
- Replace @ts-ignore with @ts-expect-error, remove console.debug/console.error

* refactor: create features/channel with generic UI and notifications/storages provider sub-folders

Consolidates 4 source locations into src/features/channel/:
- components/wrappers/dashboard/admin/channels/ → features/channel/ (11 generic UI files)
- features/notifications/providers/ → features/channel/notifications/ (9 backend + 18 form files)
- features/storages/providers/ → features/channel/storages/ (backend + forms)

The two conflicting google-drive/helpers.ts files (client helpers vs OAuth action)
are kept separate: helpers.ts and google-drive-refresh.action.ts.

Rewrites all imports codebase-wide to point to new locations.
Removes console.log/console.error calls, upgrades @ts-ignore to @ts-expect-error.

* fix: remove console.error from local storage provider

* fix: multi-select.tsx scroll issue

* fix: multi-select.tsx scroll issue
This commit is contained in:
Charles GTE
2026-05-24 17:09:12 +02:00
committed by GitHub
parent 4fadfcfaf2
commit 3fa0f9b76d
330 changed files with 627 additions and 1120 deletions
+166
View File
@@ -0,0 +1,166 @@
"use client";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormMessage, useZodForm } from "@/components/ui/form";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { Input } from "@/components/ui/input";
import { useMutation } from "@tanstack/react-query";
import { Smartphone, Loader2, FileKey2 } from "lucide-react";
import { toast } from "sonner";
import { BackupCodeSchema, OtpSchema, OtpSchemaType } from "./2fa.schema";
import { authClient } from "@/lib/auth/auth-client";
import { useState } from "react";
type TwoFactorFormProps = {
onSuccess?: (success: boolean) => void;
onSuccessData?: (data: any) => void;
};
export default function TwoFactorForm({ onSuccess, onSuccessData }: TwoFactorFormProps) {
const [isBackupCodeMode, setIsBackupCodeMode] = useState(false);
const otpForm = useZodForm({
schema: isBackupCodeMode ? BackupCodeSchema : OtpSchema,
defaultValues: {
code: "",
},
});
const { mutate: verifyOtp, isPending: isVerifyingOtp } = useMutation({
mutationFn: async (values: OtpSchemaType) => {
const { data, error } = await authClient.twoFactor.verifyTotp({
code: values.code,
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
toast.success("Authentication successful.");
onSuccess?.(true);
onSuccessData?.(data);
},
onError: (_e) => {
toast.error("Incorrect or expired code.");
otpForm.reset();
onSuccess?.(false);
},
});
const { mutate: verifyBackupCode, isPending: isVerifyingBackupCode } = useMutation({
mutationFn: async (values: OtpSchemaType) => {
const { data, error } = await authClient.twoFactor.verifyBackupCode({
code: values.code,
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
toast.success("Backup code accepted successfully.");
onSuccess?.(true);
onSuccessData?.(data);
},
onError: (_e) => {
toast.error("Invalid backup code.");
otpForm.reset();
onSuccess?.(false);
},
});
const handleSubmit = async (values: OtpSchemaType) => {
if (isBackupCodeMode) {
verifyBackupCode(values);
} else {
verifyOtp(values);
}
};
const isPending = isVerifyingOtp || isVerifyingBackupCode;
return (
<Form form={otpForm} onSubmit={handleSubmit}>
<div className="space-y-6 py-4">
<div className="flex flex-col items-center justify-center gap-4 text-center">
<div className="p-3 bg-neutral-50 text-neutral-600 rounded-full transition-all duration-300">
{isBackupCodeMode ? <FileKey2 className="w-8 h-8 text-amber-600" /> : <Smartphone className="w-8 h-8" />}
</div>
<div className="space-y-1 animate-in fade-in slide-in-from-bottom-2 duration-300">
<h4 className="font-medium text-sm">{isBackupCodeMode ? "Backup Code Authentication" : "Device Authentication"}</h4>
<p className="text-xs text-muted-foreground max-w-[250px] mx-auto">
{isBackupCodeMode ? "Please enter one of your backup codes." : "\"Please enter the verification code generated by your authentication app.\""}
</p>
</div>
</div>
<FormField
control={otpForm.control}
name="code"
render={({ field }) => (
<FormItem className="flex flex-col items-center">
<FormControl>
{isBackupCodeMode ? (
<Input
{...field}
placeholder="XXXXX-XXXXX"
className="text-center tracking-widest font-mono"
autoComplete="off"
autoFocus
onChange={(e) => {
const value = e.target.value;
field.onChange(value);
if (value.replaceAll("-", "").length === 10) {
handleSubmit({ code: value });
}
}}
/>
) : (
<InputOTP
maxLength={6}
{...field}
autoFocus
onChange={(value) => {
field.onChange(value);
if (value.length === 6) {
handleSubmit({ code: value });
}
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} className="uppercase" />
<InputOTPSlot index={1} className="uppercase" />
<InputOTPSlot index={2} className="uppercase" />
<InputOTPSlot index={3} className="uppercase" />
<InputOTPSlot index={4} className="uppercase" />
<InputOTPSlot index={5} className="uppercase" />
</InputOTPGroup>
</InputOTP>
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-col gap-3 pt-2">
<Button type="submit" className="h-11" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Verify
</Button>
<Button
type="button"
variant="link"
className="text-xs text-muted-foreground hover:text-foreground h-auto p-0"
onClick={() => {
otpForm.reset();
setIsBackupCodeMode(!isBackupCodeMode);
}}
>
{isBackupCodeMode ? "Use Authentication App" : "Use Backup Code"}
</Button>
</div>
</div>
</Form>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { zString } from "@/lib/zod";
import { z } from "zod";
export const OtpSchema = z.object({
code: zString().min(6, { message: "Le code doit contenir 6 chiffres" }),
});
export type OtpSchemaType = z.infer<typeof OtpSchema>;
export const BackupCodeSchema = z.object({
code: zString().min(1, "Le code est requis"),
});
export type BackupCodeSchemaType = z.infer<typeof BackupCodeSchema>;
+8
View File
@@ -0,0 +1,8 @@
import z from "zod";
import {zEmail} from "@/lib/zod";
export const EmailSchema = z.object({
email: zEmail(),
});
export type EmailSchemaType = z.infer<typeof EmailSchema>;
@@ -0,0 +1,93 @@
"use client";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {UploadIcon} from "lucide-react";
import {toast} from "sonner";
import {uploadUserImageAction} from "@/features/upload/upload.action";
import {useMutation} from "@tanstack/react-query";
import {updateImageUserAction} from "@/features/profile/avatar.action";
import {useRouter} from "next/navigation";
import {User} from "@/db/schema/02_user";
import React, {ChangeEvent} from "react";
export type AvatarWithUploadProps = {
user: User;
};
export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
const user = props.user;
const router = useRouter();
const submitImage = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.set("file", file);
const result = await uploadUserImageAction(formData);
const inner = result?.data;
if (inner?.success) {
const updateUser = await updateImageUserAction(inner.value ?? "");
const dataUser = updateUser?.data;
if (updateUser?.serverError || !dataUser) {
toast.error(updateUser?.serverError);
return;
}
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
},
});
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const allowedFormats = ["image/jpeg", "image/png", "image/webp"];
if (!allowedFormats.includes(file.type)) {
toast.error("Only JPG, PNG or WebP images are allowed.");
return;
}
const maxSizeInMB = 5;
if (file.size > maxSizeInMB * 1024 * 1024) {
toast.error(`Image must be smaller than ${maxSizeInMB}MB.`);
return;
}
submitImage.mutate(file);
};
return (
<div className="relative ">
<Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20">
<AvatarImage className="object-cover" src={user.image || undefined}/>
<AvatarFallback className="text-3xl">{user.name.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div
onClick={() => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".jpg,.jpeg,.png,.webp";
fileInput.onchange = (e: Event) =>
handleImageUpload(e as unknown as React.ChangeEvent<HTMLInputElement>);
fileInput.click();
}}
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32"
>
<UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary"/>
</div>
</div>
);
};
+15
View File
@@ -0,0 +1,15 @@
"use server";
import { db } from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import { eq } from "drizzle-orm";
import { z } from "zod";
import * as drizzleDb from "@/db";
export const updateImageUserAction = userAction.schema(z.string()).action(async ({ parsedInput, ctx }) => {
const [updatedUser] = await db.update(drizzleDb.schemas.user).set({ image: parsedInput }).where(eq(drizzleDb.schemas.user.id, ctx.user.id)).returning();
return {
data: updatedUser,
};
});
@@ -0,0 +1,72 @@
"use client";
import { Button } from "@/components/ui/button";
import { AlertTriangle, Copy, Download } from "lucide-react";
import { toast } from "sonner";
import { humanReadableDate } from "@/utils/date-formatting";
type BackupCodesListProps = {
codes: string[];
className?: string;
};
export function BackupCodesList({ codes, className }: BackupCodesListProps) {
const handleCopyBackupCodes = () => {
navigator.clipboard.writeText(codes.join("\n"));
toast.success("Backup codes copied to clipboard");
};
if (!codes.length) return null;
const handleDownload = () => {
const header ="Your Backup Codes" + "\n\n";
const content = `Backup Code: ${codes.join("\n")}`;
const footer = "\n\n" + `Keep these codes in a safe place. They can be used to access your account if you lose access to your authentication device. Generated on ${humanReadableDate(new Date())}.`;
const text = header + content + footer;
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `backup-codes.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Backup codes downloaded");
};
return (
<div className={`space-y-4 ${className}`}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">Your Backup Codes</h4>
<Button type="button" variant="ghost" size="sm" onClick={handleCopyBackupCodes} className="h-8 text-xs">
<Copy className="h-3 w-3 mr-2" />
Copy All Codes
</Button>
</div>
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm border">
{codes.map((code, i) => (
<div key={i} className="text-center tracking-wider">
{code}
</div>
))}
</div>
<p className="text-xs text-muted-foreground mt-2 flex items-start gap-1.5">
<AlertTriangle className="w-3 h-3 mt-0.5 text-amber-500 shrink-0" />
<span>
These codes can only be used once. After using a code, make sure to generate new backup codes to maintain account security.
</span>
</p>
<Button type="button" variant="ghost" size="sm" onClick={handleDownload} className="h-8 text-xs">
<Download className="mr-2 h-4 w-4" />
Download Backup Codes
</Button>
</div>
</div>
);
}
+122
View File
@@ -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 "./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>
);
}
+9
View File
@@ -0,0 +1,9 @@
import z from "zod";
import {zString} from "@/lib/zod";
export const ProfileSchema = z.object({
name: zString().nonempty(),
role: zString().nonempty(),
});
export type ProfileSchemaType = z.infer<typeof ProfileSchema>;
+152
View File
@@ -0,0 +1,152 @@
"use client";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {AlertCircle, Loader2} from "lucide-react";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {authClient} from "@/lib/auth/auth-client";
import {User} from "@/db/schema/02_user";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {EmailSchema, EmailSchemaType} from "./account.schema";
import {BetterAuthError} from "@/types/auth";
interface ProfileAccountProps {
user: User;
}
export function ProfileAccount({user}: ProfileAccountProps) {
const router = useRouter();
const emailForm = useZodForm({
schema: EmailSchema,
defaultValues: {
email: user.email,
},
});
const {mutate: updateEmail, isPending: isUpdatingEmail} = useMutation({
mutationFn: async (values: EmailSchemaType) => {
const {error} = await authClient.changeEmail({
newEmail: values.email,
callbackURL: window.location.href,
});
if (error) throw error;
return values.email;
},
onSuccess: (newEmail) => {
toast.success("Email updated successfully.");
emailForm.reset({email: newEmail});
router.refresh();
},
onError: (error: BetterAuthError) => {
if (error.code === "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL") {
toast.error("User already exists, use another email address!");
emailForm.reset({email: user.email});
router.refresh()
} else {
toast.error("An error occurred while trying to update your password!");
}
},
});
const {mutate: resendVerificationEmail, isPending: isResendingVerification} = useMutation({
mutationFn: async () => {
const currentEmailInput = emailForm.getValues("email");
let error: BetterAuthError | null = null;
if (currentEmailInput === user.email) {
const sendVerification = await authClient.sendVerificationEmail({
email: currentEmailInput,
callbackURL: window.location.href,
});
error = sendVerification.error;
} else {
const result = await authClient.changeEmail({
callbackURL: window.location.href,
newEmail: currentEmailInput,
});
error = result.error;
}
if (error) throw error;
},
onSuccess: () => {
toast.success("Verification email resent successfully.");
},
onError: (_e: BetterAuthError) => {
toast.error("Failed to resend verification email.");
},
});
return (
<div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">Account Settings</h2>
<p className="text-sm text-muted-foreground">Update your email and preferences.</p>
</div>
<div className="space-y-4">
<Form form={emailForm} onSubmit={(values) => updateEmail(values)}>
<div className="grid gap-3">
<FormField
control={emailForm.control}
name="email"
render={({field}) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<div className="flex flex-col gap-3">
<div className="flex flex-col sm:flex-row gap-3 max-w-xl">
<FormControl>
<Input {...field} placeholder="Your email address"/>
</FormControl>
<div className="flex flex-col md:flex-row gap-3">
<Button type="submit" variant="secondary"
disabled={isUpdatingEmail || !emailForm.formState.isDirty}>
{isUpdatingEmail &&
<Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
{"Update"}
</Button>
{!user.emailVerified && (
<Button
type="button"
variant="secondary"
onClick={() => resendVerificationEmail()}
disabled={isResendingVerification || emailForm.formState.errors.email !== undefined}
>
{isResendingVerification &&
<Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
{"Resend Verification"}
</Button>
)}
</div>
</div>
<FormMessage/>
</div>
</FormItem>
)}
/>
{!user.emailVerified && (
<div
className="flex items-center gap-2 text-sm text-amber-600 bg-amber-50 p-2 rounded-md border border-amber-100 dark:bg-amber-950/30 dark:border-amber-900 dark:text-amber-400 max-w-xl">
<AlertCircle className="w-4 h-4"/>
<span>Your email is not verified. Please check your inbox.</span>
</div>
)}
</div>
</Form>
</div>
</div>
);
}
@@ -0,0 +1,96 @@
"use client";
import React from "react";
import {cn} from "@/lib/utils";
import {useTheme} from "next-themes";
import {authClient} from "@/lib/auth/auth-client";
const themes = [{value: "light"}, {value: "dark"}, {value: "system"}];
export function ProfileAppearance() {
return (
<div className="space-y-8 animate-in fade-in-50 duration-300 ">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">Appearance Settings</h2>
<p className="text-sm text-muted-foreground">Customize the look and feel of your dashboard.</p>
</div>
<ThemeSelector/>
</div>
);
}
function ThemeSelector() {
const {theme} = useTheme();
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-3xl">
{themes.map((item) => {
const isDark = item.value === "dark";
const isSystem = item.value === "system";
const isActive = theme === item.value;
return (
<div
key={item.value}
className={cn(
"border-2 rounded-xl p-1 cursor-pointer transition-all hover:bg-accent/50 space-y-2",
isActive ? "border-primary bg-primary/5" : "border-muted/40"
)}
onClick={async () => {
await authClient.updateUser({theme: item.value});
}}
>
<div
className={cn(
"p-2 rounded-lg aspect-[4/3] flex flex-col gap-2 relative overflow-hidden border",
isDark ? "bg-slate-950 border-slate-800" : "bg-white border-slate-200",
isSystem && "bg-gradient-to-br from-white to-slate-950"
)}
>
<div
className={cn("h-3 w-full rounded-sm shadow-sm opacity-80", isDark ? "bg-slate-800" : "bg-slate-100")}/>
<div className="flex gap-2 flex-1 relative">
<div
className={cn("w-1/4 h-full rounded-sm shadow-sm opacity-80", isDark ? "bg-slate-800" : "bg-slate-100")}/>
<div className="flex-1 flex flex-col gap-2">
<div
className={cn("h-3 w-full rounded-sm shadow-sm opacity-80", isDark ? "bg-slate-800" : "bg-slate-100")}/>
<div
className={cn("flex-1 rounded-sm shadow-sm p-1 space-y-2 opacity-50", isDark ? "bg-slate-800" : "bg-slate-100")}>
<div
className={cn("h-2 w-3/4 rounded-full", isDark ? "bg-slate-700" : "bg-slate-300")}/>
<div
className={cn("h-2 w-full rounded-full", isDark ? "bg-slate-700" : "bg-slate-300")}/>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-1 px-2">
<span className="font-medium text-sm">{THEME_TEXT[item.value as ThemeKey]}</span>
<div
className={cn(
"w-4 h-4 rounded-full border flex items-center justify-center transition-all",
isActive ? "border-primary bg-primary" : "border-muted-foreground/30"
)}
>
{isActive && <div className="w-1.5 h-1.5 rounded-full bg-primary-foreground"/>}
</div>
</div>
</div>
);
})}
</div>
);
}
type ThemeKey = "dark" | "light" | "system";
const THEME_TEXT: Record<ThemeKey, string> = {
dark: "Dark",
light: "Light",
system: "System",
};
+130
View File
@@ -0,0 +1,130 @@
"use client";
import React from "react";
import {Button} from "@/components/ui/button";
import {Input} from "@/components/ui/input";
import {Badge} from "@/components/ui/badge";
import {Loader2} from "lucide-react";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {updateProfileSettingsAction} from "./profile.action";
import {User} from "@/db/schema/02_user";
import {ProfileSchema, ProfileSchemaType} from "./general.schema";
import {AvatarWithUpload} from "@/features/profile/avatar-with-upload";
interface ProfileGeneralProps {
user: User;
}
export function ProfileGeneral({user}: ProfileGeneralProps) {
const router = useRouter();
const profileForm = useZodForm({
schema: ProfileSchema,
defaultValues: {
name: user.name || "",
role: user.role || "",
},
});
const {mutate: updateProfile, isPending: isUpdatingProfile} = useMutation({
mutationFn: async (values: ProfileSchemaType) => {
const result = await updateProfileSettingsAction({name: values.name});
const inner = result?.data;
if (inner?.success) {
toast.success("Profile updated successfully.");
router.refresh();
profileForm.reset({name: values.name, role: values.role});
} else {
toast.error("Failed to update profile.");
}
},
});
return (
<div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">Profile Settings</h2>
<p className="text-sm text-muted-foreground">Manage your personal information and preferences.</p>
</div>
<div className="flex flex-col sm:flex-row gap-8 items-start">
<div className="flex flex-col items-center gap-4">
<AvatarWithUpload
user={user}
/>
</div>
<div className="flex-1 w-full max-w-lg">
<Form form={profileForm} onSubmit={(values) => updateProfile(values)}>
<div className="space-y-6">
<FormField
control={profileForm.control}
name="name"
render={({field}) => (
<FormItem>
<FormLabel>Display Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>This is your public display name</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<div className="grid gap-2">
<FormField
control={profileForm.control}
name={"role"}
render={({field}) => (
<FormItem>
<FormLabel
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
Role & Permissions
</FormLabel>
<FormControl>
<div className="flex items-center gap-2 pt-1">
<RoleBadge role={field.value || "undefined"}/>
</div>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isUpdatingProfile || !profileForm.formState.isDirty}>
{isUpdatingProfile && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
Update
</Button>
</div>
</div>
</Form>
</div>
</div>
</div>
);
}
function RoleBadge({role}: { role: string }) {
const variant = role === "admin" ? "default" : "secondary";
return (
<Badge variant={variant} className="capitalize px-2 py-0.5 text-xs">
{role}
</Badge>
);
}
+234
View File
@@ -0,0 +1,234 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Loader2, AlertTriangle } from "lucide-react";
import { authClient } from "@/lib/auth/auth-client";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { SetPasswordProfileProviderModal } from "./set-password-modal";
import { Icon } from "@iconify/react";
import Image from "next/image";
import type { AuthProviderConfig } from "@/lib/auth/config";
import type { Account } from "@/db/schema/02_user";
interface ProfileProviderProps {
accounts: Account[];
providers: AuthProviderConfig[];
}
export function ProfileProviders({
accounts,
providers,
}: ProfileProviderProps) {
const router = useRouter();
const totalConnected = accounts.length;
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const { mutate: unlinkAccount } = useMutation({
mutationFn: async (providerId: string) => {
setLoadingProvider(providerId);
const { error } = await authClient.unlinkAccount({ providerId });
if (error) throw error;
},
onSuccess: () => {
toast.success("Provider successfully unlinked!");
setLoadingProvider(null);
router.refresh();
},
onError: () => {
toast.error("An error occurred while unlinking provider.");
setLoadingProvider(null);
},
});
const { mutate: linkAccount } = useMutation({
mutationFn: async (provider: AuthProviderConfig) => {
setLoadingProvider(provider.id);
if (provider.type === "social") {
await authClient.linkSocial({
provider: provider.id as string,
callbackURL: "/dashboard",
});
}
},
onSuccess: () => {
setLoadingProvider(null);
router.refresh();
},
onError: () => {
toast.error("An error occurred while linking provider.");
setLoadingProvider(null);
},
});
const enterpriseProviders = providers.filter(
(p) => p.type === "sso" && p.id !== "passkey" && p.type !== "credential",
);
const otherProviders = providers.filter(
(p) => p.type !== "sso" && p.id !== "passkey" && p.type !== "credential",
);
const renderProvider = (provider: AuthProviderConfig) => {
const linkedAccount = accounts.find(
(acc) => acc.providerId === provider.id,
);
const isConnected = !!linkedAccount;
const canUnlink =
totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
const isLoading = loadingProvider === provider.id;
const isUnlinkDisabled =
!canUnlink ||
provider.allowUnlinking === false ||
provider.type === "sso";
const unlinkButton = (
<Button
variant="outline"
size="sm"
onClick={() => unlinkAccount(provider.id)}
disabled={isUnlinkDisabled || isLoading || provider.isManual}
className={isUnlinkDisabled ? "opacity-50 cursor-not-allowed" : ""}
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Unlink"}
</Button>
);
let actionElement;
if (provider.type === "sso") {
actionElement = null;
} else if (!isConnected) {
actionElement =
provider.id === "credential" ? (
<SetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
) : (
<Button
variant="default"
size="sm"
onClick={() => linkAccount(provider)}
disabled={
isLoading || provider.isManual || provider.allowLinking === false
}
>
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : "Link"}
</Button>
);
} else if (isUnlinkDisabled) {
actionElement = (
<Tooltip>
<TooltipTrigger asChild>
<div tabIndex={0} className="inline-block">
{unlinkButton}
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{provider.allowUnlinking === false
? "Unlinking is disabled for this provider."
: "You cannot unlink your last authentication provider."}
</p>
</TooltipContent>
</Tooltip>
);
} else {
actionElement = unlinkButton;
}
return (
<div
key={provider.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
{provider.icon.startsWith("/") ||
provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id}
width={20}
height={20}
className="w-5 h-5"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="w-5 h-5" />
)}
</div>
<div className="space-y-0.5">
<div className="font-medium flex items-center gap-2">
{provider.title || provider.name}
{isConnected && (
<Badge
variant="secondary"
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"
>
Active
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
{provider.description}
</div>
</div>
</div>
<div className="flex items-center gap-2">{actionElement}</div>
</div>
);
};
return (
<div className="space-y-8 animate-in fade-in-50 duration-300 pb-10">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
Authentication
</h2>
<p className="text-sm text-muted-foreground">
Manage how you access your account.
</p>
</div>
{enterpriseProviders.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Enterprise Connection
</h3>
<div className="grid gap-4">
{enterpriseProviders.map(renderProvider)}
</div>
</div>
)}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Standard Connections
</h3>
<div className="grid gap-4">{otherProviders.map(renderProvider)}</div>
</div>
<Alert variant={"default"}>
<AlertTriangle className="w-5 h-5 shrink-0 mt-0.5" />
<AlertDescription>
Linked providers allow you to log in to your account using any of
these methods.
</AlertDescription>
</Alert>
</div>
);
}
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, useZodForm } from "@/components/ui/form";
import { Loader2 } from "lucide-react";
import { PasswordStrengthInput } from "@/components/ui/password-input-indicator";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { authClient } from "@/lib/auth/auth-client";
import { ResetPasswordSecuritySchema, ResetPasswordSecuritySchemaType } from "./security.schema";
import {PasswordInput} from "@/components/ui/password-input";
type ResetPasswordFormProps = {
onSuccess?: () => void;
isDefault?: boolean;
};
export default function ResetPasswordForm({ onSuccess, isDefault }: ResetPasswordFormProps) {
const router = useRouter();
const [allowConfirmPassword, setAllowConfirmPassword] = useState(false);
const form = useZodForm({
schema: ResetPasswordSecuritySchema,
});
const { mutate: changePassword, isPending: isChangingPassword } = useMutation({
mutationFn: async (values: ResetPasswordSecuritySchemaType) => {
const { error } = await authClient.changePassword({
currentPassword: values.currentPassword,
newPassword: values.newPassword,
revokeOtherSessions: true,
});
// await authClient.updateUser({
// isDefaultPassword: false,
// });
if (error) throw error;
},
onSuccess: () => {
toast.success("Password reset successfully.");
form.reset();
router.refresh();
setAllowConfirmPassword(false);
if (onSuccess) {
onSuccess();
}
},
onError: () => {
toast.error("Failed to reset password.");
},
});
return (
<Form
form={form}
onSubmit={async (values) => {
changePassword(values);
}}
>
{!isDefault && (
<Alert>
<AlertDescription>After resetting your password, you will be logged out of all devices.</AlertDescription>
</Alert>
)}
<div className="space-y-4 py-4">
<FormField
control={form.control}
name="currentPassword"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Current password</FormLabel>
<FormControl>
<PasswordInput placeholder={'Fill your current password'} {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
defaultValue=""
render={({ field }) => (
<FormItem>
<PasswordStrengthInput
label={"New password"}
field={field}
onValidChange={(valid) => {
setAllowConfirmPassword(valid);
}}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormControl>
<PasswordStrengthInput label={"Confirm your password"} field={field} disabled={!allowConfirmPassword} />
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end pt-4">
<Button disabled={isChangingPassword} type="submit">
{isChangingPassword && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit
</Button>
</div>
</div>
</Form>
);
}
+493
View File
@@ -0,0 +1,493 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import {
Globe,
LogOut,
Loader2,
Fingerprint,
Trash2,
Plus,
} from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import {
revokeAllSessionsAction,
revokeSessionAction,
getPasskeysAction,
revokePasskeyAction,
} from "./security.action";
import { useRouter } from "next/navigation";
import { ResetPasswordProfileProviderModal } from "./reset-password-modal";
import { SetPasswordProfileProviderModal } from "./set-password-modal";
import { Setup2FAProfileProviderModal } from "./setup-2fa-modal";
import { Disable2FAProfileProviderModal } from "./disable-2fa-modal";
import { ViewBackupCodesModal } from "./view-backup-codes-modal";
import { getDeviceDetails } from "@/utils/detection";
import { timeAgo } from "@/utils/date-formatting";
import { authClient } from "@/lib/auth/auth-client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Account, Session, User } from "@/db/schema/02_user";
import { Icon } from "@iconify/react";
import Image from "next/image";
import type { AuthProviderConfig } from "@/lib/auth/config";
import { is } from "date-fns/locale";
interface ProfileSecurityProps {
user: User;
sessions: Session[];
credentialAccount: Account;
currentSession: Session;
isPasswordEnabled?: boolean;
isPasskeyEnabled?: boolean;
providers: AuthProviderConfig[];
}
export function ProfileSecurity({
user,
sessions,
credentialAccount,
currentSession,
isPasswordEnabled = false,
isPasskeyEnabled = false,
providers,
}: ProfileSecurityProps) {
const router = useRouter();
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false);
const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false);
const [isAddPasskeyOpen, setIsAddPasskeyOpen] = useState(false);
const [passkeyName, setPasskeyName] = useState("");
const { mutate: revokeSession, isPending: isRevoking } = useMutation({
mutationFn: async (token: string) => {
const result = await revokeSessionAction({ token });
const inner = result?.data;
if (inner?.success) {
toast.success("Session successfully revoked");
router.refresh();
} else {
toast.error("An error occurred while revoking session");
}
},
});
const { mutate: revokeOthers, isPending: isRevokingOthers } = useMutation({
mutationFn: async () => {
const result = await revokeAllSessionsAction();
const inner = result?.data;
if (inner?.success) {
toast.success("Revoking all sessions successfully done.");
router.refresh();
} else {
toast.error("An error occurred while revoking all sessions");
}
},
});
const {
data: passkeys,
isLoading: isLoadingPasskeys,
refetch: refetchPasskeys,
} = useQuery({
queryKey: ["passkeys"],
queryFn: async () => {
const result = await getPasskeysAction();
if (result?.data?.success) {
return result.data.value;
}
throw new Error("Failed to fetch passkeys");
},
});
const { mutate: revokePasskey, isPending: isRevokingPasskey } = useMutation({
mutationFn: async (id: string) => {
const result = await revokePasskeyAction({ id });
if (!result?.data?.success) {
throw new Error("Failed to revoke passkey");
}
},
onSuccess: () => {
toast.success("Passkey revoked successfully");
refetchPasskeys();
},
onError: () => {
toast.error("Failed to revoke passkey");
},
});
const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
mutationFn: async () => {
const result = await authClient.passkey.addPasskey({
name: passkeyName || "My Passkey",
});
if (result?.error) {
throw result.error;
}
return result;
},
onSuccess: () => {
toast.success("Passkey added successfully");
setIsAddPasskeyOpen(false);
setPasskeyName("");
refetchPasskeys();
},
onError: (error: any) => {
toast.error(error.message || "Failed to add passkey");
},
});
return (
<div className="space-y-8 animate-in fade-in-50 duration-300">
<div className="mb-6 space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
Security Settings
</h2>
<p className="text-sm text-muted-foreground">
Manage your password, two-factor authentication and sessions.
</p>
</div>
{(isPasskeyEnabled || isPasswordEnabled || user.twoFactorEnabled) && (
<div className="space-y-6">
<h3 className="text-lg font-medium">Authentication</h3>
<div className="border rounded-lg p-4 space-y-4">
{isPasswordEnabled && (
<>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="font-medium">Password</div>
<div className="text-sm text-muted-foreground">
{user.lastChangedPasswordAt
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
: "Never changed"}
</div>
</div>
{credentialAccount ? (
<ResetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
) : (
<SetPasswordProfileProviderModal
open={isPasswordDialogOpen}
onOpenChange={setIsPasswordDialogOpen}
/>
)}
</div>
<Separator />
</>
)}
{isPasswordEnabled && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="font-medium">Two-Factor Authentication</div>
{user.twoFactorEnabled && (
<Badge
variant="secondary"
className="text-[10px] h-5 px-1.5 text-green-600 bg-green-500/10 border-0"
>
Active
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
Enhance the security of your account by requiring a second
form of verification during login.
</div>
</div>
{user.twoFactorEnabled ? (
<div className="flex flex-col items-center gap-2">
<ViewBackupCodesModal
open={isBackupCodesDialogOpen}
onOpenChange={setIsBackupCodesDialogOpen}
/>
<Disable2FAProfileProviderModal
open={isDisable2FADialogOpen}
onOpenChange={setIsDisable2FADialogOpen}
/>
</div>
) : (
<Setup2FAProfileProviderModal
disabled={!credentialAccount}
open={isSetup2FADialogOpen}
onOpenChange={setIsSetup2FADialogOpen}
/>
)}
</div>
)}
</div>
</div>
)}
{isPasskeyEnabled && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="text-lg font-medium">Passkeys</h3>
<div className="text-sm text-muted-foreground">
Login securely with your fingerprint, face recognition, or
hardware key.
</div>
</div>
<Dialog open={isAddPasskeyOpen} onOpenChange={setIsAddPasskeyOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Add Passkey
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add New Passkey</DialogTitle>
<DialogDescription>
Create a name for your passkey to identify it later.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Passkey Name</Label>
<Input
id="name"
placeholder="e.g. MacBook Pro, iPhone, YubiKey"
value={passkeyName}
onChange={(e) => setPasskeyName(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddPasskeyOpen(false)}
>
Cancel
</Button>
<Button
onClick={() => addPasskey()}
disabled={isAddingPasskey}
>
{isAddingPasskey && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Passkey
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="border rounded-lg divide-y">
{isLoadingPasskeys ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : passkeys && passkeys.length > 0 ? (
passkeys.map((pk: any) => (
<PasskeyRow
key={pk.id}
passkey={pk}
onRevoke={(id) => revokePasskey(id)}
isRevoking={isRevokingPasskey}
/>
))
) : (
<div className="p-4 text-center text-muted-foreground">
No passkeys found.
</div>
)}
</div>
</div>
)}
<div className="space-y-6 pb-10">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Active Sessions</h3>
{sessions && sessions.length > 1 && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => revokeOthers()}
disabled={isRevokingOthers || (sessions?.length || 0) <= 1}
>
{isRevokingOthers && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Revoke All
</Button>
)}
</div>
<div className="border rounded-lg divide-y">
{sessions && sessions.length > 0 ? (
sessions?.map((session) => (
<SessionRow
key={session.id}
session={session}
onRevoke={(token) => revokeSession(token)}
isRevoking={isRevoking}
currentSession={currentSession}
providers={providers}
/>
))
) : (
<div className="p-4 text-center text-muted-foreground">
No active sessions found.
</div>
)}
</div>
</div>
</div>
);
}
function SessionRow({
session,
onRevoke,
isRevoking,
currentSession,
providers,
}: {
session: Session;
onRevoke: (token: string) => void;
isRevoking: boolean;
currentSession: Session;
providers: AuthProviderConfig[];
}) {
const deviceInfo = getDeviceDetails(session.userAgent);
const provider = providers.find((p) => p.id === (session as any).providerId);
return (
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground relative">
<deviceInfo.Icon className="w-5 h-5" />
{provider && (
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-background border flex items-center justify-center overflow-hidden">
{provider.icon.startsWith("/") ||
provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.id}
width={12}
height={12}
className="w-3 h-3"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="w-3 h-3" />
)}
</div>
)}
</div>
<div className="space-y-0.5">
<div className="text-sm font-medium flex items-center gap-2">
{deviceInfo.os}{" "}
<span className="text-muted-foreground font-normal">
{deviceInfo.browser}
</span>
{provider && (
<span className="text-muted-foreground font-normal">
{provider.title || provider.name}
</span>
)}
{session.id === currentSession.id && (
<Badge
variant="outline"
className="text-[10px] h-5 px-1.5 text-sky-600 bg-sky-50 border-sky-200 dark:bg-sky-900/20 dark:border-sky-800 dark:text-sky-400"
>
This device
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Globe className="w-3 h-3" /> {session.ipAddress}
<span className="ml-1">
{session.id === currentSession.id
? "Active now"
: `Last active ${timeAgo(new Date(session.createdAt))}`}
</span>
</div>
</div>
</div>
{session.id !== currentSession.id && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(session.token)}
disabled={isRevoking}
>
{isRevoking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogOut className="w-4 h-4" />
)}
<span className="sr-only">Revoke</span>
</Button>
)}
</div>
);
}
function PasskeyRow({
passkey,
onRevoke,
isRevoking,
}: {
passkey: any;
onRevoke: (id: string) => void;
isRevoking: boolean;
}) {
return (
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground">
<Fingerprint className="w-5 h-5" />
</div>
<div className="space-y-0.5">
<div className="font-medium text-sm">
{passkey.name || "Unnamed Passkey"}
</div>
<div className="text-xs text-muted-foreground">
Created {timeAgo(new Date(passkey.createdAt))}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => onRevoke(passkey.id)}
disabled={isRevoking}
>
{isRevoking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
<span className="sr-only">Revoke</span>
</Button>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use server";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import { ServerActionResult } from "@/types/action-type";
import { z } from "zod";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/auth";
import { user } from "@/db/schema/02_user";
import {userAction} from "@/lib/safe-actions/actions";
const UpdateProfileSchema = z.object({
name: z.string().optional(),
});
export const updateProfileSettingsAction = userAction.schema(UpdateProfileSchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return {
success: false,
actionError: {
message: "unauthorized",
cause: "User not authenticated",
},
};
}
await db
.update(user)
.set({
...(parsedInput.name ? { name: parsedInput.name } : {}),
})
.where(eq(user.id, session.user.id));
return {
success: true,
value: {},
actionSuccess: {
message: "profile_updated",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_updating_profile",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
+53
View File
@@ -0,0 +1,53 @@
"use server";
import { ServerActionResult } from "@/types/action-type";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/auth";
import z from "zod";
import { zPassword } from "@/lib/zod";
import {userAction} from "@/lib/safe-actions/actions";
export const linkPasswordProfileProviderAction = userAction
.schema(
z.object({
password: zPassword(),
})
)
.action(async ({ parsedInput }): Promise<ServerActionResult<null>> => {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return {
success: false,
actionError: {
message: "unauthorized",
cause: "User not authenticated",
},
};
}
await auth.api.setPassword({
headers: await headers(),
body: {
newPassword: parsedInput.password,
},
});
return {
success: true,
actionSuccess: {
message: "profile_updated",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_updating_profile",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { zPassword } from "@/lib/zod";
import z from "zod";
export const PasswordProviderSchema = z.object({
password: zPassword(),
confirmPassword: zPassword(),
});
export type PasswordProviderSchemaType = z.infer<typeof PasswordProviderSchema>;
@@ -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 "./profile-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>
);
}
+149
View File
@@ -0,0 +1,149 @@
"use server";
import { ServerActionResult } from "@/types/action-type";
import { z } from "zod";
import { headers } from "next/headers";
import { auth, getPasskeys, revokePasskey } from "@/lib/auth/auth";
import { userAction } from "@/lib/safe-actions/actions";
const RevokeSessionSchema = z.object({
token: z.string(),
});
const RevokePasskeySchema = z.object({
id: z.string(),
});
export const revokeSessionAction = userAction.schema(RevokeSessionSchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return {
success: false,
actionError: {
message: "unauthorized",
cause: "User not authenticated",
},
};
}
await auth.api.revokeSession({
body: {
token: parsedInput.token,
},
headers: await headers(),
});
return {
success: true,
value: {},
actionSuccess: {
message: "session_revoked",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_revoking_session",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const revokeAllSessionsAction = userAction.action(async (): Promise<ServerActionResult<{}>> => {
try {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return {
success: false,
actionError: {
message: "unauthorized",
cause: "User not authenticated",
},
};
}
const sessions = await auth.api.listSessions({
headers: await headers(),
});
const otherSessions = sessions.filter((s) => s.token !== session.session.token);
for (const s of otherSessions) {
await auth.api.revokeSession({
body: {
token: s.token,
},
headers: await headers(),
});
}
return {
success: true,
value: {},
actionSuccess: {
message: "other_sessions_revoked",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_revoking_other_sessions",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const getPasskeysAction = userAction.action(async (): Promise<ServerActionResult<any[]>> => {
try {
const passkeys = await getPasskeys();
return {
success: true,
value: passkeys || [],
actionSuccess: {
message: "passkeys_fetched",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_fetching_passkeys",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
export const revokePasskeyAction = userAction.schema(RevokePasskeySchema).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
try {
await revokePasskey(parsedInput.id);
return {
success: true,
value: {},
actionSuccess: {
message: "passkey_revoked",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "error_revoking_passkey",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
+28
View File
@@ -0,0 +1,28 @@
"use client";
import z from "zod";
import {zPassword} from "@/lib/zod";
export const ResetPasswordSecuritySchema = z
.object({
currentPassword: zPassword(),
newPassword: zPassword(),
confirmPassword: zPassword(),
})
.superRefine(({ confirmPassword, newPassword }, ctx) => {
if (confirmPassword !== newPassword) {
ctx.addIssue({
code: "custom",
message: "New password does not match",
path: ["confirmPassword"],
});
}
});
export type ResetPasswordSecuritySchemaType = z.infer<typeof ResetPasswordSecuritySchema>;
export const Setup2FASecuritySchema = z.object({
code: z.string().min(6, "Code need to contain at least 6 characters"),
});
export type Setup2FASecuritySchemaType = z.infer<typeof Setup2FASecuritySchema>;
@@ -0,0 +1,92 @@
"use client";
import {Button} from "@/components/ui/button";
import {Form, FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/components/ui/form";
import {Loader2} from "lucide-react";
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {PasswordProviderSchema, PasswordProviderSchemaType} from "./provider.schema";
import {linkPasswordProfileProviderAction} from "./provider.action";
import {PasswordInput} from "@/components/ui/password-input";
type SetPasswordFormProps = {
onSuccess?: () => void;
};
export default function SetPasswordForm({onSuccess}: SetPasswordFormProps) {
const router = useRouter();
const form = useZodForm({
schema: PasswordProviderSchema,
});
const {mutateAsync: setPasswordMutation, isPending: isSettingPassword} = useMutation({
mutationFn: async (values: PasswordProviderSchemaType) => {
const result = await linkPasswordProfileProviderAction({
password: values.password,
});
return result?.data;
},
onSuccess: (data) => {
if (data?.success) {
toast.success("Password set successfully.");
form.reset();
router.refresh();
if (onSuccess) {
onSuccess();
}
} else {
toast.error("Failed to set password.");
}
},
onError: () => {
toast.error("Failed to set password.");
},
});
return (
<Form
form={form}
onSubmit={async (values) => {
await setPasswordMutation(values);
}}
>
<div className="space-y-4 py-4">
<FormField
control={form.control}
name="password"
defaultValue=""
render={({field}) => (
<FormItem>
<PasswordStrengthInput label={"Password"} field={field}/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Confirm password</FormLabel>
<FormControl>
<PasswordInput placeholder={"Confirm your new password"} {...field} />
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end pt-4">
<Button disabled={isSettingPassword} type="submit">
{isSettingPassword && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
Set password
</Button>
</div>
</div>
</Form>
);
}
@@ -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 "./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>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import React, {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 "./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 "./backup-codes-list";
import {PasswordInput} from "@/components/ui/password-input";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
const PasswordSchema = z.object({
password: zPassword(),
});
type Password = z.infer<typeof PasswordSchema>;
type Setup2FAModalProps = {
onOpenChange: (open: boolean) => void;
open: boolean;
disabled: boolean;
};
export function Setup2FAProfileProviderModal({onOpenChange, open, disabled}: 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 disabled={disabled}>
<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 "./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>
);
}