Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f7f8e642f | ||
|
|
d9997ff5e9 | ||
|
|
36adcb323a | ||
|
|
5302060f44 | ||
|
|
d19a46bee6 | ||
|
|
d504866aad | ||
|
|
601dae4909 | ||
|
|
08f2ad0521 | ||
|
|
3b356e33e7 | ||
|
|
57d9b8da1e | ||
|
|
86f50e5741 | ||
|
|
a6e52fd03d | ||
|
|
116229a29e | ||
|
|
428782e8a4 | ||
|
|
b8442803d9 | ||
|
|
82ab2c63d6 |
@@ -9,7 +9,7 @@
|
||||
Free, open-source, and self-hosted solution for automated backup and restoration of your database instances.
|
||||
</p>
|
||||
|
||||
[](LICENSE)
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
||||
[](https://github.com/RostislavDugin/postgresus)
|
||||
|
||||
@@ -86,7 +86,7 @@ services:
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
- TIME_ZONE="Europe/Paris"
|
||||
- TZ="Europe/Paris"
|
||||
volumes:
|
||||
- portabase-private:/app/private
|
||||
depends_on:
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Metadata} from "next";
|
||||
import {ForgotPasswordForm} from "@/components/wrappers/auth/forgot-password/forgot-password-form";
|
||||
import {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";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
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>
|
||||
<CardAuth 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>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {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";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export default async function GuardPage() {
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("better-auth.two_factor")?.value;
|
||||
|
||||
if (!token) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<CardAuth 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>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,56 @@
|
||||
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 {CardContent, CardHeader} from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
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>
|
||||
<CardAuth 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 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>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,59 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {Metadata} from "next";
|
||||
import {ResetPasswordSection} from "@/components/wrappers/auth/reset-password/reset-password-section";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ResetPasswordForm } from "@/components/wrappers/auth/login/reset-password-form/reset-password-form";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@radix-ui/react-avatar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Reset Password",
|
||||
};
|
||||
export default async function RoutePage(props: { searchParams: Promise<{ token: string | undefined }> }) {
|
||||
|
||||
const { token } = await props.searchParams;
|
||||
|
||||
if (!token) {
|
||||
return redirect(`/login?error=invalid_or_expired_token`);
|
||||
}
|
||||
|
||||
const verification = await (await auth.$context).internalAdapter.findVerificationValue(`reset-password:${token}`);
|
||||
|
||||
if (!verification || verification.expiresAt < new Date()) {
|
||||
return redirect(`/login?error=invalid_or_expired_token`);
|
||||
}
|
||||
|
||||
const user = await (await auth.$context).internalAdapter.findUserById(verification.value);
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<ResetPasswordSection/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<TooltipProvider>
|
||||
<Card className="w-full max-w-md shadow-lg">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="space-y-1 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Set a new password</h1>
|
||||
<p className="text-sm text-muted-foreground text-balance">Please enter your new password below.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2 text-center">
|
||||
<Avatar className="relative flex h-16 w-16 shrink-0 overflow-hidden rounded-full border">
|
||||
<AvatarImage src={user!.image ?? ""} alt={user!.name} className="aspect-square h-full w-full object-cover" />
|
||||
<AvatarFallback className="flex h-full w-full items-center justify-center rounded-full bg-muted text-2xl font-medium text-muted-foreground">
|
||||
{user!.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="font-semibold text-lg">{user!.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{user!.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<ResetPasswordForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,9 +18,14 @@ export const metadata: Metadata = {
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const agents = await db.query.agent.findMany({
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true))
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
|
||||
with: {
|
||||
databases: true
|
||||
}
|
||||
});
|
||||
|
||||
console.log(agents);
|
||||
|
||||
|
||||
if (!agents) {
|
||||
notFound();
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import {PageParams} from "@/types/next";
|
||||
import {notFound, redirect} from "next/navigation";
|
||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
|
||||
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
|
||||
import {EditButton} from "@/components/wrappers/dashboard/database/edit-button/edit-button";
|
||||
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
|
||||
|
||||
import {db} from "@/db";
|
||||
import {eq, and, inArray} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
@@ -92,22 +90,23 @@ export default async function RoutePage(props: PageParams<{
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full">
|
||||
<div className=" w-full md:w-fit">
|
||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(dbItem.name)}
|
||||
</div>
|
||||
{!isMember && (
|
||||
<div className="flex items-center gap-2 md:justify-between w-full">
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Do not delete*/}
|
||||
{/*<EditButton/>*/}
|
||||
<RetentionPolicySheet database={dbItem}/>
|
||||
<CronButton database={dbItem}/>
|
||||
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels} organizationId={organization.id} />
|
||||
<AlertPolicyModal database={dbItem} notificationChannels={activeOrganizationChannels}
|
||||
organizationId={organization.id}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||
@@ -115,6 +114,8 @@ export default async function RoutePage(props: PageParams<{
|
||||
</div>
|
||||
)}
|
||||
</PageTitle>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{dbItem.description && (
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import React from "react";
|
||||
import React, {ReactNode} from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
|
||||
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
|
||||
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
||||
import {Header} from "@/features/layout/Header";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
|
||||
|
||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||
export default async function Layout({children}: { children: ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</>
|
||||
<SidebarProvider>
|
||||
<div className="flex flex-col lg:flex-row w-full">
|
||||
<ThemeMetaUpdater/>
|
||||
<AppSidebar/>
|
||||
<SidebarInset>
|
||||
<Header/>
|
||||
<main className="h-full">{children}</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {db} from "@/db";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
|
||||
export type databaseAgent = {
|
||||
name: string,
|
||||
@@ -15,12 +16,13 @@ export type databaseAgent = {
|
||||
}
|
||||
|
||||
export type Body = {
|
||||
version: string,
|
||||
databases: databaseAgent[]
|
||||
}
|
||||
|
||||
// Function to test the get file url presigned local
|
||||
export async function GET(request: Request) {
|
||||
const url = await getFileUrlPresignedLocal({fileName:"d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
|
||||
const url = await getFileUrlPresignedLocal({fileName: "d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
|
||||
return Response.json({
|
||||
message: url
|
||||
})
|
||||
@@ -60,7 +62,10 @@ export async function POST(
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.agent)
|
||||
.set({lastContact: lastContact})
|
||||
.set(withUpdatedAt({
|
||||
version: body.version,
|
||||
lastContact: lastContact
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -16,19 +16,22 @@ export const metadata: Metadata = {
|
||||
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="apple-mobile-web-app-title" content="Portabase"/>
|
||||
<meta name="apple-mobile-web-app-title" content={title}/>
|
||||
</head>
|
||||
<body className={cn(inter.className, "h-full")}>
|
||||
<ConsoleSilencer/>
|
||||
<Providers>{children}</Providers>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren, Suspense } from "react";
|
||||
import { ThemeProvider } from "@/features/theme/theme-provider";
|
||||
import {PropsWithChildren, Suspense} from "react";
|
||||
import {ThemeProvider} from "@/features/theme/theme-provider";
|
||||
|
||||
import {Toaster} from "@/components/ui/sonner";
|
||||
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
export type ProviderProps = PropsWithChildren<{}>;
|
||||
const queryClient = new QueryClient();
|
||||
@@ -12,9 +13,13 @@ const queryClient = new QueryClient();
|
||||
export const Providers = (props: ProviderProps) => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster />
|
||||
<Toaster/>
|
||||
{props.children}
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -41,6 +41,11 @@ const nextConfig: NextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "50mb",
|
||||
},
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -52,7 +52,7 @@
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
"argon2": "^0.43.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.4.2",
|
||||
"better-auth": "1.4.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -66,7 +66,7 @@
|
||||
"lucide-react": "^0.553.0",
|
||||
"minio": "^8.0.5",
|
||||
"motion": "^12.23.24",
|
||||
"next": "16.0.7",
|
||||
"next": "16.0.10",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
@@ -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,36 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [
|
||||
{
|
||||
id: "google",
|
||||
icon: Chrome,
|
||||
// isManual: true,
|
||||
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: "",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "../email-layout";
|
||||
import {Heading, Text, Section, Button} from "@react-email/components";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const EmailForgotPassword = ({firstname, token}: EmailCreateUserProps) => {
|
||||
const baseUrl = getServerUrl();
|
||||
|
||||
return (
|
||||
<EmailLayout preview="Portabase - Forgot Password">
|
||||
<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 reset the password for your account associated with this email.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
If you did not request this password reset, please ignore this email. Your current password
|
||||
will remain unchanged. If this seems suspicious, please contact your administrator.
|
||||
</Text>
|
||||
<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={`${baseUrl}/reset-password?token=${token}`}
|
||||
>
|
||||
Reset my password
|
||||
</Button>
|
||||
</Section>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailForgotPassword;
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "../email-layout";
|
||||
import {Heading, Text} from "@react-email/components";
|
||||
|
||||
interface EmailCreateUserProps {
|
||||
firstname?: string;
|
||||
os: string;
|
||||
browser: string;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export const EmailNewLogin = ({firstname, ipAddress, os, browser}: EmailCreateUserProps) => {
|
||||
return (
|
||||
<EmailLayout preview="Portabase - New Login">
|
||||
<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 detected a new login to your account.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-black leading-[24px]">
|
||||
<strong>Login details:</strong>
|
||||
<br/>
|
||||
Device: {os} - {browser}
|
||||
<br/>
|
||||
IP Address: {ipAddress}
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailNewLogin;
|
||||
@@ -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;
|
||||
@@ -13,7 +13,7 @@ export const EmailLayout = ({ children, preview }: PropsWithChildren<{ preview?:
|
||||
{preview ? <Preview>{preview}</Preview> : <Preview>Please check your mails</Preview>}
|
||||
<Body className="bg-gray-100 py-4" style={{ fontFamily: "Arial, sans-serif" }}>
|
||||
<Container className="bg-white border border-gray-200 p-12">
|
||||
<Img src={`${baseUrl}/images/logo-black.png`} width="200" height="auto" alt="Logo" />
|
||||
<Img src={`${baseUrl}/images/logo-dark.png`} width="200" height="auto" alt="Logo" />
|
||||
<Section>{children}</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import * as React from "react";
|
||||
import EmailLayout from "./email-layout";
|
||||
import {Text, Section, Button} from "@react-email/components";
|
||||
|
||||
export interface EmailResetPasswordProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const EmailResetPassword = ({url}: EmailResetPasswordProps) => {
|
||||
return (
|
||||
<EmailLayout preview="Email for password reset of your Portabase account">
|
||||
<Text className="text-base font-bold ">Hello !</Text>
|
||||
<Text className="text-base font-light ">You are receiving this email because we
|
||||
received a password reset request for your account.</Text>{" "}
|
||||
<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={url}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
</Section>
|
||||
<Text className="text-base font-light ">If you did not request a password reset, no
|
||||
further action is required.</Text>
|
||||
<Text className="text-base font-light ">Regards,<br/>Portabase</Text>
|
||||
</EmailLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailResetPassword;
|
||||
@@ -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,31 +1,49 @@
|
||||
"use client"
|
||||
import {env} from "@/env.mjs";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useTheme} from "next-themes";
|
||||
"use client";
|
||||
|
||||
import {env} from "@/env.mjs";
|
||||
import {useTheme} from "next-themes";
|
||||
import Image from "next/image";
|
||||
import {useEffect, useState} from "react";
|
||||
|
||||
export const AuthLogoSection = () => {
|
||||
|
||||
const {resolvedTheme} = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
const imageTheme =
|
||||
resolvedTheme === "dark"
|
||||
? "/images/logo-dark.png"
|
||||
: "/images/logo-light.png";
|
||||
|
||||
const handleLoad = () => setLoaded(true);
|
||||
|
||||
const style = {
|
||||
transition: "opacity 0.3s ease-in-out",
|
||||
opacity: loaded ? 1 : 0,
|
||||
};
|
||||
|
||||
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
|
||||
|
||||
return (
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
|
||||
<img
|
||||
className="p-12 text-black dark:text-white"
|
||||
src={imageTheme}
|
||||
alt="Logo"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground -ml-12 -mb-12">v{env.NEXT_PUBLIC_PROJECT_VERSION}</span>
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md relative flex items-center justify-center h-[160px]">
|
||||
{mounted && (
|
||||
<Image
|
||||
src={imageTheme}
|
||||
alt="Logo"
|
||||
fill
|
||||
priority
|
||||
className="object-contain p-10"
|
||||
onLoad={handleLoad}
|
||||
style={style}
|
||||
|
||||
/>
|
||||
)}
|
||||
<span className="absolute bottom-10 right-5 text-sm text-muted-foreground" style={style}>
|
||||
v{env.NEXT_PUBLIC_PROJECT_VERSION}
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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("If an account with this email address exists, you will receive an email with instructions to reset your password.");
|
||||
},
|
||||
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,123 @@
|
||||
"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) => {
|
||||
console.log(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>
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import {RegisterSchema, RegisterType} from "@/components/wrappers/auth/register/
|
||||
import {PasswordInput} from "@/components/ui/password-input";
|
||||
import {signUp} from "@/lib/auth/auth-client";
|
||||
import Link from "next/link";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
export type registerFormProps = {
|
||||
defaultValues?: RegisterType;
|
||||
@@ -45,7 +46,7 @@ export const RegisterForm = (props: registerFormProps) => {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Card>
|
||||
<CardAuth>
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Create an account</h1>
|
||||
@@ -144,7 +145,7 @@ export const RegisterForm = (props: registerFormProps) => {
|
||||
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardAuth>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {toast} from "sonner";
|
||||
import {CardAuth} from "@/features/layout/card-auth";
|
||||
|
||||
type ResetPasswordFormProps = {
|
||||
token: string;
|
||||
@@ -43,7 +44,7 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardAuth>
|
||||
<CardHeader>
|
||||
<div className="grid gap-2 text-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Reset Password</h1>
|
||||
@@ -89,6 +90,6 @@ export const ResetPasswordForm = ({token}: ResetPasswordFormProps) => {
|
||||
</ButtonWithLoading>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardAuth>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export function ComboBox<T = string>(props: ComboBoxProps<T>) {
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
style={{ width: 'var(--radix-popover-trigger-width)' }}
|
||||
|
||||
>
|
||||
<Command>
|
||||
{searchField && <CommandInput placeholder="Search choice..." className="h-9"/>}
|
||||
|
||||
@@ -1,36 +1,3 @@
|
||||
// import Link from "next/link";
|
||||
// import {cn} from "@/lib/utils";
|
||||
// import {Plus} from "lucide-react";
|
||||
//
|
||||
// type EmptyStatePlaceholderProps = {
|
||||
// url?: string;
|
||||
// text: string;
|
||||
// className?: string;
|
||||
// }
|
||||
//
|
||||
// export const EmptyStatePlaceholder = ({url, text, className}: EmptyStatePlaceholderProps) => {
|
||||
// return (
|
||||
// <div className={cn("",className)}>{url ?
|
||||
// <Link
|
||||
// href={url}
|
||||
// className={cn(
|
||||
// "h-full",
|
||||
// "flex flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
|
||||
// "hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-2"
|
||||
// )}
|
||||
// >
|
||||
// <Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
|
||||
// <span className="text-sm lg:text-base font-medium">{text}</span>
|
||||
// </Link>
|
||||
// :
|
||||
// <div className="flex h-full flex-col items-center justify-center py-12 text-center">
|
||||
// <p className="text-lg text-muted-foreground">{text}</p>
|
||||
// </div>
|
||||
// }
|
||||
// </div>
|
||||
//
|
||||
// )
|
||||
// }
|
||||
import Link from "next/link";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Plus} from "lucide-react";
|
||||
@@ -48,25 +15,27 @@ export const EmptyStatePlaceholder = ({
|
||||
text,
|
||||
className,
|
||||
}: EmptyStatePlaceholderProps) => {
|
||||
const content = (
|
||||
const Container = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
|
||||
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-4 cursor-pointer",
|
||||
onClick || url ? "cursor-pointer" : "cursor-default"
|
||||
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-4",
|
||||
(onClick || url) && "cursor-pointer"
|
||||
)}
|
||||
{...(onClick ? {onClick} : url ? {asChild: true} : {})}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
|
||||
<div>
|
||||
<p className="text-sm ">{text}</p>
|
||||
</div>
|
||||
<Plus className="w-5 h-5 lg:w-6 lg:h-6" />
|
||||
<p className="text-sm">{text}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("", className)}>
|
||||
{url ? <Link href={url}>{content}</Link> : content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
if (url) {
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<Link href={url}>{Container}</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn(className)}>{Container}</div>;
|
||||
};
|
||||
|
||||
@@ -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,17 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { formatDateLastContact } from "@/utils/date-formatting";
|
||||
import { ConnectionCircle } from "@/components/wrappers/common/connection-circle";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {ConnectionCircle} from "@/components/wrappers/common/connection-circle";
|
||||
import {Agent, AgentWith} from "@/db/schema/08_agent";
|
||||
import {Activity, Database, ShieldCheck} from "lucide-react";
|
||||
|
||||
export type agentCardProps = {
|
||||
data: Agent;
|
||||
data: AgentWith;
|
||||
};
|
||||
|
||||
export const AgentCard = (props: agentCardProps) => {
|
||||
const { data: agent } = props;
|
||||
const {data: agent} = props;
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/agents/${agent.id}`}
|
||||
@@ -20,10 +21,31 @@ export const AgentCard = (props: agentCardProps) => {
|
||||
<Card className="flex flex-row justify-between">
|
||||
<div className="flex-1 text-left">
|
||||
<CardHeader className="text-2xl font-bold">{agent.name}</CardHeader>
|
||||
<CardContent>Last contact: {formatDateLastContact(agent.lastContact)}</CardContent>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4"/>
|
||||
<span>{formatDateLastContact(agent.lastContact)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4"/>
|
||||
<span>{agent.databases?.length ?? 0} DB</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4"/>
|
||||
{agent.version ?
|
||||
<span>v{agent.version}</span>
|
||||
:
|
||||
<span>N/A</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
<div className="flex items-center px-4">
|
||||
<ConnectionCircle date={agent.lastContact} />
|
||||
<ConnectionCircle date={agent.lastContact}/>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getAccounts, getSession, getSessions} from "@/lib/auth/auth";
|
||||
import {LoggedInButtonClient} from "./logged-in-button";
|
||||
|
||||
export const LoggedInButton = async () => {
|
||||
const user = await currentUser();
|
||||
const sessions = await getSessions();
|
||||
const currentSession = await getSession();
|
||||
const accounts = await getAccounts();
|
||||
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<LoggedInButtonClient
|
||||
user={user}
|
||||
sessions={sessions}
|
||||
// @ts-ignore
|
||||
currentSession={currentSession.session}
|
||||
accounts={accounts}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +1,82 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar";
|
||||
import { ChevronUp } from "lucide-react";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import {LoggedInDropdown} from "@/components/wrappers/dashboard/common/logged-in/logged-in-dropdown";
|
||||
// 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;
|
||||
//
|
||||
//
|
||||
// return (
|
||||
// <>
|
||||
// <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>
|
||||
// </>
|
||||
// );
|
||||
// };
|
||||
"use client";
|
||||
|
||||
export const LoggedInButton = async () => {
|
||||
const user = await currentUser();
|
||||
import {ChevronUp} from "lucide-react";
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
import {SidebarMenuButton} from "@/components/ui/sidebar";
|
||||
import {LoggedInDropdown} from "./logged-in-dropdown";
|
||||
import {Account, Session, User} from "better-auth";
|
||||
|
||||
if (!user) return null;
|
||||
type LoggedInButtonClientProps = {
|
||||
user: User;
|
||||
sessions: Session[];
|
||||
currentSession: Session;
|
||||
accounts: Account[];
|
||||
}
|
||||
|
||||
export const LoggedInButtonClient = ({user, sessions, currentSession, accounts}: LoggedInButtonClientProps) => {
|
||||
|
||||
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,
|
||||
}}
|
||||
// @ts-ignore
|
||||
user={user}
|
||||
// @ts-ignore
|
||||
sessions={sessions}
|
||||
// @ts-ignore
|
||||
currentSession={currentSession}
|
||||
// @ts-ignore
|
||||
accounts={accounts}
|
||||
>
|
||||
<SidebarMenuButton>
|
||||
<SidebarMenuButton type="button">
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
|
||||
{user.image ? <AvatarImage src={user.image} alt={`${user.name ?? "-"}'s profile picture`} /> : null}
|
||||
{user.image && <AvatarImage src={user.image}/>}
|
||||
</Avatar>
|
||||
|
||||
<span className="first-letter:capitalize">{user.name}</span>
|
||||
<ChevronUp className="ml-auto" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -9,13 +9,13 @@ import {SidebarLogo} from "@/components/wrappers/dashboard/common/sidebar/logo-s
|
||||
import {SidebarMenuCustomMain} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar-main";
|
||||
import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sidebar/side-bar-footer-credit";
|
||||
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
|
||||
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button";
|
||||
import {env} from "@/env.mjs";
|
||||
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button.server";
|
||||
|
||||
export function AppSidebar() {
|
||||
const projectName = env.PROJECT_NAME;
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<Sidebar collapsible="icon" >
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
@@ -34,6 +34,7 @@ export function AppSidebar() {
|
||||
<SidebarMenu className="mb-2">
|
||||
<SidebarMenuItem className="p-2">
|
||||
<LoggedInButton/>
|
||||
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SideBarFooterCredit/>
|
||||
|
||||
@@ -1,48 +1,64 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import {Skeleton} from "@/components/ui/skeleton";
|
||||
|
||||
export const SidebarLogo = ({projectName}: {
|
||||
projectName: string;
|
||||
}) => {
|
||||
export const SidebarLogo = ({ projectName }: { projectName: string }) => {
|
||||
const { state, isMobile } = useSidebar();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
if (!mounted) return(
|
||||
<div className="m-4 w-[190px] h-[45px]">
|
||||
<Skeleton className="w-full h-full bg-transparent" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
|
||||
const imageTheme =
|
||||
resolvedTheme === "dark" ? "/images/logo-dark.png" : "/images/logo-light.png";
|
||||
|
||||
const handleLoad = () => setLoaded(true);
|
||||
|
||||
const style = {
|
||||
transition: "opacity 0.3s ease-in-out",
|
||||
opacity: loaded ? 1 : 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ml-1 flex items-center justify-center">
|
||||
<Link href={"/"}>
|
||||
{state === 'collapsed' && !isMobile ? (
|
||||
<Link href="/dashboard/home">
|
||||
{state === "collapsed" && !isMobile ? (
|
||||
<Image
|
||||
loading="eager"
|
||||
src={"/images/logo.png"}
|
||||
src="/images/logo.png"
|
||||
alt={`Logo ${projectName}`}
|
||||
className="h-10 w-10 object-contain"
|
||||
height={10}
|
||||
width={10}
|
||||
width={40}
|
||||
height={40}
|
||||
loading="eager"
|
||||
priority
|
||||
style={style}
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
) : (
|
||||
<div className="m-4">
|
||||
<div className="m-4 w-[190px] h-[45px] ">
|
||||
<Image
|
||||
loading="eager"
|
||||
src={imageTheme}
|
||||
alt={`Logo ${projectName}`}
|
||||
className="object-contain"
|
||||
width={190}
|
||||
height={90}
|
||||
loading="eager"
|
||||
priority
|
||||
style={style}
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -91,7 +91,7 @@ export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseP
|
||||
<Collapsible key={index} defaultOpen className="group/group-collapsible">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger className="flex w-full items-center text-sm font-medium text-sidebar-foreground/70">
|
||||
<CollapsibleTrigger disabled={group.type == "list"} className="flex w-full items-center text-sm font-medium text-sidebar-foreground/70">
|
||||
{group.label}
|
||||
{group.type === "collapse" && (
|
||||
<ChevronDown className="ml-auto h-4 w-4 transition-transform group-data-[state=open]/group-collapsible:rotate-180" />
|
||||
|
||||
@@ -13,9 +13,12 @@ export function OrganizationCombobox() {
|
||||
const {data: activeOrganization, refetch: refetchActiveOrga} = authClient.useActiveOrganization();
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
|
||||
if (!organizations) return null;
|
||||
// if (!organizations) return null;
|
||||
|
||||
// const values = organizations.map(org => ({ value: org.slug, label: org.name }));
|
||||
const values = organizations?.map(org => ({ value: org.slug, label: org.name })) ?? [];
|
||||
|
||||
|
||||
const values = organizations.map(org => ({ value: org.slug, label: org.name }));
|
||||
|
||||
const onValueChange = async (slug: string) => {
|
||||
await authClient.organization.setActive({ organizationSlug: slug });
|
||||
|
||||
@@ -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,256 @@
|
||||
"use client";
|
||||
|
||||
import React, {useState} from "react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from "@/components/ui/dialog";
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Loader2, Copy, CheckCircle2, ShieldCheck} from "lucide-react";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {Setup2FASecuritySchema, Setup2FASecuritySchemaType} from "../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";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
password: zPassword(),
|
||||
});
|
||||
|
||||
type Password = z.infer<typeof PasswordSchema>;
|
||||
|
||||
type Setup2FAModalProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export function Setup2FAProfileProviderModal({onOpenChange, open, disabled}: Setup2FAModalProps) {
|
||||
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD");
|
||||
|
||||
const [totpURI, setTotpURI] = useState<string>("");
|
||||
const [secret, setSecret] = useState<string>("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
|
||||
const form = useZodForm({
|
||||
schema: Setup2FASecuritySchema,
|
||||
defaultValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const passwordForm = useZodForm({
|
||||
schema: PasswordSchema,
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const {mutate: enable2FA, isPending: isEnabling} = useMutation({
|
||||
mutationFn: async (values: Password) => {
|
||||
const {data, error} = await authClient.twoFactor.enable({
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setTotpURI(data.totpURI);
|
||||
setSecret(data.totpURI.split("secret=")[1].split("&")[0]);
|
||||
setBackupCodes(data.backupCodes || []);
|
||||
setStep("QR");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to enable two-factor authentication.");
|
||||
},
|
||||
});
|
||||
|
||||
const {mutate: verify2FA, isPending: isVerifying} = useMutation({
|
||||
mutationFn: async (values: Setup2FASecuritySchemaType) => {
|
||||
const {data, error} = await authClient.twoFactor.verifyTotp({
|
||||
code: values.code,
|
||||
trustDevice: true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Two-factor authentication enabled successfully.");
|
||||
setStep("BACKUP");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("The provided code is invalid.");
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopySecret = () => {
|
||||
navigator.clipboard.writeText(secret);
|
||||
toast.success("Secret copied to clipboard");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
router.refresh();
|
||||
onOpenChange(false);
|
||||
setStep("PASSWORD");
|
||||
form.reset();
|
||||
passwordForm.reset();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}>
|
||||
<DialogTrigger asChild disabled={disabled}>
|
||||
<Button variant="outline" size="sm">
|
||||
<ShieldCheck className="w-4 h-4 mr-2"/>
|
||||
Enable Two-Factor
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Two-Factor Authentication</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "PASSWORD" && ""}
|
||||
{step === "QR" && "Scan the QR code below with your authentication app or enter the secret key manually."}
|
||||
{step === "BACKUP" && "Save these backup codes in a secure location. They can be used to access your account if you lose access to your authentication device."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "PASSWORD" && (
|
||||
<Form form={passwordForm} onSubmit={async (values) => enable2FA(values)}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder="Fill your current password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isEnabling || !passwordForm.formState.isDirty}>
|
||||
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === "QR" && (
|
||||
<Form
|
||||
form={form}
|
||||
onSubmit={async (values) => {
|
||||
verify2FA(values);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center space-y-6 py-4">
|
||||
<div className="p-4 bg-white rounded-xl shadow-sm border">
|
||||
{totpURI && (
|
||||
<QRCode value={totpURI} size={180}
|
||||
style={{height: "auto", maxWidth: "100%", width: "100%"}}
|
||||
viewBox={`0 0 256 256`}/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
<p className="text-xs text-muted-foreground text-center">If you are unable to scan the
|
||||
QR code, you can manually enter the secret key into your authentication app :</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 bg-muted p-2 rounded text-xs font-mono break-all text-center">{secret}</code>
|
||||
<Button type="button" size="icon" variant="ghost" onClick={handleCopySecret}>
|
||||
<Copy className="h-4 w-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full border-t pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({field}) => (
|
||||
<FormItem className="flex flex-col items-center">
|
||||
<FormLabel className="mb-2">Verification Code</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
autoFocus
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
if (value.length === 6) {
|
||||
verify2FA(form.getValues());
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0}/>
|
||||
<InputOTPSlot index={1}/>
|
||||
<InputOTPSlot index={2}/>
|
||||
<InputOTPSlot index={3}/>
|
||||
<InputOTPSlot index={4}/>
|
||||
<InputOTPSlot index={5}/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end w-full">
|
||||
<Button disabled={isVerifying} type="submit">
|
||||
{isVerifying && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
|
||||
I've Configured My App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === "BACKUP" && (
|
||||
<div className="space-y-6 py-4">
|
||||
<Alert variant="default"
|
||||
className="border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-900">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400"/>
|
||||
<AlertDescription
|
||||
className="text-green-700 dark:text-green-400">Two Factor Authentication is now enabled
|
||||
on your account.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<BackupCodesList codes={backupCodes}/>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button onClick={handleClose}>Finish Setup</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||
import { Loader2, FileKey2, RefreshCw, AlertTriangle, Download } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { zPassword } from "@/lib/zod";
|
||||
import { BackupCodesList } from "../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,181 @@
|
||||
"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 [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
||||
|
||||
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
|
||||
|
||||
const {mutate: unlinkAccount} = useMutation({
|
||||
mutationFn: async (providerId: string) => {
|
||||
setLoadingProvider(providerId);
|
||||
const {error} = await authClient.unlinkAccount({ providerId });
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Provider successfully unlinked!");
|
||||
setLoadingProvider(null);
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while unlinking provider.");
|
||||
setLoadingProvider(null);
|
||||
},
|
||||
});
|
||||
|
||||
const {mutate: linkAccount} = useMutation({
|
||||
mutationFn: async (providerId: string) => {
|
||||
setLoadingProvider(providerId);
|
||||
const {error} = await authClient.signIn.social({
|
||||
provider: providerId as "google" | "github" | "credential",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
if (error) throw error;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Provider successfully Linked!");
|
||||
setLoadingProvider(null);
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("An error occurred while linking provider.");
|
||||
setLoadingProvider(null);
|
||||
},
|
||||
});
|
||||
|
||||
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 canUnlink = totalConnected > 1 ;
|
||||
// const isLoading = isUnlinking || isLinking;
|
||||
const isLoading = loadingProvider === provider.id;
|
||||
|
||||
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,220 @@
|
||||
"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
|
||||
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
|
||||
: "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
|
||||
disabled={!credentialAccount}
|
||||
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>;
|
||||
@@ -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,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";
|
||||
@@ -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 @@
|
||||
ALTER TABLE "agents" ADD COLUMN "version" text;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user" ADD COLUMN "lastChangedPasswordAt" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "two_factor_enabled" boolean DEFAULT false;
|
||||