mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86f50e5741 | ||
|
|
a6e52fd03d | ||
|
|
116229a29e | ||
|
|
428782e8a4 |
Binary file not shown.
@@ -1,15 +1,23 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Metadata} from "next";
|
||||
import {ForgotPasswordForm} from "@/components/wrappers/auth/forgot-password/forgot-password-form";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Forgot Password",
|
||||
};
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ForgotPasswordForm } from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form";
|
||||
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) {
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<ForgotPasswordForm/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<TooltipProvider>
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Reset password</h1>
|
||||
<p className="text-balance text-muted-foreground">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ForgotPasswordForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { GuardForm } from "@/components/wrappers/auth/guard/guard-form";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function GuardPage() {
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("better-auth.two_factor")?.value;
|
||||
|
||||
if (!token) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Two-factor verification</h1>
|
||||
<p className="text-balance text-muted-foreground">Please enter the verification code generated by your authentication app.</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GuardForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,55 @@
|
||||
import {env} from "@/env.mjs";
|
||||
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
|
||||
import {Metadata} from "next";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
||||
import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login",
|
||||
};
|
||||
|
||||
export default async function SignInPage() {
|
||||
const authGoogleEnabled = env.AUTH_GOOGLE_METHOD;
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<LoginForm authGoogleEnabled={authGoogleEnabled}/>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Login</h1>
|
||||
<p className="text-balance text-muted-foreground">Fill your login informations</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<LoginForm />
|
||||
|
||||
{SUPPORTED_PROVIDERS.filter((p) => !p.isManual).length > 0 && (
|
||||
<>
|
||||
|
||||
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||
<Separator/>
|
||||
<div className="px-2 text-center bg-card text-sm">OR</div>
|
||||
<Separator/>
|
||||
</div>
|
||||
|
||||
<SocialAuthButtons />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account ?{" "}
|
||||
<Link href="/register" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Page, PageContent, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {UserForm} from "@/components/wrappers/dashboard/profile/user-form/user-form";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/avatar/avatar-with-upload";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getAccounts, getSessions} from "@/lib/auth/auth";
|
||||
import {Metadata} from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Profile",
|
||||
};
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (user.role !== "user" && user.role !== "admin" && user.role !== "superadmin") {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const sessions = await getSessions();
|
||||
const accounts = await getAccounts();
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex items-center">
|
||||
<AvatarWithUpload
|
||||
user={{
|
||||
...user,
|
||||
image: user.image ?? null,
|
||||
role: user.role ?? null,
|
||||
banned: user.banned ?? null,
|
||||
banReason: user.banReason ?? null,
|
||||
banExpires: user.banExpires ?? null,
|
||||
deletedAt: user.deletedAt ? new Date(user.deletedAt) : null,
|
||||
}}
|
||||
/>
|
||||
{user.name}
|
||||
<Badge className="ml-3 hidden lg:block">{user.role}</Badge>
|
||||
</PageTitle>
|
||||
</div>
|
||||
<PageContent>
|
||||
<UserForm
|
||||
userId={user.id}
|
||||
sessions={sessions}
|
||||
accounts={accounts}
|
||||
defaultValues={{
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role ?? undefined,
|
||||
}}
|
||||
/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {Database} from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db as dbClient} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {SafeActionResult} from "next-safe-action";
|
||||
import {ZodString} from "zod";
|
||||
@@ -66,10 +66,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
|
||||
const [databaseUpdated] = await dbClient
|
||||
.update(drizzleDb.schemas.database)
|
||||
.set({
|
||||
.set(withUpdatedAt({
|
||||
name: db.name,
|
||||
agentId: agent.id,
|
||||
lastContact: lastContact
|
||||
})
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -41,6 +41,11 @@ const nextConfig: NextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "50mb",
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-email": "^4.0.13",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"react-qr-code": "^2.0.18",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"react-twc": "^1.4.2",
|
||||
"react-use-measure": "^2.1.7",
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
import {Chrome, KeyRound, LucideIcon} from "lucide-react";
|
||||
|
||||
|
||||
export interface AuthProviderConfig {
|
||||
id: "google" | "github" | "credential";
|
||||
icon: LucideIcon;
|
||||
isManual?: boolean;
|
||||
credentials?: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
SECURITY: {
|
||||
@@ -62,3 +75,35 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
||||
{
|
||||
id: "google",
|
||||
icon: Chrome,
|
||||
credentials: {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
|
||||
},
|
||||
},
|
||||
/*
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
icon: Github,
|
||||
description: "Connexion via GitHub",
|
||||
credentials: {
|
||||
clientId: process.env.GITHUB_CLIENT_ID || "",
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET || "",
|
||||
},
|
||||
},*/
|
||||
{
|
||||
id: "credential",
|
||||
icon: KeyRound,
|
||||
isManual: true,
|
||||
credentials: {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import EmailLayout from "@/components/emails/email-layout";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
oldEmail?: string;
|
||||
newEmail?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const EmailVerification = ({firstname, oldEmail, newEmail, url: urlVerification}: EmailCreateUserProps) => {
|
||||
const serverUrl = new URL(getServerUrl());
|
||||
const url = new URL(urlVerification);
|
||||
url.hostname = serverUrl.hostname;
|
||||
url.port = serverUrl.port == "80" ? "" : serverUrl.port;
|
||||
const newUrl = url.toString();
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase email verification">
|
||||
<Heading className="mx-0 my-[30px] p-0 text-center font-normal text-[24px] text-black">
|
||||
Hello <strong>{firstname}</strong>,
|
||||
</Heading>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
We received a request to change the email address associated with your account.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
If you did not request this change, please ignore this email. Your current email address will remain
|
||||
unchanged. If this seems suspicious, contact your administrator.
|
||||
</Text>
|
||||
|
||||
{oldEmail && newEmail ? (
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Old email address:</strong> {oldEmail}
|
||||
<br/>
|
||||
<strong>New email address:</strong> {newEmail}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Section className="mt-[32px] mb-[32px] text-center">
|
||||
<Button
|
||||
className="rounded bg-[#000000] px-5 py-3 text-center font-semibold text-[12px] text-white no-underline"
|
||||
href={newUrl}>
|
||||
Confirm email address
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailVerification;
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {Ref, useState} from "react";
|
||||
import {Ref, useEffect, useState} from "react";
|
||||
import {Check, X} from "lucide-react";
|
||||
import {motion, AnimatePresence} from "framer-motion";
|
||||
import {
|
||||
@@ -22,6 +22,9 @@ interface PasswordStrengthInputProps {
|
||||
};
|
||||
label?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
onValidChange?: (isValid: boolean) => void;
|
||||
|
||||
}
|
||||
|
||||
const passwordTextsFields = {
|
||||
@@ -48,6 +51,7 @@ export function PasswordStrengthInput({
|
||||
field,
|
||||
label,
|
||||
description,
|
||||
disabled, onValidChange
|
||||
}: PasswordStrengthInputProps) {
|
||||
const text = passwordTextsFields
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
@@ -83,6 +87,14 @@ export function PasswordStrengthInput({
|
||||
return text.strength.strong;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (strengthScore === requirements.length) {
|
||||
onValidChange?.(true);
|
||||
} else {
|
||||
onValidChange?.(false);
|
||||
}
|
||||
}, [strengthScore, onValidChange]);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{label ?? text.label}</FormLabel>
|
||||
@@ -99,6 +111,8 @@ export function PasswordStrengthInput({
|
||||
}}
|
||||
ref={field.ref}
|
||||
name={field.name}
|
||||
disabled={disabled}
|
||||
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {toast} from "sonner";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import Link from "next/link";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
||||
import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
|
||||
import {authClient, signIn} from "@/lib/auth/auth-client";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Icon} from "@iconify/react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {
|
||||
ForgotPasswordSchema,
|
||||
ForgotPasswordType
|
||||
} from "@/components/wrappers/auth/forgot-password/forgot-password.schema";
|
||||
import {ArrowLeft} from "lucide-react";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
|
||||
export type ForgotPasswordFormProps = {};
|
||||
|
||||
export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: ForgotPasswordSchema,
|
||||
});
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: ForgotPasswordType) => {
|
||||
try {
|
||||
const {data, error} = await authClient.requestPasswordReset({
|
||||
email: values.email,
|
||||
redirectTo: `${getServerUrl()}/reset-password`,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
toast.success(data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Unexpected client error during login");
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.message || "Client error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Forgot Password</h1>
|
||||
<p className="text-balance text-muted-foreground">
|
||||
Enter your email to reset your password
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
placeholder="example@portabase.io"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
Send reset link
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<div className="mt-4 text-center text-sm flex items-center justify-center gap-1">
|
||||
<ArrowLeft size={14} className="text-gray-400"/>
|
||||
<Link href="/login" className="underline">
|
||||
Go back
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import TwoFactorForm from "@/components/wrappers/dashboard/profile/form/2fa-form";
|
||||
|
||||
export const GuardForm = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("redirect") || "/dashboard";
|
||||
|
||||
return (
|
||||
<TwoFactorForm
|
||||
onSuccess={(success) => {
|
||||
if (success) {
|
||||
toast.success("Successfully logged in!");
|
||||
//@ts-ignore
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {signIn} from "@/lib/auth/auth-client";
|
||||
import {JSX} from "react";
|
||||
|
||||
export type AuthButtonProps = {
|
||||
providers: SocialProviderType[];
|
||||
callBackURL?: string;
|
||||
};
|
||||
|
||||
export type SocialProviderType = {
|
||||
id:
|
||||
| "github"
|
||||
| "apple"
|
||||
| "discord"
|
||||
| "facebook"
|
||||
| "microsoft"
|
||||
| "google"
|
||||
| "spotify"
|
||||
| "twitch"
|
||||
| "twitter"
|
||||
| "dropbox"
|
||||
| "kick"
|
||||
| "linkedin"
|
||||
| "gitlab"
|
||||
| "tiktok"
|
||||
| "reddit"
|
||||
| "roblox"
|
||||
| "vk";
|
||||
name: string;
|
||||
icon: JSX.Element;
|
||||
};
|
||||
|
||||
export const SocialAuthButton = (props: AuthButtonProps): JSX.Element => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mt-5">
|
||||
{props.providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.id}
|
||||
aria-label={`Sign in with ${provider.name}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void signIn.social({
|
||||
provider: provider.id,
|
||||
callbackURL: props.callBackURL ?? "/dashboard/profile",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{provider.icon}
|
||||
Sign in with {provider.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { zEmail } from "@/lib/zod";
|
||||
|
||||
export const ForgotPasswordSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, "Email is required")
|
||||
.email("Invalid email address"),
|
||||
email: zEmail(),
|
||||
});
|
||||
|
||||
export type ForgotPasswordType = z.infer<typeof ForgotPasswordSchema>;
|
||||
export type ForgotPasswordType = z.infer<typeof ForgotPasswordSchema>;
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { requestPasswordReset } from "@/lib/auth/auth-client";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import Link from "next/link";
|
||||
import { ForgotPasswordSchema, ForgotPasswordType } from "./forgot-password-form.schema";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
export type ForgotPasswordFormProps = {
|
||||
defaultValues?: ForgotPasswordType;
|
||||
};
|
||||
|
||||
export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => {
|
||||
|
||||
const form = useZodForm({
|
||||
schema: ForgotPasswordSchema,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: ForgotPasswordType) => {
|
||||
await requestPasswordReset(
|
||||
{
|
||||
email: values.email,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Password reset successfully.");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="email" autoFocus
|
||||
placeholder="example@portabase.io"
|
||||
{...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-y-6 w-full">
|
||||
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}>
|
||||
Send reset link
|
||||
</ButtonWithLoading>
|
||||
<Link href="/login" className="group flex items-center text-sm hover:underline">
|
||||
<ArrowLeft className="mr-1 size-4 text-muted-foreground transition-transform group-hover:-translate-x-1" />
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
"use server";
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { zString } from "@/lib/zod";
|
||||
import z from "zod";
|
||||
import { db } from "@/db";
|
||||
import {action} from "@/lib/safe-actions/actions";
|
||||
|
||||
//todo: to be continued...
|
||||
export const forgotPasswordAction = action
|
||||
.schema(
|
||||
z.object({
|
||||
schema: z.object({
|
||||
email: zString(),
|
||||
}),
|
||||
redirectTo: zString().optional(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput }): Promise<ServerActionResult<null>> => {
|
||||
try {
|
||||
const user = await (await auth.$context).internalAdapter.findUserByEmail(parsedInput.schema.email);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "password_reset",
|
||||
cause: "user_not_found",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const existingToken = await db.query.verification.findFirst({
|
||||
where: (verifications, { eq, and, gte }) =>
|
||||
and(eq(verifications.value, user.user.id), gte(verifications.expiresAt, new Date(Date.now() + 15 * 60 * 1000))),
|
||||
});
|
||||
|
||||
if (existingToken) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "password_reset",
|
||||
cause: "reset_already_requested",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// await (
|
||||
// await auth.$context
|
||||
// ).options.emailAndPassword
|
||||
// .sendResetPassword(
|
||||
// {
|
||||
// user: user.user,
|
||||
// url,
|
||||
// token: verificationToken,
|
||||
// },
|
||||
// ctx.request
|
||||
// )
|
||||
// .catch((e) => {
|
||||
// ctx.context.logger.error("Failed to send reset password email", e);
|
||||
// });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "password_reset",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { z } from "zod";
|
||||
import { zEmail, zString } from "@/lib/zod";
|
||||
|
||||
export const LoginSchema = z.object({
|
||||
email: z.string().email({message: "Email is invalid"}),
|
||||
password: z.string().nonempty({message: "Password could not be empty"}),
|
||||
|
||||
})
|
||||
email: zEmail(),
|
||||
password: zString().nonempty(),
|
||||
});
|
||||
|
||||
export type LoginType = z.infer<typeof LoginSchema>;
|
||||
|
||||
@@ -1,177 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
|
||||
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {toast} from "sonner";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import Link from "next/link";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
||||
import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
|
||||
import {signIn} from "@/lib/auth/auth-client";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Icon} from "@iconify/react";
|
||||
import {useEffect, useState} from "react";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
export type loginFormProps = {
|
||||
defaultValues?: LoginType;
|
||||
authGoogleEnabled: boolean;
|
||||
};
|
||||
|
||||
export const LoginForm = (props: loginFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: LoginSchema,
|
||||
});
|
||||
|
||||
const [urlParams] = useState(() =>
|
||||
new URLSearchParams(typeof window !== "undefined" ? window.location.search : "")
|
||||
);
|
||||
const [urlParams, setUrlParams] = useState<URLSearchParams>();
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
setUrlParams(urlParams);
|
||||
const error = urlParams.get("error");
|
||||
if (error?.includes("pending")) {
|
||||
toast.error("Your account is not active.");
|
||||
urlParams.delete("error");
|
||||
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
||||
}
|
||||
}, [urlParams]);
|
||||
if (error?.includes("invalid_or_expired_token")) {
|
||||
toast.error("Password reset invalid token.");
|
||||
urlParams.delete("error");
|
||||
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const form = useZodForm({
|
||||
schema: LoginSchema,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: LoginType) => {
|
||||
try {
|
||||
const callbackURL =
|
||||
urlParams.get("redirect")?.startsWith("/")
|
||||
? urlParams.get("redirect")
|
||||
: "/dashboard/profile";
|
||||
|
||||
const {error} = await signIn.email({
|
||||
email: values.email,
|
||||
await signIn.email(
|
||||
{
|
||||
password: values.password,
|
||||
callbackURL: callbackURL ?? "/dashboard/profile",
|
||||
});
|
||||
email: values.email,
|
||||
callbackURL: urlParams?.get("redirect") ?? "/dashboard",
|
||||
},
|
||||
{
|
||||
onSuccess: (context) => {
|
||||
if (context.data.twoFactorRedirect) {
|
||||
//@ts-ignore
|
||||
router.push("/guard?redirect=" + encodeURIComponent(context.data.callbackURL || "/dashboard"));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
} else {
|
||||
toast.success("Login success");
|
||||
toast.success("Login success");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Unexpected client error during login");
|
||||
}
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.message || "Client error");
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const availableProviders: SocialProviderType[] = [];
|
||||
|
||||
if (props.authGoogleEnabled) {
|
||||
availableProviders.push({
|
||||
id: "google",
|
||||
name: "Google",
|
||||
icon: <Icon icon="logos:google-icon" width="25" height="25"/>,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Login</h1>
|
||||
<p className="text-balance text-muted-foreground">
|
||||
Enter your information below to login
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
placeholder="example@portabase.io"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="text-center text-sm">
|
||||
<Link href="/forgot-password" className="hover:underline">
|
||||
Forgot your password ?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
autoComplete="current-password"
|
||||
placeholder="Your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
{availableProviders.length > 0 && (
|
||||
<div className="relative my-4 flex items-center justify-center overflow-hidden">
|
||||
<Separator/>
|
||||
<div className="px-2 text-center bg-card text-sm">OR</div>
|
||||
<Separator/>
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="email" autoFocus placeholder="exemple@portabase.io" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="text-center text-sm">
|
||||
<Link href={"/forgot-password"} className="hover:underline ml-1">
|
||||
Forgot your password ?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SocialAuthButton
|
||||
callBackURL={urlParams.get("redirect") ?? "/dashboard/profile"}
|
||||
providers={availableProviders}
|
||||
/>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account ?{" "}
|
||||
<Link href="/register" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
<FormControl>
|
||||
<PasswordInput autoComplete="current-password webauthn"
|
||||
placeholder={"Enter your password"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ButtonWithLoading className="mt-2" isPending={mutation.isPending}>
|
||||
Login
|
||||
</ButtonWithLoading>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { zPassword, zString } from "@/lib/zod";
|
||||
import z from "zod";
|
||||
import {action} from "@/lib/safe-actions/actions";
|
||||
|
||||
export const resetPasswordAction = action
|
||||
.schema(
|
||||
z.object({
|
||||
schema: z.object({
|
||||
password: zPassword(),
|
||||
}),
|
||||
token: zString(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput }): Promise<ServerActionResult<null>> => {
|
||||
try {
|
||||
const verification = await (await auth.$context).internalAdapter.findVerificationValue(`reset-password:${parsedInput.token}`);
|
||||
if (!verification || verification.expiresAt < new Date()) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "password_reset",
|
||||
cause: "invalid_or_expired_token",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
|
||||
console.log(user)
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "password_reset",
|
||||
cause: "user_not_found",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const hashedPassword = await (await auth.$context).password.hash(parsedInput.schema.password);
|
||||
console.log(hashedPassword)
|
||||
console.log("ok")
|
||||
// await (await auth.$context).internalAdapter.updatePassword(user.id, hashedPassword);
|
||||
console.log("ici")
|
||||
// await (await auth.$context).internalAdapter.deleteSessions(user.id);
|
||||
// console.log("ici2")
|
||||
// await (await auth.$context).internalAdapter.deleteVerificationValue(verification.id);
|
||||
// console.log("ici3");
|
||||
//
|
||||
// (await auth.$context).internalAdapter.updateUser(user.id, {
|
||||
// lastChangedPasswordAt: new Date(),
|
||||
// });
|
||||
|
||||
// await auth.api.resetPassword({
|
||||
// headers: await headers(),
|
||||
// body: {
|
||||
// newPassword: parsedInput.schema.password,
|
||||
// token: parsedInput.token,
|
||||
// },
|
||||
// });
|
||||
|
||||
await (
|
||||
await auth.$context
|
||||
).internalAdapter.updateUser(user.id, {
|
||||
isDefaultPassword: false,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
actionSuccess: {
|
||||
message: "password_reset",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "password_reset",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import {z} from "zod";
|
||||
import {zPassword} from "@/lib/zod";
|
||||
|
||||
export const ResetPasswordSchema = z
|
||||
.object({
|
||||
password: zPassword(),
|
||||
confirmPassword: zPassword(),
|
||||
})
|
||||
.superRefine(({confirmPassword, password}, ctx) => {
|
||||
if (confirmPassword !== password) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Confirmation password does not match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type ResetPasswordType = z.infer<typeof ResetPasswordSchema>;
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
|
||||
import {FormControl, FormField, FormItem, FormLabel, useZodForm} from "@/components/ui/form";
|
||||
import {Form} from "@/components/ui/form";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import Link from "next/link";
|
||||
import {ResetPasswordSchema, ResetPasswordType} from "./reset-password-form.schema";
|
||||
import {PasswordStrengthInput} from "@/components/ui/password-input-indicator";
|
||||
import {useRouter, useSearchParams} from "next/navigation";
|
||||
import {ArrowLeft} from "lucide-react";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {BetterAuthError} from "@/types/auth";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
export type ResetPasswordFormProps = {
|
||||
defaultValues?: ResetPasswordType;
|
||||
};
|
||||
|
||||
export const ResetPasswordForm = (props: ResetPasswordFormProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: ResetPasswordSchema,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: ResetPasswordType) => {
|
||||
|
||||
const {data, error} = await authClient.resetPassword({
|
||||
newPassword: values.password,
|
||||
token: searchParams.get("token") || "",
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Password successfully reset!");
|
||||
setTimeout(() => router.push("/"), 1400);
|
||||
},
|
||||
onError: (error: BetterAuthError) => {
|
||||
console.log(error)
|
||||
toast.error("An error occurred while resetting password");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4 mb-1"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<PasswordStrengthInput label={"New password"} field={field}/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirmation password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={"Enter your conformation password"} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-y-6 w-full">
|
||||
<ButtonWithLoading className="mt-2 w-full" isPending={mutation.isPending}>
|
||||
Reset
|
||||
</ButtonWithLoading>
|
||||
<Link href="/login" className="group flex items-center text-sm hover:underline">
|
||||
<ArrowLeft
|
||||
className="mr-1 size-4 text-muted-foreground transition-transform group-hover:-translate-x-1"/>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../../portabase.config";
|
||||
|
||||
export function SocialAuthButtons() {
|
||||
const [isLoading, setIsLoading] = useState<string | null>(null);
|
||||
|
||||
const handleSocialSignIn = async (providerId: string) => {
|
||||
setIsLoading(providerId);
|
||||
try {
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: providerId as "google" | "github",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error("An error occurred while signing in with the provider. Please try again.");
|
||||
} else {
|
||||
toast.success("Redirecting to provider...");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("An error occurred while signing in with the provider. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const socialProviders = SUPPORTED_PROVIDERS.filter((p) => !p.isManual);
|
||||
|
||||
if (socialProviders.length === 0) return null;
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{socialProviders.map((provider) => (
|
||||
<Button key={provider.id} variant="outline" className="w-full gap-2" onClick={() => handleSocialSignIn(provider.id)} disabled={!!isLoading}>
|
||||
{isLoading === provider.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <provider.icon className="h-4 w-4" />}
|
||||
<span>{PROVIDERS_TEXT[provider.id].title}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const PROVIDERS_TEXT = {
|
||||
credential: {
|
||||
title: "Password",
|
||||
description: "Use your email address and password to sign in."
|
||||
},
|
||||
google: {
|
||||
title: "Google",
|
||||
description: "Sign in with your Google account."
|
||||
},
|
||||
github: {
|
||||
title: "GitHub",
|
||||
description: "Sign in with your GitHub account."
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
"use client"
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {updateUserAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useState} from "react";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {UserWithAccounts} from "@/db/schema/02_user";
|
||||
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
|
||||
export const organizationsColumnsAdmin: ColumnDef<Organization>[] = [
|
||||
|
||||
@@ -4,7 +4,7 @@ import {ColumnDef} from "@tanstack/react-table";
|
||||
import {Unlink} from "lucide-react";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {unlinkUserProviderAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||
import {unlinkUserProviderAction} from "@/components/wrappers/dashboard/profile2/user-form/user-form.action";
|
||||
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
||||
import {Account} from "better-auth";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {toast} from "sonner";
|
||||
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
|
||||
import {deleteUserAction} from "@/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action";
|
||||
|
||||
export type ButtonDeleteUserProps = {
|
||||
userId: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {updateUserAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||
import {updateUserAction} from "@/components/wrappers/dashboard/profile2/user-form/user-form.action";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
|
||||
@@ -9,7 +9,7 @@ import detectOSWithUA from "@/utils/os-parser";
|
||||
import {Icon} from "@iconify/react";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {timeAgo} from "@/utils/date-formatting";
|
||||
import {deleteUserSessionAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||
import {deleteUserSessionAction} from "@/components/wrappers/dashboard/profile2/user-form/user-form.action";
|
||||
|
||||
export const sessionsColumns: ColumnDef<Session>[] = [
|
||||
{
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
import { ChevronUp } from "lucide-react";
|
||||
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
|
||||
import {LoggedInDropdown} from "@/components/wrappers/dashboard/common/logged-in/logged-in-dropdown";
|
||||
|
||||
export const LoggedInButton = async () => {
|
||||
const user = await currentUser();
|
||||
const sessions = await getSessions();
|
||||
const currentSession = await getSession();
|
||||
const accounts = await getAccounts();
|
||||
|
||||
// if (!user) return null;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<LoggedInDropdown
|
||||
user={{
|
||||
...user,
|
||||
image: user.image ?? null,
|
||||
role: user.role ?? null,
|
||||
banned: user.banned ?? null,
|
||||
banReason: user.banReason ?? null,
|
||||
banExpires: user.banExpires ?? null,
|
||||
deletedAt: user.deletedAt ? new Date(user.deletedAt) : null,
|
||||
}}
|
||||
>
|
||||
<SidebarMenuButton>
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
|
||||
{user.image ? <AvatarImage src={user.image} alt={`${user.name ?? "-"}'s profile picture`} /> : null}
|
||||
</Avatar>
|
||||
<span className="first-letter:capitalize">{user.name}</span>
|
||||
<ChevronUp className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</LoggedInDropdown>
|
||||
<>
|
||||
<LoggedInDropdown
|
||||
// @ts-ignore
|
||||
user={user}
|
||||
// @ts-ignore
|
||||
sessions={sessions}
|
||||
// @ts-ignore
|
||||
currentSession={currentSession.session}
|
||||
// @ts-ignore
|
||||
accounts={accounts}
|
||||
>
|
||||
<SidebarMenuButton>
|
||||
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{user?.name[0].toUpperCase()}</AvatarFallback>
|
||||
{user?.image ? <AvatarImage src={user?.image} alt={`${user.name ?? "-"}'s profile picture`} /> : null}
|
||||
</Avatar>
|
||||
|
||||
<span className="first-letter:capitalize">{user?.name}</span>
|
||||
<ChevronUp className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</LoggedInDropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren } from "react";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { redirect } from "next/navigation";
|
||||
import { CircleUser, LogOut, ShieldHalf } from "lucide-react";
|
||||
import { signOut } from "@/lib/auth/auth-client";
|
||||
import {PropsWithChildren, ReactNode, useState} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
import { CircleUser, LogOut } from "lucide-react";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { signOut } from "@/lib/auth/auth-client";
|
||||
import {ProfileModal} from "@/components/wrappers/dashboard/common/profile/profile-modal";
|
||||
import {Account, Session, User} from "@/db/schema/02_user";
|
||||
|
||||
export type LoggedInDropdownProps = PropsWithChildren<{
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
currentSession: Session;
|
||||
accounts: Account[];
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const LoggedInDropdown = (props: LoggedInDropdownProps) => {
|
||||
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children }: LoggedInDropdownProps) => {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{props.children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" className="min-w-[var(--radix-popper-anchor-width)]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
redirect("/dashboard/profile");
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-start items-center gap-2">
|
||||
<CircleUser size={16} />
|
||||
<span>Account</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
{/*{(props.user.role === "superadmin" || props.user.role === "admin") && (*/}
|
||||
{/* <DropdownMenuItem*/}
|
||||
{/* onClick={() => {*/}
|
||||
{/* redirect("/dashboard/admin");*/}
|
||||
{/* }}*/}
|
||||
{/* >*/}
|
||||
{/* <div className="flex justify-start items-center gap-2">*/}
|
||||
{/* <ShieldHalf size={16} />*/}
|
||||
{/* <span>Administration Panel</span>*/}
|
||||
{/* </div>*/}
|
||||
{/* </DropdownMenuItem>*/}
|
||||
{/*)}*/}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/login");
|
||||
<>
|
||||
<ProfileModal
|
||||
user={user}
|
||||
sessions={sessions}
|
||||
currentSession={currentSession}
|
||||
accounts={accounts}
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" className="min-w-[var(--radix-popper-anchor-width)]">
|
||||
<DropdownMenuItem onClick={() => setIsModalOpen(!isModalOpen)}>
|
||||
<div className="flex justify-start items-center gap-2">
|
||||
<CircleUser size={16} />
|
||||
<span>Account</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
router.push("/login");
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-start items-center gap-2">
|
||||
<LogOut size={16} />
|
||||
<span>Log out</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-start items-center gap-2">
|
||||
<LogOut size={16} className="text-red-500" />
|
||||
<span className="text-red-500">Logout</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { Account, Session, User } from "@/db/schema/02_user";
|
||||
import { ProfileSidebar } from "./profile-sidebar";
|
||||
import {ProfileProviders} from "@/components/wrappers/dashboard/profile/profile-providers";
|
||||
import {ProfileAccount} from "@/components/wrappers/dashboard/profile/profile-account";
|
||||
import {ProfileAppearance} from "@/components/wrappers/dashboard/profile/profile-apperance";
|
||||
import {ProfileSecurity} from "@/components/wrappers/dashboard/profile/profile-security";
|
||||
import {ProfileGeneral} from "@/components/wrappers/dashboard/profile/profile-general";
|
||||
|
||||
type ProfileModalProps = {
|
||||
open: boolean;
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
currentSession: Session;
|
||||
accounts: Account[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange }: ProfileModalProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogDescription>Manage your account settings</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="profile" orientation="vertical" className="flex flex-col lg:flex-row h-full w-full">
|
||||
<ProfileSidebar user={user} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-background h-full scroll-smooth">
|
||||
<TabsContent value="profile" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
||||
<ProfileGeneral user={user} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
||||
<ProfileSecurity
|
||||
user={user}
|
||||
sessions={sessions}
|
||||
currentSession={currentSession}
|
||||
credentialAccount={accounts.find((acc) => acc.providerId === "credential")!}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="providers" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
||||
<ProfileProviders accounts={accounts} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="account" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
||||
<ProfileAccount user={user} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="appearance" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
||||
<ProfileAppearance />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import React, { use } from "react";
|
||||
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { UserIcon, Settings, Palette, ShieldHalf, Workflow } from "lucide-react";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
|
||||
interface ProfileSidebarProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function ProfileSidebar({ user }: ProfileSidebarProps) {
|
||||
|
||||
return (
|
||||
<div className="w-full lg:w-[260px] flex-shrink-0 lg:border-r bg-muted/10 p-4 lg:p-6 flex flex-col gap-4 border-b lg:border-b-0">
|
||||
<div className="flex items-center px-2 mb-2">
|
||||
<span className="font-bold text-xl tracking-tight">Settings</span>
|
||||
</div>
|
||||
|
||||
<TabsList className="flex flex-col h-auto w-full bg-transparent p-0 gap-1 justify-start items-stretch">
|
||||
<SettingsTabTrigger value="profile" icon={<UserIcon className="w-4 h-4" />}>
|
||||
Profile
|
||||
</SettingsTabTrigger>
|
||||
<SettingsTabTrigger value="security" icon={<ShieldHalf className="w-4 h-4" />}>
|
||||
Security & Access
|
||||
</SettingsTabTrigger>
|
||||
<SettingsTabTrigger value="providers" icon={<Workflow className="w-4 h-4" />}>
|
||||
Connected Accounts
|
||||
</SettingsTabTrigger>
|
||||
<SettingsTabTrigger value="account" icon={<Settings className="w-4 h-4" />}>
|
||||
Account
|
||||
</SettingsTabTrigger>
|
||||
<SettingsTabTrigger value="appearance" icon={<Palette className="w-4 h-4" />}>
|
||||
Appearance
|
||||
</SettingsTabTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsTabTrigger({ value, icon, children }: { value: string; icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<TabsTrigger
|
||||
value={value}
|
||||
className="w-full justify-start gap-3 px-3 py-2.5 rounded-md transition-all whitespace-nowrap flex-shrink-0 text-sm data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm data-[state=active]:font-medium hover:bg-muted/50 hover:text-foreground text-muted-foreground"
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</TabsTrigger>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export function AppSidebar() {
|
||||
<SidebarMenu className="mb-2">
|
||||
<SidebarMenuItem className="p-2">
|
||||
<LoggedInButton/>
|
||||
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SideBarFooterCredit/>
|
||||
|
||||
@@ -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,101 @@
|
||||
"use server";
|
||||
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import { z } from "zod";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
|
||||
const RevokeSessionSchema = z.object({
|
||||
token: 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { uploadImageAction } from "@/features/upload/public/upload.action";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { updateImageUserAction } from "@/components/wrappers/dashboard/profile2/avatar/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 uploadImage = await uploadImageAction(formData);
|
||||
const data = uploadImage?.data?.data;
|
||||
|
||||
if (uploadImage?.serverError || !data) {
|
||||
console.log(uploadImage?.serverError);
|
||||
toast.error(uploadImage?.serverError);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateUser = await updateImageUserAction(data.url);
|
||||
const dataUser = updateUser?.data?.data;
|
||||
|
||||
if (updateUser?.serverError || !dataUser) {
|
||||
console.log(updateUser?.serverError);
|
||||
toast.error(updateUser?.serverError);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Successfully uploaded user image!");
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.includes("image")) {
|
||||
toast.error("File not an image");
|
||||
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 src={user.image || undefined}/>
|
||||
<AvatarFallback className="text-3xl">{user.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div
|
||||
onClick={() => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
// @ts-ignore
|
||||
fileInput.onchange = handleImageUpload;
|
||||
fileInput.click();
|
||||
}}
|
||||
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-100 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,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,168 @@
|
||||
"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) => {
|
||||
console.error("totp", 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) => {
|
||||
console.error("bak", 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" 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,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 "../schemas/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,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 "../schemas/provider.schema";
|
||||
import {linkPasswordProfileProviderAction} from "../actions/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,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Loader2, ShieldX } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import TwoFactorForm from "../form/2fa-form";
|
||||
import { zPassword } from "@/lib/zod";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
password: zPassword(),
|
||||
});
|
||||
|
||||
type Password = z.infer<typeof PasswordSchema>;
|
||||
|
||||
type Disable2FAModalProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
export function Disable2FAProfileProviderModal({ onOpenChange, open }: Disable2FAModalProps) {
|
||||
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<"OTP" | "PASSWORD">("OTP");
|
||||
|
||||
const passwordForm = useZodForm({
|
||||
schema: PasswordSchema,
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: disable2FA, isPending: isDisabling } = useMutation({
|
||||
mutationFn: async (values: Password) => {
|
||||
const { data, error } = await authClient.twoFactor.disable({
|
||||
password: values.password,
|
||||
});
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.refresh();
|
||||
toast.success("Two-factor authentication disabled successfully.");
|
||||
onOpenChange(false);
|
||||
setStep("OTP");
|
||||
passwordForm.reset();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to disable two-factor authentication.");
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
passwordForm.reset();
|
||||
setStep("OTP");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<ShieldX className="w-4 h-4 mr-2" />
|
||||
Disable Two-Factor
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
|
||||
<DialogDescription>Are you sure you want to disable two-factor authentication? This will reduce the security of your account.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "OTP" && (
|
||||
<TwoFactorForm
|
||||
onSuccess={(success) => {
|
||||
if (success) {
|
||||
setStep("PASSWORD");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "PASSWORD" && (
|
||||
<Form form={passwordForm} onSubmit={async (values) => disable2FA(values)}>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={"Fill your current Password"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<Button type="button" variant="ghost" onClick={() => setStep("OTP")} disabled={isDisabling}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isDisabling || !passwordForm.formState.isDirty}>
|
||||
{isDisabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Disable
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import ResetPasswordForm from "../form/reset-password-form";
|
||||
|
||||
type ResetPasswordModalProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
export function ResetPasswordProfileProviderModal({ onOpenChange, open }: ResetPasswordModalProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Reset Password
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset Password</DialogTitle>
|
||||
<DialogDescription>Enter a new password for your account below.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ResetPasswordForm onSuccess={() => onOpenChange(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import SetPasswordForm from "../form/set-password-form";
|
||||
|
||||
type SetPasswordModalProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
export function SetPasswordProfileProviderModal({ onOpenChange, open }: SetPasswordModalProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default" size="sm">
|
||||
Set Password
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Password</DialogTitle>
|
||||
<DialogDescription>Create a password for your account to enable password-based login.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<SetPasswordForm onSuccess={() => onOpenChange(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import {useState} from "react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from "@/components/ui/dialog";
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Loader2, Copy, CheckCircle2, ShieldCheck} from "lucide-react";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Setup2FASecuritySchema, Setup2FASecuritySchemaType} from "../schemas/security.schema";
|
||||
import {toast} from "sonner";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {Alert, AlertDescription} from "@/components/ui/alert";
|
||||
import {InputOTP, InputOTPGroup, InputOTPSlot} from "@/components/ui/input-otp";
|
||||
import QRCode from "react-qr-code";
|
||||
import z from "zod";
|
||||
import {zPassword} from "@/lib/zod";
|
||||
import {BackupCodesList} from "../components/backup-codes-list";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
password: zPassword(),
|
||||
});
|
||||
|
||||
type Password = z.infer<typeof PasswordSchema>;
|
||||
|
||||
type Setup2FAModalProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModalProps) {
|
||||
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD");
|
||||
|
||||
const [totpURI, setTotpURI] = useState<string>("");
|
||||
const [secret, setSecret] = useState<string>("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
|
||||
const form = useZodForm({
|
||||
schema: Setup2FASecuritySchema,
|
||||
defaultValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const passwordForm = useZodForm({
|
||||
schema: PasswordSchema,
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const {mutate: enable2FA, isPending: isEnabling} = useMutation({
|
||||
mutationFn: async (values: Password) => {
|
||||
const {data, error} = await authClient.twoFactor.enable({
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setTotpURI(data.totpURI);
|
||||
setSecret(data.totpURI.split("secret=")[1].split("&")[0]);
|
||||
setBackupCodes(data.backupCodes || []);
|
||||
setStep("QR");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to enable two-factor authentication.");
|
||||
},
|
||||
});
|
||||
|
||||
const {mutate: verify2FA, isPending: isVerifying} = useMutation({
|
||||
mutationFn: async (values: Setup2FASecuritySchemaType) => {
|
||||
const {data, error} = await authClient.twoFactor.verifyTotp({
|
||||
code: values.code,
|
||||
trustDevice: true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Two-factor authentication enabled successfully.");
|
||||
setStep("BACKUP");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("The provided code is invalid.");
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopySecret = () => {
|
||||
navigator.clipboard.writeText(secret);
|
||||
toast.success("Secret copied to clipboard");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
router.refresh();
|
||||
onOpenChange(false);
|
||||
setStep("PASSWORD");
|
||||
form.reset();
|
||||
passwordForm.reset();
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<ShieldCheck className="w-4 h-4 mr-2"/>
|
||||
Enable Two-Factor
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Two-Factor Authentication</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "PASSWORD" && ""}
|
||||
{step === "QR" && "Scan the QR code below with your authentication app or enter the secret key manually."}
|
||||
{step === "BACKUP" && "Save these backup codes in a secure location. They can be used to access your account if you lose access to your authentication device."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "PASSWORD" && (
|
||||
<Form form={passwordForm} onSubmit={async (values) => enable2FA(values)}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="Fill your current password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isEnabling || !passwordForm.formState.isDirty}>
|
||||
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === "QR" && (
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={async (values) => {
|
||||
verify2FA(values);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center space-y-6 py-4">
|
||||
<div className="p-4 bg-white rounded-xl shadow-sm border">
|
||||
{totpURI && (
|
||||
<QRCode value={totpURI} size={180}
|
||||
style={{height: "auto", maxWidth: "100%", width: "100%"}}
|
||||
viewBox={`0 0 256 256`}/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
<p className="text-xs text-muted-foreground text-center">If you are unable to scan the QR code, you can manually enter the secret key into your authentication app :</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 bg-muted p-2 rounded text-xs font-mono break-all text-center">{secret}</code>
|
||||
<Button type="button" size="icon" variant="ghost" onClick={handleCopySecret}>
|
||||
<Copy className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full border-t pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({field}) => (
|
||||
<FormItem className="flex flex-col items-center">
|
||||
<FormLabel className="mb-2">Verification Code</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
autoFocus
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (value.length === 6) {
|
||||
verify2FA(form.getValues());
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0}/>
|
||||
<InputOTPSlot index={1}/>
|
||||
<InputOTPSlot index={2}/>
|
||||
<InputOTPSlot index={3}/>
|
||||
<InputOTPSlot index={4}/>
|
||||
<InputOTPSlot index={5}/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end w-full">
|
||||
<Button disabled={isVerifying} type="submit">
|
||||
{isVerifying && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
|
||||
I've Configured My App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === "BACKUP" && (
|
||||
<div className="space-y-6 py-4">
|
||||
<Alert variant="default"
|
||||
className="border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-900">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400"/>
|
||||
<AlertDescription
|
||||
className="text-green-700 dark:text-green-400">Two Factor Authentication is now enabled on your account.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<BackupCodesList codes={backupCodes}/>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleClose}>Finish Setup</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||
import { Loader2, FileKey2, RefreshCw, AlertTriangle, Download } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { zPassword } from "@/lib/zod";
|
||||
import { BackupCodesList } from "../components/backup-codes-list";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
password: zPassword(),
|
||||
});
|
||||
|
||||
type Password = z.infer<typeof PasswordSchema>;
|
||||
|
||||
type ViewBackupCodesModalProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
export function ViewBackupCodesModal({ onOpenChange, open }: ViewBackupCodesModalProps) {
|
||||
|
||||
|
||||
const [step, setStep] = useState<"PASSWORD" | "CODES">("PASSWORD");
|
||||
const [codes, setCodes] = useState<string[]>([]);
|
||||
|
||||
const form = useZodForm({
|
||||
schema: PasswordSchema,
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: generateCodes, isPending } = useMutation({
|
||||
mutationFn: async (values: Password) => {
|
||||
const { data, error } = await authClient.twoFactor.generateBackupCodes({
|
||||
password: values.password,
|
||||
});
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data?.backupCodes) {
|
||||
setCodes(data.backupCodes);
|
||||
setStep("CODES");
|
||||
toast.success("New backup codes generated successfully.");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to generate backup codes. Your password may be incorrect.");
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
setStep("PASSWORD");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<FileKey2 className="w-4 h-4 mr-2" />
|
||||
Regenerate Backup Codes
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Backup Codes</DialogTitle>
|
||||
<DialogDescription>{step === "PASSWORD" ? "For security reasons, existing codes are hidden. You must generate a new set to view them." : "Save these codes securely. They will not be shown again once you close this window."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "PASSWORD" && (
|
||||
<Form form={form} onSubmit={(values) => generateCodes(values)}>
|
||||
<div className="space-y-4 py-2">
|
||||
<Alert variant="destructive" className="py-3 w-fit">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle className="text-sm font-semibold ml-2">Important</AlertTitle>
|
||||
<AlertDescription className="text-xs ml-2 mt-1">Generating new codes will invalidate your existing ones. Make sure to save the new codes securely.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder={"Fill your current Password"} {...field} autoFocus />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="submit" disabled={isPending} variant="default">
|
||||
{isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||
Generate New Codes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === "CODES" && (
|
||||
<div className="space-y-6 py-2 animate-in fade-in zoom-in-95 duration-200">
|
||||
<BackupCodesList codes={codes} />
|
||||
|
||||
<div className="flex flex-col sm:flex-row justify-between gap-2 pt-2">
|
||||
<Button onClick={() => handleClose()} className="w-full sm:w-auto">
|
||||
I Have Saved My Codes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import {useEffect} from "react";
|
||||
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 "./schemas/account.schema";
|
||||
import {BetterAuthError} from "@/types/auth";
|
||||
|
||||
|
||||
interface ProfileAccountProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export function ProfileAccount({user}: ProfileAccountProps) {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
interval = setInterval(async () => {
|
||||
router.refresh();
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
});
|
||||
|
||||
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) => {
|
||||
console.log(error)
|
||||
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) => {
|
||||
console.error(e);
|
||||
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,97 @@
|
||||
"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, setTheme} = 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 () => {
|
||||
// setTheme(item.value)
|
||||
// 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 "./actions/profile.action";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {ProfileSchema, ProfileSchemaType} from "./schemas/general.schema";
|
||||
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/components/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,175 @@
|
||||
"use client";
|
||||
|
||||
import React, {useState} from "react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {Loader2, AlertTriangle} from "lucide-react";
|
||||
import {Account} from "@/db/schema/02_user";
|
||||
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, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {Alert, AlertDescription} from "@/components/ui/alert";
|
||||
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../../../portabase.config";
|
||||
|
||||
interface ProfileProviderProps {
|
||||
accounts: Account[];
|
||||
}
|
||||
|
||||
export function ProfileProviders({accounts}: ProfileProviderProps) {
|
||||
const router = useRouter();
|
||||
const totalConnected = accounts.length;
|
||||
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
|
||||
const {mutate: unlinkAccount, isPending: isUnlinking} = useMutation({
|
||||
mutationFn: async (providerId: string) => {
|
||||
const {error} = await authClient.unlinkAccount({
|
||||
providerId,
|
||||
});
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Provider successfully unlinked!");
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while unlinking provider.");
|
||||
},
|
||||
});
|
||||
|
||||
const {mutate: linkAccount, isPending: isLinking} = useMutation({
|
||||
mutationFn: async (providerId: string) => {
|
||||
const {error} = await authClient.signIn.social({
|
||||
provider: providerId as "google" | "github",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Provider successfully Linked!");
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while linked provider.");
|
||||
},
|
||||
});
|
||||
|
||||
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">Connected Accounts</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage the providers used to sign in to your account.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{SUPPORTED_PROVIDERS.map((provider) => {
|
||||
const linkedAccount = accounts.find((acc) => acc.providerId === provider.id);
|
||||
const isConnected = !!linkedAccount;
|
||||
|
||||
const canUnlink = totalConnected > 1 || (totalConnected === 1 && !provider.isManual);
|
||||
const isLoading = isUnlinking || isLinking;
|
||||
|
||||
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 className="w-5 h-5"/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{PROVIDERS_TEXT[provider.id].title}
|
||||
{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">
|
||||
{isConnected ? "Connected" : "Not Connected"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => unlinkAccount(provider.id)}
|
||||
disabled={!canUnlink || isLoading || provider.isManual}
|
||||
className={!canUnlink ? "opacity-50 cursor-not-allowed" : ""}
|
||||
>
|
||||
{isLoading ? <Loader2
|
||||
className="w-4 h-4 animate-spin"/> : "Unlink"}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!canUnlink && (
|
||||
<TooltipContent>
|
||||
<p>
|
||||
You cannot unlink your last authentication provider or if you
|
||||
don't have a password set.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<>
|
||||
{provider.id === "credential" ? (
|
||||
<SetPasswordProfileProviderModal open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}/>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => linkAccount(provider.id)}
|
||||
disabled={isLoading || provider.isManual}
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin"/> : "Link"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</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. If you use the same
|
||||
email address with another provider, it will be automatically linked when you log in.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const PROVIDERS_TEXT = {
|
||||
credential: {
|
||||
title: "Password",
|
||||
description: "Use your email address and password to sign in.",
|
||||
},
|
||||
google: {
|
||||
title: "Google",
|
||||
description: "Sign in with your Google account.",
|
||||
},
|
||||
github: {
|
||||
title: "GitHub",
|
||||
description: "Sign in with your GitHub account.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"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} from "lucide-react";
|
||||
import {Account, Session, User} from "@/db/schema/02_user";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {revokeAllSessionsAction, revokeSessionAction} from "./actions/security.action";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {ResetPasswordProfileProviderModal} from "./modal/reset-password-modal";
|
||||
import {SetPasswordProfileProviderModal} from "./modal/set-password-modal";
|
||||
import {Setup2FAProfileProviderModal} from "./modal/setup-2fa-modal";
|
||||
import {Disable2FAProfileProviderModal} from "./modal/disable-2fa-modal";
|
||||
import {ViewBackupCodesModal} from "./modal/view-backup-codes-modal";
|
||||
import {getDeviceDetails} from "@/utils/detection";
|
||||
import {timeAgo} from "@/utils/date-formatting";
|
||||
|
||||
interface ProfileSecurityProps {
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
credentialAccount: Account;
|
||||
currentSession: Session;
|
||||
}
|
||||
|
||||
export function ProfileSecurity({user, sessions, credentialAccount, currentSession}: 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 {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");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium">Authentication</h3>
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<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*/}
|
||||
{/* ? t("sections.authentication.password.description.last_changed", {*/}
|
||||
{/* date: timeAgo(new Date(user.lastChangedPasswordAt), locale),*/}
|
||||
{/* })*/}
|
||||
{/* : "Never changed"}*/}
|
||||
</div>
|
||||
</div>
|
||||
{credentialAccount ? (
|
||||
<ResetPasswordProfileProviderModal open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}/>
|
||||
) : (
|
||||
<SetPasswordProfileProviderModal open={isPasswordDialogOpen}
|
||||
onOpenChange={setIsPasswordDialogOpen}/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator/>
|
||||
|
||||
<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 open={isSetup2FADialogOpen}
|
||||
onOpenChange={setIsSetup2FADialogOpen}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<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}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground">No active sessions found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
session,
|
||||
onRevoke,
|
||||
isRevoking,
|
||||
currentSession,
|
||||
}: {
|
||||
session: Session;
|
||||
onRevoke: (token: string) => void;
|
||||
isRevoking: boolean;
|
||||
currentSession: Session;
|
||||
}) {
|
||||
const deviceInfo = getDeviceDetails(session.userAgent);
|
||||
|
||||
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">
|
||||
<deviceInfo.Icon className="w-5 h-5"/>
|
||||
</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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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,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,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,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>;
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { UploadIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { uploadImageAction } from "@/features/upload/public/upload.action";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { updateImageUserAction } from "@/components/wrappers/dashboard/profile/avatar/avatar.action";
|
||||
import { updateImageUserAction } from "@/components/wrappers/dashboard/profile2/avatar/avatar.action";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { User } from "@/db/schema/02_user";
|
||||
import {ChangeEvent} from "react";
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import { z } from "zod";
|
||||
import { UserSchema } from "@/components/wrappers/dashboard/profile/user-form/user-form.schema";
|
||||
import { UserSchema } from "@/components/wrappers/dashboard/profile2/user-form/user-form.schema";
|
||||
import { db } from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
+2
-2
@@ -8,9 +8,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { UserSchema, UserType } from "@/components/wrappers/dashboard/profile/user-form/user-form.schema";
|
||||
import { UserSchema, UserType } from "@/components/wrappers/dashboard/profile2/user-form/user-form.schema";
|
||||
import { toast } from "sonner";
|
||||
import { updateUserAction } from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||
import { updateUserAction } from "@/components/wrappers/dashboard/profile2/user-form/user-form.action";
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/users/sessions/table-columns";
|
||||
import {accountsColumns} from "@/components/wrappers/dashboard/admin/users/accounts/table-columns";
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user" ADD COLUMN "lastChangedPasswordAt" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "two_factor_enabled" boolean DEFAULT false;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,13 @@
|
||||
"when": 1766308683196,
|
||||
"tag": "0012_peaceful_leopardon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1766327760039,
|
||||
"tag": "0013_past_logan",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {project} from "./06_project";
|
||||
import {member, OrganizationMember} from "@/db/schema/04_member";
|
||||
import {invitation} from "@/db/schema/05_invitation";
|
||||
import {organization} from "@/db/schema/03_organization";
|
||||
import {Account} from "better-auth";
|
||||
import {Account as BetterAuthAccount} from "better-auth";
|
||||
import {timestamps} from "@/db/schema/00_common";
|
||||
|
||||
export const user = pgTable("user", {
|
||||
@@ -19,6 +19,8 @@ export const user = pgTable("user", {
|
||||
banned: boolean("banned"),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: timestamp("ban_expires"),
|
||||
lastChangedPasswordAt: timestamp(),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
@@ -95,7 +97,14 @@ export const projectRelations = relations(project, ({one}) => ({
|
||||
export const userSchema = createSelectSchema(user);
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
|
||||
type FixedAccount = Omit<Account, 'updatedAt'> & {
|
||||
export const sessionSchema = createSelectSchema(session);
|
||||
export type Session = z.infer<typeof sessionSchema>;
|
||||
|
||||
export const accountSchema = createSelectSchema(account);
|
||||
export type Account = z.infer<typeof accountSchema>;
|
||||
|
||||
|
||||
type FixedAccount = Omit<BetterAuthAccount, 'updatedAt'> & {
|
||||
updatedAt: Date | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@ import {notFound} from "next/navigation";
|
||||
import {SidebarTrigger} from "@/components/ui/sidebar";
|
||||
import {ModeToggle} from "@/features/theme/ModeToggle";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button";
|
||||
import {BreadCrumbsWrapper} from "@/components/wrappers/common/bread-crumbs/bread-crumbs";
|
||||
import GitHubStarsButtonCustom from "@/components/wrappers/common/github/github-button";
|
||||
// import GitHubStarsButtonCustom from "@/components/wrappers/common/github-button";
|
||||
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button";
|
||||
|
||||
export const Header = async () => {
|
||||
const user = await currentUser();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
import {createAuthClient} from "better-auth/react";
|
||||
|
||||
import {adminClient, inferAdditionalFields, organizationClient} from "better-auth/client/plugins";
|
||||
import {adminClient, inferAdditionalFields, organizationClient, twoFactorClient} from "better-auth/client/plugins";
|
||||
import {ac, user, admin as adminRole, pending, superadmin, orgAdmin, orgMember, orgOwner} from "./permissions";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
@@ -12,6 +12,7 @@ const {PROJECT_URL} = await res.json();
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: PROJECT_URL,
|
||||
plugins: [
|
||||
twoFactorClient(),
|
||||
organizationClient({
|
||||
ac,
|
||||
roles: {
|
||||
@@ -34,4 +35,4 @@ export const authClient = createAuthClient({
|
||||
|
||||
});
|
||||
|
||||
export const {signIn, signOut, signUp, useSession, listAccounts, admin} = authClient;
|
||||
export const {signIn, signOut, signUp, useSession, listAccounts, admin, requestPasswordReset} = authClient;
|
||||
|
||||
+59
-14
@@ -4,7 +4,7 @@ import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {env} from "@/env.mjs";
|
||||
import {nextCookies} from "better-auth/next-js";
|
||||
import {admin as adminPlugin, openAPI, Organization, organization} from "better-auth/plugins";
|
||||
import {admin as adminPlugin, openAPI, Organization, organization, twoFactor} from "better-auth/plugins";
|
||||
import {ac, admin, orgAdmin, orgMember, orgOwner, pending, superadmin, user} from "@/lib/auth/permissions";
|
||||
import {headers} from "next/headers";
|
||||
import {count, eq} from "drizzle-orm";
|
||||
@@ -12,45 +12,87 @@ import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_or
|
||||
import {sendEmail} from "@/lib/email/email-helper";
|
||||
import {render} from "@react-email/render";
|
||||
import EmailResetPassword from "@/components/emails/email-reset-password";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../portabase.config";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import EmailVerification from "@/components/emails/email-verification";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: drizzleDb.schemas,
|
||||
}),
|
||||
baseURL: env.PROJECT_URL,
|
||||
secret: env.PROJECT_SECRET,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false,
|
||||
sendResetPassword: async ({user, url, token}, request) => {
|
||||
|
||||
const [updatedUser] = await db.update(drizzleDb.schemas.user).set(withUpdatedAt({
|
||||
emailVerified: true,
|
||||
})).where(eq(drizzleDb.schemas.user.id, user.id)).returning();
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your password",
|
||||
html: await render(EmailResetPassword({url: url})),
|
||||
});
|
||||
|
||||
},
|
||||
onPasswordReset: async ({user}, request) => {
|
||||
console.log(`Password for user ${user.email} has been reset.`);
|
||||
},
|
||||
|
||||
/*
|
||||
async sendVerificationEmail(data, request) {
|
||||
// Send an email to the user with a link to verify their email
|
||||
},
|
||||
async verifyEmail(data, request) {
|
||||
// Verify the email address
|
||||
},*/
|
||||
|
||||
},
|
||||
socialProviders: {
|
||||
google: {
|
||||
clientId: env.AUTH_GOOGLE_ID!,
|
||||
clientSecret: env.AUTH_GOOGLE_SECRET!,
|
||||
emailVerification: {
|
||||
async sendVerificationEmail({user, token, url}) {
|
||||
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Portabase Email Verification",
|
||||
html: await render(EmailVerification({
|
||||
firstname: user.name,
|
||||
url: url
|
||||
})),
|
||||
});
|
||||
|
||||
await (
|
||||
await auth.$context
|
||||
).internalAdapter.updateUser(user.id, {
|
||||
emailVerified: false,
|
||||
});
|
||||
},
|
||||
async afterEmailVerification(user) {
|
||||
await (
|
||||
await auth.$context
|
||||
).internalAdapter.updateUser(user.id, {
|
||||
emailVerified: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
socialProviders: SUPPORTED_PROVIDERS.reduce((acc: any, provider: any) => {
|
||||
if (provider.id === "credential") return acc;
|
||||
if (provider.id === "google") {
|
||||
acc.google = {
|
||||
clientId: env.AUTH_GOOGLE_ID! as string,
|
||||
clientSecret: env.AUTH_GOOGLE_SECRET! as string,
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
plugins: [
|
||||
openAPI(),
|
||||
nextCookies(),
|
||||
twoFactor(),
|
||||
organization({
|
||||
ac,
|
||||
roles: {
|
||||
@@ -80,6 +122,9 @@ export const auth = betterAuth({
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
},
|
||||
changeEmail: {
|
||||
enabled: true,
|
||||
},
|
||||
additionalFields: {
|
||||
deletedAt: {
|
||||
type: "number",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const zString = () =>
|
||||
z.string();
|
||||
|
||||
export const zEnum = <T extends [string, ...string[]]>(values: T) => z.enum(values, { message: "Field required" });
|
||||
|
||||
export const zEmail = () => z.string().email({ message: "Invalid email" });
|
||||
|
||||
const passwordRegex = /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-])/;
|
||||
|
||||
export const zPassword = () => zString().min(8, { message: "New Password too short" }).regex(passwordRegex, { message: "New password too weak" });
|
||||
|
||||
export const zDate = () =>
|
||||
z.preprocess(
|
||||
(arg) => {
|
||||
if (typeof arg === "string" || arg instanceof Date) {
|
||||
const date = new Date(arg);
|
||||
return isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
z.date()
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
export type SignUpUser = {
|
||||
name: string
|
||||
email: string
|
||||
password: string
|
||||
callbackURL?: string
|
||||
theme: string
|
||||
isDefaultPassword: boolean
|
||||
}
|
||||
|
||||
|
||||
export type BetterAuthError = {
|
||||
code?: string;
|
||||
message?: string;
|
||||
status: number;
|
||||
statusText: string;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import {formatDistanceToNow} from "date-fns";
|
||||
import {format} from "date-fns";
|
||||
|
||||
/**
|
||||
* Get user's locale and timezone from the browser
|
||||
@@ -36,3 +37,4 @@ export function formatDateLastContact(lastContact: string | number | Date | null
|
||||
? formatLocalizedDate(lastContact)
|
||||
: "Never connected.";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Smartphone, Laptop, type LucideIcon } from "lucide-react";
|
||||
|
||||
interface DeviceDetails {
|
||||
os: string;
|
||||
browser: string;
|
||||
isMobile: boolean;
|
||||
Icon: LucideIcon;
|
||||
deviceType: "Mobile" | "Desktop";
|
||||
}
|
||||
|
||||
export const getDeviceDetails = (userAgent: string | undefined | null): DeviceDetails => {
|
||||
const ua = (userAgent || "").toLowerCase();
|
||||
|
||||
const isMobile = /mobile|iphone|android/.test(ua);
|
||||
|
||||
const os = /mac os/.test(ua)
|
||||
? "macOS"
|
||||
: /windows/.test(ua)
|
||||
? "Windows"
|
||||
: /linux/.test(ua)
|
||||
? "Linux"
|
||||
: /android/.test(ua)
|
||||
? "Android"
|
||||
: /ios/.test(ua)
|
||||
? "iOS"
|
||||
: "Unknown OS";
|
||||
|
||||
const browser = /chrome/.test(ua) ? "Chrome" : /firefox/.test(ua) ? "Firefox" : /safari/.test(ua) ? "Safari" : /edg/.test(ua) ? "Edge" : "Browser";
|
||||
|
||||
return {
|
||||
os,
|
||||
browser,
|
||||
isMobile,
|
||||
deviceType: isMobile ? "Mobile" : "Desktop",
|
||||
Icon: isMobile ? Smartphone : Laptop,
|
||||
};
|
||||
};
|
||||
@@ -11869,6 +11869,7 @@ __metadata:
|
||||
react-dropzone: "npm:^14.3.8"
|
||||
react-email: "npm:^4.0.13"
|
||||
react-hook-form: "npm:^7.56.3"
|
||||
react-qr-code: "npm:^2.0.18"
|
||||
react-resizable-panels: "npm:^3.0.2"
|
||||
react-twc: "npm:^1.4.2"
|
||||
react-use-measure: "npm:^2.1.7"
|
||||
@@ -12147,6 +12148,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"qr.js@npm:0.0.0":
|
||||
version: 0.0.0
|
||||
resolution: "qr.js@npm:0.0.0"
|
||||
checksum: 10c0/1c6a4c7a58d04e52ec2fee99e39b680fdc5b2a510a981df42c36b716a8eac6634d130fc4d65af8f030f2a07dbf5fa046b97cdfa7456c250ebb50a73916efdcb5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"query-string@npm:^7.1.3":
|
||||
version: 7.1.3
|
||||
resolution: "query-string@npm:7.1.3"
|
||||
@@ -12291,6 +12299,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-qr-code@npm:^2.0.18":
|
||||
version: 2.0.18
|
||||
resolution: "react-qr-code@npm:2.0.18"
|
||||
dependencies:
|
||||
prop-types: "npm:^15.8.1"
|
||||
qr.js: "npm:0.0.0"
|
||||
peerDependencies:
|
||||
react: "*"
|
||||
checksum: 10c0/4e13b795cbb10f1dcf0e39d682bb59851e4c84010ba2be7225b2ad9d5c1ffea52d2d38f884ee26235b7002b8ca99e83b805f55e877663c39d67496764d975cf1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-remove-scroll-bar@npm:^2.3.7":
|
||||
version: 2.3.8
|
||||
resolution: "react-remove-scroll-bar@npm:2.3.8"
|
||||
|
||||
Reference in New Issue
Block a user