mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user