Compare commits

..
16 Commits
122 changed files with 11344 additions and 863 deletions
Binary file not shown.
+2 -2
View File
@@ -9,7 +9,7 @@
Free, open-source, and self-hosted solution for automated backup and restoration of your database instances.
</p>
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/solucetechnologies/portabase?color=brightgreen)](https://hub.docker.com/r/solucetechnologies/portabase)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](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:
+22 -12
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+44 -5
View File
@@ -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&apos;t have an account ?{" "}
<Link href="/register" className="underline">
Sign up
</Link>
</div>
</CardContent>
</CardAuth>
</TooltipProvider>
)
}
+56 -12
View File
@@ -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 && (
+13 -13
View File
@@ -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>
);
}
-62
View File
@@ -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>
);
}
+4 -3
View File
@@ -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 -2
View File
@@ -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});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+1 -3
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

+8 -5
View File
@@ -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>
);
+11 -6
View File
@@ -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>
+5
View File
@@ -41,6 +41,11 @@ const nextConfig: NextConfig = {
typescript: {
ignoreBuildErrors: true,
},
experimental: {
serverActions: {
bodySizeLimit: "50mb",
},
},
async headers() {
return [
{
+4 -3
View File
@@ -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",
+46
View File
@@ -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: "",
},
},
];
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

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;
+1 -1
View File
@@ -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;
+15 -1
View File
@@ -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&apos;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;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "user" ADD COLUMN "lastChangedPasswordAt" timestamp;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "two_factor_enabled" boolean DEFAULT false;

Some files were not shown because too many files have changed in this diff Show More