diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index bf265941..7dd050ee 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx index b129a399..9d5b40c3 100644 --- a/app/(auth)/forgot-password/page.tsx +++ b/app/(auth)/forgot-password/page.tsx @@ -1,15 +1,23 @@ -import {PageParams} from "@/types/next"; -import {Metadata} from "next"; -import {ForgotPasswordForm} from "@/components/wrappers/auth/forgot-password/forgot-password-form"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; -export const metadata: Metadata = { - title: "Forgot Password", -}; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { ForgotPasswordForm } from "@/components/wrappers/auth/login/forgot-password-form/forgot-password-form"; + +export default async function RoutePage(props: { searchParams: Promise<{ callbackUrl: string | undefined }> }) { -export default async function RoutePage(props: PageParams<{}>) { return ( -
- -
- ) -} \ No newline at end of file + + + +
+

Reset password

+

Enter your email address and we'll send you a link to reset your password.

+
+
+ + + +
+
+ ); +} diff --git a/app/(auth)/guard/page.tsx b/app/(auth)/guard/page.tsx new file mode 100644 index 00000000..e284605c --- /dev/null +++ b/app/(auth)/guard/page.tsx @@ -0,0 +1,32 @@ +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { GuardForm } from "@/components/wrappers/auth/guard/guard-form"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; + +export default async function GuardPage() { + + const cookieStore = await cookies(); + const token = cookieStore.get("better-auth.two_factor")?.value; + + if (!token) { + redirect("/login"); + } + + + return ( + + + +
+

Two-factor verification

+

Please enter the verification code generated by your authentication app.

+
+
+ + + +
+
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 9f9f55dc..76acd5b6 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,17 +1,55 @@ -import {env} from "@/env.mjs"; import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form"; import {Metadata} from "next"; +import {SUPPORTED_PROVIDERS} from "../../../portabase.config"; +import {SocialAuthButtons} from "@/components/wrappers/auth/social-buttons"; +import {TooltipProvider} from "@/components/ui/tooltip"; +import {Card, CardContent, CardHeader} from "@/components/ui/card"; +import Link from "next/link"; +import {Separator} from "@/components/ui/separator"; export const metadata: Metadata = { title: "Login", }; export default async function SignInPage() { - const authGoogleEnabled = env.AUTH_GOOGLE_METHOD; return ( -
- -
+ + + +
+

Login

+

Fill your login informations

+
+
+ + + + {SUPPORTED_PROVIDERS.filter((p) => !p.isManual).length > 0 && ( + <> + +
+ +
OR
+ +
+ + + + )} + +
+ Don't have an account ?{" "} + + Sign up + +
+
+
+
+ + + + ) } diff --git a/app/(customer)/dashboard/profile/page.tsx b/app/(customer)/dashboard/profile/page.tsx deleted file mode 100644 index f44ccdad..00000000 --- a/app/(customer)/dashboard/profile/page.tsx +++ /dev/null @@ -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 ( - -
- - - {user.name} - {user.role} - -
- - - -
- ); -} diff --git a/app/api/agent/[agentId]/status/helpers.ts b/app/api/agent/[agentId]/status/helpers.ts index 394b36ce..8fc622d8 100644 --- a/app/api/agent/[agentId]/status/helpers.ts +++ b/app/api/agent/[agentId]/status/helpers.ts @@ -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"; diff --git a/next.config.ts b/next.config.ts index f17edb44..f104148e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -41,6 +41,11 @@ const nextConfig: NextConfig = { typescript: { ignoreBuildErrors: true, }, + experimental: { + serverActions: { + bodySizeLimit: "50mb", + }, + }, async headers() { return [ { diff --git a/package.json b/package.json index e8fadf44..b1fc843d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/portabase.config.ts b/portabase.config.ts index 7a29222a..cdd6dd05 100644 --- a/portabase.config.ts +++ b/portabase.config.ts @@ -1,3 +1,16 @@ +import {Chrome, KeyRound, LucideIcon} from "lucide-react"; + + +export interface AuthProviderConfig { + id: "google" | "github" | "credential"; + icon: LucideIcon; + isManual?: boolean; + credentials?: { + clientId: string; + clientSecret: string; + }; +} + export const PORTABASE_DEFAULT_SETTINGS = { SECURITY: { @@ -62,3 +75,35 @@ export const PORTABASE_DEFAULT_SETTINGS = { }, }, }; + + +export const SUPPORTED_PROVIDERS: AuthProviderConfig[] = [ + { + id: "google", + icon: Chrome, + credentials: { + clientId: process.env.GOOGLE_CLIENT_ID || "", + clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", + }, + }, + /* + { + id: "github", + name: "GitHub", + icon: Github, + description: "Connexion via GitHub", + credentials: { + clientId: process.env.GITHUB_CLIENT_ID || "", + clientSecret: process.env.GITHUB_CLIENT_SECRET || "", + }, + },*/ + { + id: "credential", + icon: KeyRound, + isManual: true, + credentials: { + clientId: "", + clientSecret: "", + }, + }, +]; diff --git a/src/components/emails/email-verification.tsx b/src/components/emails/email-verification.tsx new file mode 100644 index 00000000..d33e0472 --- /dev/null +++ b/src/components/emails/email-verification.tsx @@ -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 ( + + + Hello {firstname}, + + + We received a request to change the email address associated with your account. + + + 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. + + + {oldEmail && newEmail ? ( + + Old email address: {oldEmail} +
+ New email address: {newEmail} +
+ ) : null} + +
+ +
+
+ ); +}; + +export default EmailVerification; diff --git a/src/components/ui/password-input-indicator.tsx b/src/components/ui/password-input-indicator.tsx index 24418b0b..fecc41bc 100644 --- a/src/components/ui/password-input-indicator.tsx +++ b/src/components/ui/password-input-indicator.tsx @@ -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 ( {label ?? text.label} @@ -99,6 +111,8 @@ export function PasswordStrengthInput({ }} ref={field.ref} name={field.name} + disabled={disabled} + /> diff --git a/src/components/wrappers/auth/forgot-password/forgot-password-form.tsx b/src/components/wrappers/auth/forgot-password/forgot-password-form.tsx deleted file mode 100644 index bf99fc8c..00000000 --- a/src/components/wrappers/auth/forgot-password/forgot-password-form.tsx +++ /dev/null @@ -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 ( - - - -
-

Forgot Password

-

- Enter your email to reset your password -

-
-
- -
{ - await mutation.mutateAsync(values); - }} - > - ( - - Email - - - - - - )} - /> - - - - -
- - - Go back - -
-
-
-
- ); -}; diff --git a/src/components/wrappers/auth/guard/guard-form.tsx b/src/components/wrappers/auth/guard/guard-form.tsx new file mode 100644 index 00000000..a1a77b49 --- /dev/null +++ b/src/components/wrappers/auth/guard/guard-form.tsx @@ -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 ( + { + if (success) { + toast.success("Successfully logged in!"); + //@ts-ignore + router.push(callbackUrl); + router.refresh(); + } + }} + /> + ); +}; diff --git a/src/components/wrappers/auth/login/button-auth/social-auth-button.tsx b/src/components/wrappers/auth/login/button-auth/social-auth-button.tsx deleted file mode 100644 index c71ddca1..00000000 --- a/src/components/wrappers/auth/login/button-auth/social-auth-button.tsx +++ /dev/null @@ -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 ( -
- {props.providers.map((provider) => ( - - ))} -
- ); -}; diff --git a/src/components/wrappers/auth/forgot-password/forgot-password.schema.ts b/src/components/wrappers/auth/login/forgot-password-form/forgot-password-form.schema.ts similarity index 55% rename from src/components/wrappers/auth/forgot-password/forgot-password.schema.ts rename to src/components/wrappers/auth/login/forgot-password-form/forgot-password-form.schema.ts index 5854b198..a9d2161b 100644 --- a/src/components/wrappers/auth/forgot-password/forgot-password.schema.ts +++ b/src/components/wrappers/auth/login/forgot-password-form/forgot-password-form.schema.ts @@ -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; \ No newline at end of file +export type ForgotPasswordType = z.infer; diff --git a/src/components/wrappers/auth/login/forgot-password-form/forgot-password-form.tsx b/src/components/wrappers/auth/login/forgot-password-form/forgot-password-form.tsx new file mode 100644 index 00000000..f30d5fc0 --- /dev/null +++ b/src/components/wrappers/auth/login/forgot-password-form/forgot-password-form.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Form } from "@/components/ui/form"; +import { requestPasswordReset } from "@/lib/auth/auth-client"; +import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading"; +import Link from "next/link"; +import { ForgotPasswordSchema, ForgotPasswordType } from "./forgot-password-form.schema"; +import { ArrowLeft } from "lucide-react"; + +export type ForgotPasswordFormProps = { + defaultValues?: ForgotPasswordType; +}; + +export const ForgotPasswordForm = (props: ForgotPasswordFormProps) => { + + const form = useZodForm({ + schema: ForgotPasswordSchema, + }); + + const mutation = useMutation({ + mutationFn: async (values: ForgotPasswordType) => { + await requestPasswordReset( + { + email: values.email, + }, + { + onSuccess: () => { + toast.success("Password reset successfully."); + }, + onError: (error) => { + toast.error(error.error.message); + }, + } + ); + }, + }); + + + + return ( +
{ + await mutation.mutateAsync(values); + }} + > + ( + + Email Address + + + + + + )} + /> + +
+ + Send reset link + + + + Back to login + +
+ + ); +}; diff --git a/src/components/wrappers/auth/login/forgot-password-form/forgot-password.actions.ts b/src/components/wrappers/auth/login/forgot-password-form/forgot-password.actions.ts new file mode 100644 index 00000000..29d5c535 --- /dev/null +++ b/src/components/wrappers/auth/login/forgot-password-form/forgot-password.actions.ts @@ -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> => { + 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", + }, + }; + } + }); diff --git a/src/components/wrappers/auth/login/login-form/login-form.schema.ts b/src/components/wrappers/auth/login/login-form/login-form.schema.ts index dc354201..21cc18ef 100644 --- a/src/components/wrappers/auth/login/login-form/login-form.schema.ts +++ b/src/components/wrappers/auth/login/login-form/login-form.schema.ts @@ -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; diff --git a/src/components/wrappers/auth/login/login-form/login-form.tsx b/src/components/wrappers/auth/login/login-form/login-form.tsx index d6f54e0f..cb94fb21 100644 --- a/src/components/wrappers/auth/login/login-form/login-form.tsx +++ b/src/components/wrappers/auth/login/login-form/login-form.tsx @@ -1,177 +1,122 @@ "use client"; -import {Card, CardContent, CardHeader} from "@/components/ui/card"; +import {useEffect, useState} from "react"; +import {useMutation} from "@tanstack/react-query"; +import {toast} from "sonner"; + import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import {Input} from "@/components/ui/input"; import {Form} from "@/components/ui/form"; -import {Button} from "@/components/ui/button"; -import {toast} from "sonner"; -import {useMutation} from "@tanstack/react-query"; -import {TooltipProvider} from "@/components/ui/tooltip"; -import Link from "next/link"; -import {PasswordInput} from "@/components/ui/password-input"; import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema"; -import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button"; import {signIn} from "@/lib/auth/auth-client"; +import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; +import Link from "next/link"; import {useRouter} from "next/navigation"; -import {Icon} from "@iconify/react"; -import {useEffect, useState} from "react"; -import {Separator} from "@/components/ui/separator"; +import {PasswordInput} from "@/components/ui/password-input"; export type loginFormProps = { defaultValues?: LoginType; - authGoogleEnabled: boolean; }; export const LoginForm = (props: loginFormProps) => { - const router = useRouter(); - const form = useZodForm({ - schema: LoginSchema, - }); - const [urlParams] = useState(() => - new URLSearchParams(typeof window !== "undefined" ? window.location.search : "") - ); + const [urlParams, setUrlParams] = useState(); useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + setUrlParams(urlParams); const error = urlParams.get("error"); if (error?.includes("pending")) { toast.error("Your account is not active."); urlParams.delete("error"); window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString()); } - }, [urlParams]); + if (error?.includes("invalid_or_expired_token")) { + toast.error("Password reset invalid token."); + urlParams.delete("error"); + window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString()); + } + }, []); + + const form = useZodForm({ + schema: LoginSchema, + }); + + const router = useRouter(); const mutation = useMutation({ mutationFn: async (values: LoginType) => { - try { - const callbackURL = - urlParams.get("redirect")?.startsWith("/") - ? urlParams.get("redirect") - : "/dashboard/profile"; - - const {error} = await signIn.email({ - email: values.email, + await signIn.email( + { password: values.password, - callbackURL: callbackURL ?? "/dashboard/profile", - }); + email: values.email, + callbackURL: urlParams?.get("redirect") ?? "/dashboard", + }, + { + onSuccess: (context) => { + if (context.data.twoFactorRedirect) { + //@ts-ignore + router.push("/guard?redirect=" + encodeURIComponent(context.data.callbackURL || "/dashboard")); + } - if (error) { - toast.error(error.message); - } else { - toast.success("Login success"); + toast.success("Login success"); + }, + onError: (error) => { + toast.error(error.error.message); + }, } - } catch (err) { - console.error(err); - toast.error("Unexpected client error during login"); - } - }, - onError: (err: any) => { - toast.error(err.message || "Client error"); + ); }, }); - const availableProviders: SocialProviderType[] = []; - - if (props.authGoogleEnabled) { - availableProviders.push({ - id: "google", - name: "Google", - icon: , - }); - } - return ( - - - -
-

Login

-

- Enter your information below to login -

-
-
- -
{ - await mutation.mutateAsync(values); - }} - > - ( - - Email - - - - - - )} - /> - ( - -
- Password -
- - Forgot your password ? - -
- -
- - - - -
- )} - /> - - - - {availableProviders.length > 0 && ( -
- -
OR
- +
{ + await mutation.mutateAsync(values); + }} + > + ( + + Email Address + + + + + + )} + /> + ( + +
+ Password +
+ + Forgot your password ? + +
- )} - - - -
- Don't have an account ?{" "} - - Sign up - -
- - - - + + + + +
+ )} + /> + + Login + + ); }; diff --git a/src/components/wrappers/auth/login/reset-password-form/reset-password-form.action.ts b/src/components/wrappers/auth/login/reset-password-form/reset-password-form.action.ts new file mode 100644 index 00000000..f059fde6 --- /dev/null +++ b/src/components/wrappers/auth/login/reset-password-form/reset-password-form.action.ts @@ -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> => { + 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", + }, + }; + } + }); diff --git a/src/components/wrappers/auth/login/reset-password-form/reset-password-form.schema.ts b/src/components/wrappers/auth/login/reset-password-form/reset-password-form.schema.ts new file mode 100644 index 00000000..58191391 --- /dev/null +++ b/src/components/wrappers/auth/login/reset-password-form/reset-password-form.schema.ts @@ -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; diff --git a/src/components/wrappers/auth/login/reset-password-form/reset-password-form.tsx b/src/components/wrappers/auth/login/reset-password-form/reset-password-form.tsx new file mode 100644 index 00000000..5402e578 --- /dev/null +++ b/src/components/wrappers/auth/login/reset-password-form/reset-password-form.tsx @@ -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 ( +
{ + await mutation.mutateAsync(values); + }} + > + ( + + + + )} + /> + ( + + Confirmation password + + + + + )} + /> + +
+ + Reset + + + + Back to login + +
+ + ); +}; diff --git a/src/components/wrappers/auth/social-buttons.tsx b/src/components/wrappers/auth/social-buttons.tsx new file mode 100644 index 00000000..b3c69fa1 --- /dev/null +++ b/src/components/wrappers/auth/social-buttons.tsx @@ -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(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 ( +
+ {socialProviders.map((provider) => ( + + ))} +
+ ); +} + + +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." + } +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/admin/organizations/columns-organizations.tsx b/src/components/wrappers/dashboard/admin/organizations/columns-organizations.tsx index 37bad487..ad5fe75e 100644 --- a/src/components/wrappers/dashboard/admin/organizations/columns-organizations.tsx +++ b/src/components/wrappers/dashboard/admin/organizations/columns-organizations.tsx @@ -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[] = [ diff --git a/src/components/wrappers/dashboard/admin/users/accounts/table-columns.tsx b/src/components/wrappers/dashboard/admin/users/accounts/table-columns.tsx index e4c195fe..774b7a37 100644 --- a/src/components/wrappers/dashboard/admin/users/accounts/table-columns.tsx +++ b/src/components/wrappers/dashboard/admin/users/accounts/table-columns.tsx @@ -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"; diff --git a/src/components/wrappers/dashboard/admin/users/button-delete-use.tsx b/src/components/wrappers/dashboard/admin/users/button-delete-use.tsx index c126bab1..80886fa4 100644 --- a/src/components/wrappers/dashboard/admin/users/button-delete-use.tsx +++ b/src/components/wrappers/dashboard/admin/users/button-delete-use.tsx @@ -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; diff --git a/src/components/wrappers/dashboard/admin/users/columns-users.tsx b/src/components/wrappers/dashboard/admin/users/columns-users.tsx index df3c9352..669e6fa5 100644 --- a/src/components/wrappers/dashboard/admin/users/columns-users.tsx +++ b/src/components/wrappers/dashboard/admin/users/columns-users.tsx @@ -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"; diff --git a/src/components/wrappers/dashboard/admin/users/sessions/table-columns.tsx b/src/components/wrappers/dashboard/admin/users/sessions/table-columns.tsx index 44c4f0e0..8c6e4760 100644 --- a/src/components/wrappers/dashboard/admin/users/sessions/table-columns.tsx +++ b/src/components/wrappers/dashboard/admin/users/sessions/table-columns.tsx @@ -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[] = [ { diff --git a/src/components/wrappers/dashboard/common/logged-in/logged-in-button.tsx b/src/components/wrappers/dashboard/common/logged-in/logged-in-button.tsx index 49edcb68..0822a96d 100644 --- a/src/components/wrappers/dashboard/common/logged-in/logged-in-button.tsx +++ b/src/components/wrappers/dashboard/common/logged-in/logged-in-button.tsx @@ -1,34 +1,42 @@ -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { SidebarMenuButton } from "@/components/ui/sidebar"; import { ChevronUp } from "lucide-react"; + +import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"; import { currentUser } from "@/lib/auth/current-user"; +import { SidebarMenuButton } from "@/components/ui/sidebar"; +import { getAccounts, getSession, getSessions } from "@/lib/auth/auth"; import {LoggedInDropdown} from "@/components/wrappers/dashboard/common/logged-in/logged-in-dropdown"; export const LoggedInButton = async () => { const user = await currentUser(); + const sessions = await getSessions(); + const currentSession = await getSession(); + const accounts = await getAccounts(); if (!user) return null; return ( - - - - {user.name[0].toUpperCase()} - {user.image ? : null} - - {user.name} - - - + <> + + + + + {user.name[0].toUpperCase()} + {user.image ? : null} + + + {user.name} + + + + ); }; diff --git a/src/components/wrappers/dashboard/common/logged-in/logged-in-dropdown.tsx b/src/components/wrappers/dashboard/common/logged-in/logged-in-dropdown.tsx index ba4f438b..7fd0afa3 100644 --- a/src/components/wrappers/dashboard/common/logged-in/logged-in-dropdown.tsx +++ b/src/components/wrappers/dashboard/common/logged-in/logged-in-dropdown.tsx @@ -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 ( - - {props.children} - - { - redirect("/dashboard/profile"); - }} - > -
- - Account -
-
- {/*{(props.user.role === "superadmin" || props.user.role === "admin") && (*/} - {/* {*/} - {/* redirect("/dashboard/admin");*/} - {/* }}*/} - {/* >*/} - {/*
*/} - {/* */} - {/* Administration Panel*/} - {/*
*/} - {/* */} - {/*)}*/} - { - await signOut({ - fetchOptions: { - onSuccess: () => { - router.push("/login"); + <> + + + + {children} + + setIsModalOpen(!isModalOpen)}> +
+ + Account +
+
+ { + await signOut({ + fetchOptions: { + onSuccess: () => { + router.push("/login"); + }, }, - }, - }); - }} - > -
- - Log out -
-
-
-
+ }); + }} + > +
+ + Logout +
+
+
+
+ ); }; diff --git a/src/components/wrappers/dashboard/common/profile/profile-modal.tsx b/src/components/wrappers/dashboard/common/profile/profile-modal.tsx new file mode 100644 index 00000000..b38a77a7 --- /dev/null +++ b/src/components/wrappers/dashboard/common/profile/profile-modal.tsx @@ -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 ( + + + + Settings + Manage your account settings + + + + +
+ + + + + + acc.providerId === "credential")!} + /> + + + + + + + + + + + + + +
+
+
+
+ ); +}; diff --git a/src/components/wrappers/dashboard/common/profile/profile-sidebar.tsx b/src/components/wrappers/dashboard/common/profile/profile-sidebar.tsx new file mode 100644 index 00000000..df579496 --- /dev/null +++ b/src/components/wrappers/dashboard/common/profile/profile-sidebar.tsx @@ -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 ( +
+
+ Settings +
+ + + }> + Profile + + }> + Security & Access + + }> + Connected Accounts + + }> + Account + + }> + Appearance + + +
+ ); +} + +function SettingsTabTrigger({ value, icon, children }: { value: string; icon: React.ReactNode; children: React.ReactNode }) { + return ( + + {icon} + {children} + + ); +} diff --git a/src/components/wrappers/dashboard/common/sidebar/app-sidebar.tsx b/src/components/wrappers/dashboard/common/sidebar/app-sidebar.tsx index 4559989f..84a202a8 100644 --- a/src/components/wrappers/dashboard/common/sidebar/app-sidebar.tsx +++ b/src/components/wrappers/dashboard/common/sidebar/app-sidebar.tsx @@ -34,6 +34,7 @@ export function AppSidebar() { + diff --git a/src/components/wrappers/dashboard/profile/avatar/avatar.action.ts b/src/components/wrappers/dashboard/profile/actions/avatar.action.ts similarity index 100% rename from src/components/wrappers/dashboard/profile/avatar/avatar.action.ts rename to src/components/wrappers/dashboard/profile/actions/avatar.action.ts diff --git a/src/components/wrappers/dashboard/profile/actions/profile.action.ts b/src/components/wrappers/dashboard/profile/actions/profile.action.ts new file mode 100644 index 00000000..c098bd57 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/actions/profile.action.ts @@ -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> => { + 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", + }, + }; + } +}); diff --git a/src/components/wrappers/dashboard/profile/actions/provider.action.ts b/src/components/wrappers/dashboard/profile/actions/provider.action.ts new file mode 100644 index 00000000..16b0db9b --- /dev/null +++ b/src/components/wrappers/dashboard/profile/actions/provider.action.ts @@ -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> => { + 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", + }, + }; + } + }); diff --git a/src/components/wrappers/dashboard/profile/actions/security.action.ts b/src/components/wrappers/dashboard/profile/actions/security.action.ts new file mode 100644 index 00000000..b7dce2ca --- /dev/null +++ b/src/components/wrappers/dashboard/profile/actions/security.action.ts @@ -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> => { + 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> => { + 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", + }, + }; + } +}); diff --git a/src/components/wrappers/dashboard/profile/components/avatar-with-upload.tsx b/src/components/wrappers/dashboard/profile/components/avatar-with-upload.tsx new file mode 100644 index 00000000..8cc64eef --- /dev/null +++ b/src/components/wrappers/dashboard/profile/components/avatar-with-upload.tsx @@ -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) => { + 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 ( +
+ + + + {user.name.charAt(0)} + + +
{ + 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" + > + +
+
+ ); +}; diff --git a/src/components/wrappers/dashboard/profile/components/backup-codes-list.tsx b/src/components/wrappers/dashboard/profile/components/backup-codes-list.tsx new file mode 100644 index 00000000..d28ab241 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/components/backup-codes-list.tsx @@ -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 ( +
+
+
+

Your Backup Codes

+ +
+
+ {codes.map((code, i) => ( +
+ {code} +
+ ))} +
+

+ + + These codes can only be used once. After using a code, make sure to generate new backup codes to maintain account security. + +

+ +
+
+ ); +} diff --git a/src/components/wrappers/dashboard/profile/form/2fa-form.tsx b/src/components/wrappers/dashboard/profile/form/2fa-form.tsx new file mode 100644 index 00000000..2876e6b5 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/form/2fa-form.tsx @@ -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 ( +
+
+
+
+ {isBackupCodeMode ? : } +
+
+

{isBackupCodeMode ? "Backup Code Authentication" : "Device Authentication"}

+

+ {isBackupCodeMode ? "Please enter one of your backup codes." : "\"Please enter the verification code generated by your authentication app.\""} +

+
+
+ + ( + + + {isBackupCodeMode ? ( + { + const value = e.target.value; + field.onChange(value); + if (value.replaceAll("-", "").length === 10) { + handleSubmit({ code: value }); + } + }} + /> + ) : ( + { + field.onChange(value); + if (value.length === 6) { + handleSubmit({ code: value }); + } + }} + > + + + + + + + + + + )} + + + + )} + /> + +
+ + + +
+
+
+ ); +} diff --git a/src/components/wrappers/dashboard/profile/form/2fa.schema.ts b/src/components/wrappers/dashboard/profile/form/2fa.schema.ts new file mode 100644 index 00000000..eb708eee --- /dev/null +++ b/src/components/wrappers/dashboard/profile/form/2fa.schema.ts @@ -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; + +export const BackupCodeSchema = z.object({ + code: zString().min(1, "Le code est requis"), +}); + +export type BackupCodeSchemaType = z.infer; diff --git a/src/components/wrappers/dashboard/profile/form/reset-password-form.tsx b/src/components/wrappers/dashboard/profile/form/reset-password-form.tsx new file mode 100644 index 00000000..2628f5eb --- /dev/null +++ b/src/components/wrappers/dashboard/profile/form/reset-password-form.tsx @@ -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 ( +
{ + changePassword(values); + }} + > + {!isDefault && ( + + After resetting your password, you will be logged out of all devices. + + )} + +
+ ( + + Current password + + + + + )} + /> + + ( + + { + setAllowConfirmPassword(valid); + }} + /> + + )} + /> + + ( + + + + + + )} + /> + +
+ +
+
+
+ ); +} + + diff --git a/src/components/wrappers/dashboard/profile/form/set-password-form.tsx b/src/components/wrappers/dashboard/profile/form/set-password-form.tsx new file mode 100644 index 00000000..365a2b33 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/form/set-password-form.tsx @@ -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 ( +
{ + await setPasswordMutation(values); + }} + > +
+ ( + + + + )} + /> + ( + + Confirm password + + + + + )} + /> + +
+ +
+
+
+ ); +} diff --git a/src/components/wrappers/dashboard/profile/modal/disable-2fa-modal.tsx b/src/components/wrappers/dashboard/profile/modal/disable-2fa-modal.tsx new file mode 100644 index 00000000..9a2d4f68 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/modal/disable-2fa-modal.tsx @@ -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; + +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 ( + (!v ? handleClose() : onOpenChange(v))}> + + + + + + Disable Two-Factor Authentication + Are you sure you want to disable two-factor authentication? This will reduce the security of your account. + + + {step === "OTP" && ( + { + if (success) { + setStep("PASSWORD"); + } + }} + /> + )} + + {step === "PASSWORD" && ( +
disable2FA(values)}> + ( + + Current Password + + + + + + )} + /> + +
+ + +
+ + )} +
+
+ ); +} diff --git a/src/components/wrappers/dashboard/profile/modal/reset-password-modal.tsx b/src/components/wrappers/dashboard/profile/modal/reset-password-modal.tsx new file mode 100644 index 00000000..1943d096 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/modal/reset-password-modal.tsx @@ -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 ( + + + + + + + Reset Password + Enter a new password for your account below. + + onOpenChange(false)} /> + + + ); +} + diff --git a/src/components/wrappers/dashboard/profile/modal/set-password-modal.tsx b/src/components/wrappers/dashboard/profile/modal/set-password-modal.tsx new file mode 100644 index 00000000..13ff747b --- /dev/null +++ b/src/components/wrappers/dashboard/profile/modal/set-password-modal.tsx @@ -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 ( + + + + + + + Set Password + Create a password for your account to enable password-based login. + + + onOpenChange(false)} /> + + + ); +} + diff --git a/src/components/wrappers/dashboard/profile/modal/setup-2fa-modal.tsx b/src/components/wrappers/dashboard/profile/modal/setup-2fa-modal.tsx new file mode 100644 index 00000000..60832d30 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/modal/setup-2fa-modal.tsx @@ -0,0 +1,253 @@ +"use client"; + +import {useState} from "react"; +import {Button} from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger +} from "@/components/ui/dialog"; +import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; +import {Loader2, Copy, CheckCircle2, ShieldCheck} from "lucide-react"; +import {useMutation} from "@tanstack/react-query"; +import {useRouter} from "next/navigation"; +import {Setup2FASecuritySchema, Setup2FASecuritySchemaType} from "../schemas/security.schema"; +import {toast} from "sonner"; +import {authClient} from "@/lib/auth/auth-client"; +import {Alert, AlertDescription} from "@/components/ui/alert"; +import {InputOTP, InputOTPGroup, InputOTPSlot} from "@/components/ui/input-otp"; +import QRCode from "react-qr-code"; +import z from "zod"; +import {zPassword} from "@/lib/zod"; +import {BackupCodesList} from "../components/backup-codes-list"; +import {PasswordInput} from "@/components/ui/password-input"; + +const PasswordSchema = z.object({ + password: zPassword(), +}); + +type Password = z.infer; + +type Setup2FAModalProps = { + onOpenChange: (open: boolean) => void; + open: boolean; +}; + +export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModalProps) { + + const router = useRouter(); + const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD"); + + const [totpURI, setTotpURI] = useState(""); + const [secret, setSecret] = useState(""); + const [backupCodes, setBackupCodes] = useState([]); + + 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 ( + (!v ? handleClose() : onOpenChange(v))}> + + + + + + Enable Two-Factor Authentication + + {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."} + + + + {step === "PASSWORD" && ( +
enable2FA(values)}> +
+
+ ( + + Current Password + + + + + + )} + /> +
+
+ +
+
+
+ )} + + {step === "QR" && ( +
{ + verify2FA(values); + }} + > +
+
+ {totpURI && ( + + )} +
+ +
+

If you are unable to scan the QR code, you can manually enter the secret key into your authentication app :

+
+ {secret} + +
+
+ +
+ ( + + Verification Code + + { + field.onChange(value); + if (value.length === 6) { + verify2FA(form.getValues()); + } + }} + > + + + + + + + + + + + + )} + /> +
+ +
+ +
+
+
+ )} + + {step === "BACKUP" && ( +
+ + + Two Factor Authentication is now enabled on your account. + + + + +
+ +
+
+ )} +
+
+ ); +} diff --git a/src/components/wrappers/dashboard/profile/modal/view-backup-codes-modal.tsx b/src/components/wrappers/dashboard/profile/modal/view-backup-codes-modal.tsx new file mode 100644 index 00000000..26d873b0 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/modal/view-backup-codes-modal.tsx @@ -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; + +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([]); + + 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 ( + (!v ? handleClose() : onOpenChange(v))}> + + + + + + Backup Codes + {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."} + + + {step === "PASSWORD" && ( +
generateCodes(values)}> +
+ + + Important + Generating new codes will invalidate your existing ones. Make sure to save the new codes securely. + + + ( + + Current Password + + + + + + )} + /> + +
+ +
+
+
+ )} + + {step === "CODES" && ( +
+ + +
+ +
+
+ )} +
+
+ ); +} diff --git a/src/components/wrappers/dashboard/profile/profile-account.tsx b/src/components/wrappers/dashboard/profile/profile-account.tsx new file mode 100644 index 00000000..de8b3f36 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/profile-account.tsx @@ -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 ( +
+
+

Account Settings

+

Update your email and preferences.

+
+ +
+
updateEmail(values)}> +
+ ( + + Email Address +
+
+ + + + +
+ + + {!user.emailVerified && ( + + )} +
+
+ +
+
+ )} + /> + + {!user.emailVerified && ( +
+ + Your email is not verified. Please check your inbox. +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/wrappers/dashboard/profile/profile-apperance.tsx b/src/components/wrappers/dashboard/profile/profile-apperance.tsx new file mode 100644 index 00000000..f4f90ddf --- /dev/null +++ b/src/components/wrappers/dashboard/profile/profile-apperance.tsx @@ -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 ( +
+
+

Appearance Settings

+

Customize the look and feel of your dashboard.

+
+ +
+ ); +} + +function ThemeSelector() { + const {theme, setTheme} = useTheme(); + + + return ( +
+ {themes.map((item) => { + const isDark = item.value === "dark"; + const isSystem = item.value === "system"; + const isActive = theme === item.value; + + return ( +
{ + // setTheme(item.value) + // await authClient.updateUser({theme: item.value}); + }} + > +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ {THEME_TEXT[item.value as ThemeKey]} +
+ {isActive &&
} +
+
+
+ ); + })} +
+ ); +} + +type ThemeKey = "dark" | "light" | "system"; + + +const THEME_TEXT: Record = { + dark: "Dark", + light: "Light", + system: "System", +}; \ No newline at end of file diff --git a/src/components/wrappers/dashboard/profile/profile-general.tsx b/src/components/wrappers/dashboard/profile/profile-general.tsx new file mode 100644 index 00000000..cb1174ba --- /dev/null +++ b/src/components/wrappers/dashboard/profile/profile-general.tsx @@ -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 ( +
+
+

Profile Settings

+

Manage your personal information and preferences.

+
+ +
+
+ +
+ +
+
updateProfile(values)}> +
+ ( + + Display Name + + + + This is your public display name + + + )} + /> + +
+ ( + + + Role & Permissions + + +
+ +
+
+
+ )} + /> +
+ +
+ +
+
+
+
+
+
+ ); +} + +function RoleBadge({role}: { role: string }) { + + const variant = role === "admin" ? "default" : "secondary"; + + return ( + + {role} + + ); +} diff --git a/src/components/wrappers/dashboard/profile/profile-providers.tsx b/src/components/wrappers/dashboard/profile/profile-providers.tsx new file mode 100644 index 00000000..b3601c67 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/profile-providers.tsx @@ -0,0 +1,175 @@ +"use client"; + +import React, {useState} from "react"; +import {Button} from "@/components/ui/button"; +import {Badge} from "@/components/ui/badge"; +import {Loader2, AlertTriangle} from "lucide-react"; +import {Account} from "@/db/schema/02_user"; +import {authClient} from "@/lib/auth/auth-client"; +import {useMutation} from "@tanstack/react-query"; +import {toast} from "sonner"; +import {useRouter} from "next/navigation"; +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; +import {Alert, AlertDescription} from "@/components/ui/alert"; +import {SetPasswordProfileProviderModal} from "./modal/set-password-modal"; +import {SUPPORTED_PROVIDERS} from "../../../../../portabase.config"; + +interface ProfileProviderProps { + accounts: Account[]; +} + +export function ProfileProviders({accounts}: ProfileProviderProps) { + const router = useRouter(); + const totalConnected = accounts.length; + + const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false); + + const {mutate: unlinkAccount, isPending: isUnlinking} = useMutation({ + mutationFn: async (providerId: string) => { + const {error} = await authClient.unlinkAccount({ + providerId, + }); + if (error) throw error; + }, + onSuccess: () => { + toast.success("Provider successfully unlinked!"); + router.refresh(); + }, + onError: () => { + toast.error("An error occurred while unlinking provider."); + }, + }); + + const {mutate: linkAccount, isPending: isLinking} = useMutation({ + mutationFn: async (providerId: string) => { + const {error} = await authClient.signIn.social({ + provider: providerId as "google" | "github", + callbackURL: "/dashboard", + }); + if (error) throw error; + }, + onSuccess: () => { + toast.success("Provider successfully Linked!"); + router.refresh(); + }, + onError: () => { + toast.error("An error occurred while linked provider."); + }, + }); + + return ( +
+
+

Connected Accounts

+

Manage the providers used to sign in to your account.

+
+ +
+ {SUPPORTED_PROVIDERS.map((provider) => { + const linkedAccount = accounts.find((acc) => acc.providerId === provider.id); + const isConnected = !!linkedAccount; + + const canUnlink = totalConnected > 1 || (totalConnected === 1 && !provider.isManual); + const isLoading = isUnlinking || isLinking; + + return ( +
+
+
+ +
+
+
+ {PROVIDERS_TEXT[provider.id].title} + {isConnected && ( + + Active + + )} +
+
+ {isConnected ? "Connected" : "Not Connected"} +
+
+
+ +
+ {isConnected ? ( + + + + + + + + {!canUnlink && ( + +

+ You cannot unlink your last authentication provider or if you + don't have a password set. +

+
+ )} +
+
+ ) : ( + <> + {provider.id === "credential" ? ( + + ) : ( + + )} + + )} +
+
+ ); + })} +
+ + + + + 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. + + +
+ ); +} + + +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.", + }, +}; + diff --git a/src/components/wrappers/dashboard/profile/profile-security.tsx b/src/components/wrappers/dashboard/profile/profile-security.tsx new file mode 100644 index 00000000..68557d13 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/profile-security.tsx @@ -0,0 +1,219 @@ +"use client"; + +import {useState} from "react"; +import {Button} from "@/components/ui/button"; +import {Separator} from "@/components/ui/separator"; +import {Badge} from "@/components/ui/badge"; +import {Globe, LogOut, Loader2} from "lucide-react"; +import {Account, Session, User} from "@/db/schema/02_user"; +import {useMutation} from "@tanstack/react-query"; +import {toast} from "sonner"; +import {revokeAllSessionsAction, revokeSessionAction} from "./actions/security.action"; +import {useRouter} from "next/navigation"; +import {ResetPasswordProfileProviderModal} from "./modal/reset-password-modal"; +import {SetPasswordProfileProviderModal} from "./modal/set-password-modal"; +import {Setup2FAProfileProviderModal} from "./modal/setup-2fa-modal"; +import {Disable2FAProfileProviderModal} from "./modal/disable-2fa-modal"; +import {ViewBackupCodesModal} from "./modal/view-backup-codes-modal"; +import {getDeviceDetails} from "@/utils/detection"; +import {timeAgo} from "@/utils/date-formatting"; + +interface ProfileSecurityProps { + user: User; + sessions: Session[]; + credentialAccount: Account; + currentSession: Session; +} + +export function ProfileSecurity({user, sessions, credentialAccount, currentSession}: ProfileSecurityProps) { + const router = useRouter(); + + const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false); + const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false); + const [isSetup2FADialogOpen, setIsSetup2FADialogOpen] = useState(false); + const [isDisable2FADialogOpen, setIsDisable2FADialogOpen] = useState(false); + + const {mutate: revokeSession, isPending: isRevoking} = useMutation({ + mutationFn: async (token: string) => { + const result = await revokeSessionAction({token}); + const inner = result?.data; + if (inner?.success) { + toast.success("Session successfully revoked"); + router.refresh(); + } else { + toast.error("An error occurred while revoking session"); + } + }, + }); + + const {mutate: revokeOthers, isPending: isRevokingOthers} = useMutation({ + mutationFn: async () => { + const result = await revokeAllSessionsAction(); + const inner = result?.data; + if (inner?.success) { + toast.success("Revoking all sessions successfully done."); + router.refresh(); + } else { + toast.error("An error occurred while revoking all sessions"); + } + }, + }); + + return ( +
+
+

Security Settings

+

Manage your password, two-factor authentication and + sessions.

+
+ +
+

Authentication

+
+
+
+
Password
+
+ {/*{user.lastChangedPasswordAt*/} + {/* ? t("sections.authentication.password.description.last_changed", {*/} + {/* date: timeAgo(new Date(user.lastChangedPasswordAt), locale),*/} + {/* })*/} + {/* : "Never changed"}*/} +
+
+ {credentialAccount ? ( + + ) : ( + + )} +
+ + + +
+
+
+
Two-Factor Authentication
+ {user.twoFactorEnabled && ( + + Active + + )} +
+
Enhance the security of your account by + requiring a second form of verification during login. +
+
+ + {user.twoFactorEnabled ? ( +
+ + +
+ ) : ( + + )} +
+
+
+ +
+
+

Active Sessions

+ {sessions && sessions.length > 1 && ( + + )} +
+
+ {sessions && sessions.length > 0 ? ( + sessions?.map((session) => ( + revokeSession(token)} + isRevoking={isRevoking} + currentSession={currentSession} + /> + )) + ) : ( +
No active sessions found.
+ )} +
+
+
+ ); +} + +function SessionRow({ + session, + onRevoke, + isRevoking, + currentSession, + }: { + session: Session; + onRevoke: (token: string) => void; + isRevoking: boolean; + currentSession: Session; +}) { + const deviceInfo = getDeviceDetails(session.userAgent); + + return ( +
+
+
+ +
+
+
+ {deviceInfo.os} • {deviceInfo.browser} + {session.id === currentSession.id && ( + + This device + + )} +
+
+ {session.ipAddress} • + + {session.id === currentSession.id + ? "Active now" + : `Last active ${timeAgo(new Date(session.createdAt))}`} + +
+
+
+ + {session.id !== currentSession.id && ( + + )} +
+ ); +} diff --git a/src/components/wrappers/dashboard/profile/schemas/account.schema.ts b/src/components/wrappers/dashboard/profile/schemas/account.schema.ts new file mode 100644 index 00000000..7c147e6a --- /dev/null +++ b/src/components/wrappers/dashboard/profile/schemas/account.schema.ts @@ -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; diff --git a/src/components/wrappers/dashboard/profile/schemas/general.schema.ts b/src/components/wrappers/dashboard/profile/schemas/general.schema.ts new file mode 100644 index 00000000..0689e229 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/schemas/general.schema.ts @@ -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; diff --git a/src/components/wrappers/dashboard/profile/schemas/provider.schema.ts b/src/components/wrappers/dashboard/profile/schemas/provider.schema.ts new file mode 100644 index 00000000..4501a3fe --- /dev/null +++ b/src/components/wrappers/dashboard/profile/schemas/provider.schema.ts @@ -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; diff --git a/src/components/wrappers/dashboard/profile/schemas/security.schema.ts b/src/components/wrappers/dashboard/profile/schemas/security.schema.ts new file mode 100644 index 00000000..d5950853 --- /dev/null +++ b/src/components/wrappers/dashboard/profile/schemas/security.schema.ts @@ -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; + +export const Setup2FASecuritySchema = z.object({ + code: z.string().min(6, "Code need to contain at least 6 characters"), +}); + +export type Setup2FASecuritySchemaType = z.infer; diff --git a/src/components/wrappers/dashboard/profile/avatar/avatar-with-upload.tsx b/src/components/wrappers/dashboard/profile2/avatar/avatar-with-upload.tsx similarity index 98% rename from src/components/wrappers/dashboard/profile/avatar/avatar-with-upload.tsx rename to src/components/wrappers/dashboard/profile2/avatar/avatar-with-upload.tsx index 6ed9b638..2acf76b6 100644 --- a/src/components/wrappers/dashboard/profile/avatar/avatar-with-upload.tsx +++ b/src/components/wrappers/dashboard/profile2/avatar/avatar-with-upload.tsx @@ -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"; diff --git a/src/components/wrappers/dashboard/profile2/avatar/avatar.action.ts b/src/components/wrappers/dashboard/profile2/avatar/avatar.action.ts new file mode 100644 index 00000000..d44cc2a2 --- /dev/null +++ b/src/components/wrappers/dashboard/profile2/avatar/avatar.action.ts @@ -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, + }; +}); diff --git a/src/components/wrappers/dashboard/profile/button-delete-account/button-delete-account.tsx b/src/components/wrappers/dashboard/profile2/button-delete-account/button-delete-account.tsx similarity index 100% rename from src/components/wrappers/dashboard/profile/button-delete-account/button-delete-account.tsx rename to src/components/wrappers/dashboard/profile2/button-delete-account/button-delete-account.tsx diff --git a/src/components/wrappers/dashboard/profile/button-delete-account/delete-account.action.ts b/src/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action.ts similarity index 100% rename from src/components/wrappers/dashboard/profile/button-delete-account/delete-account.action.ts rename to src/components/wrappers/dashboard/profile2/button-delete-account/delete-account.action.ts diff --git a/src/components/wrappers/dashboard/profile/user-avatar/user-avatar.tsx b/src/components/wrappers/dashboard/profile2/user-avatar/user-avatar.tsx similarity index 100% rename from src/components/wrappers/dashboard/profile/user-avatar/user-avatar.tsx rename to src/components/wrappers/dashboard/profile2/user-avatar/user-avatar.tsx diff --git a/src/components/wrappers/dashboard/profile/user-form/user-form.action.ts b/src/components/wrappers/dashboard/profile2/user-form/user-form.action.ts similarity index 97% rename from src/components/wrappers/dashboard/profile/user-form/user-form.action.ts rename to src/components/wrappers/dashboard/profile2/user-form/user-form.action.ts index 1efaad26..ee1da954 100644 --- a/src/components/wrappers/dashboard/profile/user-form/user-form.action.ts +++ b/src/components/wrappers/dashboard/profile2/user-form/user-form.action.ts @@ -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"; diff --git a/src/components/wrappers/dashboard/profile/user-form/user-form.schema.ts b/src/components/wrappers/dashboard/profile2/user-form/user-form.schema.ts similarity index 100% rename from src/components/wrappers/dashboard/profile/user-form/user-form.schema.ts rename to src/components/wrappers/dashboard/profile2/user-form/user-form.schema.ts diff --git a/src/components/wrappers/dashboard/profile/user-form/user-form.tsx b/src/components/wrappers/dashboard/profile2/user-form/user-form.tsx similarity index 98% rename from src/components/wrappers/dashboard/profile/user-form/user-form.tsx rename to src/components/wrappers/dashboard/profile2/user-form/user-form.tsx index c860e3c5..9b09a038 100644 --- a/src/components/wrappers/dashboard/profile/user-form/user-form.tsx +++ b/src/components/wrappers/dashboard/profile2/user-form/user-form.tsx @@ -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"; diff --git a/src/db/migrations/0013_past_logan.sql b/src/db/migrations/0013_past_logan.sql new file mode 100644 index 00000000..656bb0b8 --- /dev/null +++ b/src/db/migrations/0013_past_logan.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" ADD COLUMN "lastChangedPasswordAt" timestamp;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "two_factor_enabled" boolean DEFAULT false; \ No newline at end of file diff --git a/src/db/migrations/meta/0013_snapshot.json b/src/db/migrations/meta/0013_snapshot.json new file mode 100644 index 00000000..f6b7015b --- /dev/null +++ b/src/db/migrations/meta/0013_snapshot.json @@ -0,0 +1,1745 @@ +{ + "id": "111a7060-826c-4df7-bc2a-320b6e477ac1", + "prevId": "05a7de9f-f36d-47d1-b66c-0ef01d476b44", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "storage": { + "name": "storage", + "type": "type_storage", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'local'" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "s3_endpoint_url": { + "name": "s3_endpoint_url", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "s3_access_key_id": { + "name": "s3_access_key_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "s3_secret_access_key": { + "name": "s3_secret_access_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "s3_bucket_name": { + "name": "s3_bucket_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_password": { + "name": "smtp_password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_from": { + "name": "smtp_from", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_host": { + "name": "smtp_host", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_port": { + "name": "smtp_port", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "smtp_user": { + "name": "smtp_user", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_name_unique": { + "name": "settings_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lastChangedPasswordAt": { + "name": "lastChangedPasswordAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "projects_organization_id_organization_id_fk": { + "name": "projects_organization_id_organization_id_fk", + "tableFrom": "projects", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.backups": { + "name": "backups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "file": { + "name": "file", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "backups_database_id_databases_id_fk": { + "name": "backups_database_id_databases_id_fk", + "tableFrom": "backups", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.databases": { + "name": "databases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_database_id": { + "name": "agent_database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dbms": { + "name": "dbms", + "type": "dbms_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backup_policy": { + "name": "backup_policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_waiting_for_backup": { + "name": "is_waiting_for_backup", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "backup_to_restore": { + "name": "backup_to_restore", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_contact": { + "name": "last_contact", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "databases_agent_id_agents_id_fk": { + "name": "databases_agent_id_agents_id_fk", + "tableFrom": "databases", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "databases_project_id_projects_id_fk": { + "name": "databases_project_id_projects_id_fk", + "tableFrom": "databases", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.restorations": { + "name": "restorations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "status": { + "name": "status", + "type": "status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'waiting'" + }, + "backup_id": { + "name": "backup_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "restorations_backup_id_backups_id_fk": { + "name": "restorations_backup_id_backups_id_fk", + "tableFrom": "restorations", + "tableTo": "backups", + "columnsFrom": [ + "backup_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "restorations_database_id_databases_id_fk": { + "name": "restorations_database_id_databases_id_fk", + "tableFrom": "restorations", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.retention_policies": { + "name": "retention_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "retention_policy_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "days": { + "name": "days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "gfs_daily": { + "name": "gfs_daily", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "gfs_weekly": { + "name": "gfs_weekly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 4 + }, + "gfs_monthly": { + "name": "gfs_monthly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 12 + }, + "gfs_yearly": { + "name": "gfs_yearly", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "retention_policies_database_id_databases_id_fk": { + "name": "retention_policies_database_id_databases_id_fk", + "tableFrom": "retention_policies", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "last_contact": { + "name": "last_contact", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agents_slug_unique": { + "name": "agents_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channel": { + "name": "notification_channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "provider_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_notification_channels": { + "name": "organization_notification_channels", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "organization_notification_channels_organization_id_organization_id_fk": { + "name": "organization_notification_channels_organization_id_organization_id_fk", + "tableFrom": "organization_notification_channels", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_notification_channels_notification_channel_id_notification_channel_id_fk": { + "name": "organization_notification_channels_notification_channel_id_notification_channel_id_fk", + "tableFrom": "organization_notification_channels", + "tableTo": "notification_channel", + "columnsFrom": [ + "notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_notification_channels_organization_id_notification_channel_id_unique": { + "name": "organization_notification_channels_organization_id_notification_channel_id_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "notification_channel_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.alert_policy": { + "name": "alert_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "notification_channel_id": { + "name": "notification_channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_kind": { + "name": "event_kind", + "type": "event_kind[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "database_id": { + "name": "database_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "alert_policy_notification_channel_id_notification_channel_id_fk": { + "name": "alert_policy_notification_channel_id_notification_channel_id_fk", + "tableFrom": "alert_policy", + "tableTo": "notification_channel", + "columnsFrom": [ + "notification_channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "alert_policy_database_id_databases_id_fk": { + "name": "alert_policy_database_id_databases_id_fk", + "tableFrom": "alert_policy", + "tableTo": "databases", + "columnsFrom": [ + "database_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_log": { + "name": "notification_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_name": { + "name": "provider_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_response": { + "name": "provider_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.retention_policy_type": { + "name": "retention_policy_type", + "schema": "public", + "values": [ + "count", + "days", + "gfs" + ] + }, + "public.provider_kind": { + "name": "provider_kind", + "schema": "public", + "values": [ + "slack", + "smtp" + ] + }, + "public.event_kind": { + "name": "event_kind", + "schema": "public", + "values": [ + "error_backup", + "error_restore", + "success_restore", + "success_backup", + "weekly_report" + ] + }, + "public.level": { + "name": "level", + "schema": "public", + "values": [ + "critical", + "warning", + "info" + ] + }, + "public.dbms_status": { + "name": "dbms_status", + "schema": "public", + "values": [ + "postgresql", + "mysql" + ] + }, + "public.status": { + "name": "status", + "schema": "public", + "values": [ + "waiting", + "ongoing", + "failed", + "success" + ] + }, + "public.type_storage": { + "name": "type_storage", + "schema": "public", + "values": [ + "local", + "s3" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 6511a8b6..8ecc167f 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1766308683196, "tag": "0012_peaceful_leopardon", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1766327760039, + "tag": "0013_past_logan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/02_user.ts b/src/db/schema/02_user.ts index 23dedec1..66ff84ef 100644 --- a/src/db/schema/02_user.ts +++ b/src/db/schema/02_user.ts @@ -6,7 +6,7 @@ import {project} from "./06_project"; import {member, OrganizationMember} from "@/db/schema/04_member"; import {invitation} from "@/db/schema/05_invitation"; import {organization} from "@/db/schema/03_organization"; -import {Account} from "better-auth"; +import {Account as BetterAuthAccount} from "better-auth"; import {timestamps} from "@/db/schema/00_common"; export const user = pgTable("user", { @@ -19,6 +19,8 @@ export const user = pgTable("user", { banned: boolean("banned"), banReason: text("ban_reason"), banExpires: timestamp("ban_expires"), + lastChangedPasswordAt: timestamp(), + twoFactorEnabled: boolean("two_factor_enabled").default(false), ...timestamps }); @@ -95,7 +97,14 @@ export const projectRelations = relations(project, ({one}) => ({ export const userSchema = createSelectSchema(user); export type User = z.infer; -type FixedAccount = Omit & { +export const sessionSchema = createSelectSchema(session); +export type Session = z.infer; + +export const accountSchema = createSelectSchema(account); +export type Account = z.infer; + + +type FixedAccount = Omit & { updatedAt: Date | null; }; diff --git a/src/features/layout/Header.tsx b/src/features/layout/Header.tsx index dca1a487..deebb3f4 100644 --- a/src/features/layout/Header.tsx +++ b/src/features/layout/Header.tsx @@ -3,10 +3,9 @@ import {notFound} from "next/navigation"; import {SidebarTrigger} from "@/components/ui/sidebar"; import {ModeToggle} from "@/features/theme/ModeToggle"; import {currentUser} from "@/lib/auth/current-user"; -import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button"; import {BreadCrumbsWrapper} from "@/components/wrappers/common/bread-crumbs/bread-crumbs"; import GitHubStarsButtonCustom from "@/components/wrappers/common/github/github-button"; -// import GitHubStarsButtonCustom from "@/components/wrappers/common/github-button"; +import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button"; export const Header = async () => { const user = await currentUser(); diff --git a/src/lib/auth/auth-client.ts b/src/lib/auth/auth-client.ts index 7390f80c..2fe12af2 100644 --- a/src/lib/auth/auth-client.ts +++ b/src/lib/auth/auth-client.ts @@ -1,7 +1,7 @@ "use client" import {createAuthClient} from "better-auth/react"; -import {adminClient, inferAdditionalFields, organizationClient} from "better-auth/client/plugins"; +import {adminClient, inferAdditionalFields, organizationClient, twoFactorClient} from "better-auth/client/plugins"; import {ac, user, admin as adminRole, pending, superadmin, orgAdmin, orgMember, orgOwner} from "./permissions"; import {auth} from "@/lib/auth/auth"; import {getServerUrl} from "@/utils/get-server-url"; @@ -12,6 +12,7 @@ const {PROJECT_URL} = await res.json(); export const authClient = createAuthClient({ baseURL: PROJECT_URL, plugins: [ + twoFactorClient(), organizationClient({ ac, roles: { @@ -34,4 +35,4 @@ export const authClient = createAuthClient({ }); -export const {signIn, signOut, signUp, useSession, listAccounts, admin} = authClient; +export const {signIn, signOut, signUp, useSession, listAccounts, admin, requestPasswordReset} = authClient; diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index 82e56550..4198ba10 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -4,7 +4,7 @@ import * as drizzleDb from "@/db"; import {db} from "@/db"; import {env} from "@/env.mjs"; import {nextCookies} from "better-auth/next-js"; -import {admin as adminPlugin, openAPI, Organization, organization} from "better-auth/plugins"; +import {admin as adminPlugin, openAPI, Organization, organization, twoFactor} from "better-auth/plugins"; import {ac, admin, orgAdmin, orgMember, orgOwner, pending, superadmin, user} from "@/lib/auth/permissions"; import {headers} from "next/headers"; import {count, eq} from "drizzle-orm"; @@ -12,6 +12,9 @@ import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_or import {sendEmail} from "@/lib/email/email-helper"; import {render} from "@react-email/render"; import EmailResetPassword from "@/components/emails/email-reset-password"; +import {SUPPORTED_PROVIDERS} from "../../../portabase.config"; +import {withUpdatedAt} from "@/db/utils"; +import EmailVerification from "@/components/emails/email-verification"; export const auth = betterAuth({ database: drizzleAdapter(db, { @@ -23,34 +26,73 @@ export const auth = betterAuth({ enabled: true, requireEmailVerification: false, sendResetPassword: async ({user, url, token}, request) => { + + const [updatedUser] = await db.update(drizzleDb.schemas.user).set(withUpdatedAt({ + emailVerified: true, + })).where(eq(drizzleDb.schemas.user.id, user.id)).returning(); + await sendEmail({ to: user.email, subject: "Reset your password", html: await render(EmailResetPassword({url: url})), }); + }, onPasswordReset: async ({user}, request) => { console.log(`Password for user ${user.email} has been reset.`); }, - - /* - async sendVerificationEmail(data, request) { - // Send an email to the user with a link to verify their email - }, - async verifyEmail(data, request) { - // Verify the email address - },*/ - }, - socialProviders: { - google: { - clientId: env.AUTH_GOOGLE_ID!, - clientSecret: env.AUTH_GOOGLE_SECRET!, + emailVerification: { + async sendVerificationEmail({user, token, url}) { + + + + await sendEmail({ + to: user.email, + subject: "Portabase Email Verification", + html: await render(EmailVerification({ + firstname: user.name, + url: url + })), + }); + + await ( + await auth.$context + ).internalAdapter.updateUser(user.id, { + emailVerified: false, + }); + }, + async afterEmailVerification(user) { + await ( + await auth.$context + ).internalAdapter.updateUser(user.id, { + emailVerified: true, + }); }, }, + + + socialProviders: SUPPORTED_PROVIDERS.reduce((acc: any, provider: any) => { + if (provider.id === "credential") return acc; + if (provider.id === "google") { + acc.google = { + clientId: env.AUTH_GOOGLE_ID! as string, + clientSecret: env.AUTH_GOOGLE_SECRET! as string, + }; + } + return acc; + }, {}), + account: { + accountLinking: { + enabled: true, + }, + }, + + plugins: [ openAPI(), nextCookies(), + twoFactor(), organization({ ac, roles: { @@ -80,6 +122,9 @@ export const auth = betterAuth({ deleteUser: { enabled: true, }, + changeEmail: { + enabled: true, + }, additionalFields: { deletedAt: { type: "number", diff --git a/src/lib/zod.ts b/src/lib/zod.ts new file mode 100644 index 00000000..151903f0 --- /dev/null +++ b/src/lib/zod.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const zString = () => + z.string(); + +export const zEnum = (values: T) => z.enum(values, { message: "Field required" }); + +export const zEmail = () => z.string().email({ message: "Invalid email" }); + +const passwordRegex = /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-])/; + +export const zPassword = () => zString().min(8, { message: "New Password too short" }).regex(passwordRegex, { message: "New password too weak" }); + +export const zDate = () => + z.preprocess( + (arg) => { + if (typeof arg === "string" || arg instanceof Date) { + const date = new Date(arg); + return isNaN(date.getTime()) ? undefined : date; + } + return undefined; + }, + z.date() + ); diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 00000000..bc6ea81c --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,17 @@ + +export type SignUpUser = { + name: string + email: string + password: string + callbackURL?: string + theme: string + isDefaultPassword: boolean +} + + +export type BetterAuthError = { + code?: string; + message?: string; + status: number; + statusText: string; +}; \ No newline at end of file diff --git a/src/utils/date-formatting.ts b/src/utils/date-formatting.ts index 536d30ab..ca8922c1 100644 --- a/src/utils/date-formatting.ts +++ b/src/utils/date-formatting.ts @@ -1,4 +1,5 @@ import {formatDistanceToNow} from "date-fns"; +import {format} from "date-fns"; /** * Get user's locale and timezone from the browser @@ -36,3 +37,4 @@ export function formatDateLastContact(lastContact: string | number | Date | null ? formatLocalizedDate(lastContact) : "Never connected."; } + diff --git a/src/utils/detection.ts b/src/utils/detection.ts new file mode 100644 index 00000000..8db1f96a --- /dev/null +++ b/src/utils/detection.ts @@ -0,0 +1,37 @@ +import { Smartphone, Laptop, type LucideIcon } from "lucide-react"; + +interface DeviceDetails { + os: string; + browser: string; + isMobile: boolean; + Icon: LucideIcon; + deviceType: "Mobile" | "Desktop"; +} + +export const getDeviceDetails = (userAgent: string | undefined | null): DeviceDetails => { + const ua = (userAgent || "").toLowerCase(); + + const isMobile = /mobile|iphone|android/.test(ua); + + const os = /mac os/.test(ua) + ? "macOS" + : /windows/.test(ua) + ? "Windows" + : /linux/.test(ua) + ? "Linux" + : /android/.test(ua) + ? "Android" + : /ios/.test(ua) + ? "iOS" + : "Unknown OS"; + + const browser = /chrome/.test(ua) ? "Chrome" : /firefox/.test(ua) ? "Firefox" : /safari/.test(ua) ? "Safari" : /edg/.test(ua) ? "Edge" : "Browser"; + + return { + os, + browser, + isMobile, + deviceType: isMobile ? "Mobile" : "Desktop", + Icon: isMobile ? Smartphone : Laptop, + }; +}; diff --git a/yarn.lock b/yarn.lock index ba373d47..c381fe7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11869,6 +11869,7 @@ __metadata: react-dropzone: "npm:^14.3.8" react-email: "npm:^4.0.13" react-hook-form: "npm:^7.56.3" + react-qr-code: "npm:^2.0.18" react-resizable-panels: "npm:^3.0.2" react-twc: "npm:^1.4.2" react-use-measure: "npm:^2.1.7" @@ -12147,6 +12148,13 @@ __metadata: languageName: node linkType: hard +"qr.js@npm:0.0.0": + version: 0.0.0 + resolution: "qr.js@npm:0.0.0" + checksum: 10c0/1c6a4c7a58d04e52ec2fee99e39b680fdc5b2a510a981df42c36b716a8eac6634d130fc4d65af8f030f2a07dbf5fa046b97cdfa7456c250ebb50a73916efdcb5 + languageName: node + linkType: hard + "query-string@npm:^7.1.3": version: 7.1.3 resolution: "query-string@npm:7.1.3" @@ -12291,6 +12299,18 @@ __metadata: languageName: node linkType: hard +"react-qr-code@npm:^2.0.18": + version: 2.0.18 + resolution: "react-qr-code@npm:2.0.18" + dependencies: + prop-types: "npm:^15.8.1" + qr.js: "npm:0.0.0" + peerDependencies: + react: "*" + checksum: 10c0/4e13b795cbb10f1dcf0e39d682bb59851e4c84010ba2be7225b2ad9d5c1ffea52d2d38f884ee26235b7002b8ca99e83b805f55e877663c39d67496764d975cf1 + languageName: node + linkType: hard + "react-remove-scroll-bar@npm:^2.3.7": version: 2.3.8 resolution: "react-remove-scroll-bar@npm:2.3.8"