Working on the profile module.

This commit is contained in:
charlesgauthereau
2025-12-22 20:32:18 +01:00
parent 08f2ad0521
commit 601dae4909
17 changed files with 4020 additions and 41 deletions
+2 -4
View File
@@ -1,4 +1,4 @@
import React from "react"; import React, {ReactNode} from "react";
import {redirect} from "next/navigation"; import {redirect} from "next/navigation";
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar"; import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
@@ -6,12 +6,11 @@ import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sid
import {Header} from "@/features/layout/Header"; import {Header} from "@/features/layout/Header";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
export default async function Layout({children}: { children: React.ReactNode }) { export default async function Layout({children}: { children: ReactNode }) {
const user = await currentUser(); const user = await currentUser();
if (!user) redirect("/login"); if (!user) redirect("/login");
return ( return (
<>
<SidebarProvider> <SidebarProvider>
<div className="flex flex-col lg:flex-row w-full"> <div className="flex flex-col lg:flex-row w-full">
<AppSidebar/> <AppSidebar/>
@@ -21,6 +20,5 @@ export default async function Layout({children}: { children: React.ReactNode })
</SidebarInset> </SidebarInset>
</div> </div>
</SidebarProvider> </SidebarProvider>
</>
); );
} }
+8 -3
View File
@@ -5,6 +5,7 @@ import {Providers} from "./providers";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer"; import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
import {inter} from "@/fonts/fonts"; import {inter} from "@/fonts/fonts";
import {currentUser} from "@/lib/auth/current-user";
const title = process.env.PROJECT_NAME ?? "App Title"; const title = process.env.PROJECT_NAME ?? "App Title";
@@ -16,19 +17,23 @@ export const metadata: Metadata = {
description: process.env.PROJECT_DESCRIPTION ?? undefined, description: process.env.PROJECT_DESCRIPTION ?? undefined,
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const user = await currentUser();
console.info("useerrrrr",user)
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head> <head>
<meta name="apple-mobile-web-app-title" content="Portabase"/> <meta name="apple-mobile-web-app-title" content={title}/>
</head> </head>
<body className={cn(inter.className, "h-full")}> <body className={cn(inter.className, "h-full")}>
<ConsoleSilencer/> <ConsoleSilencer/>
<Providers>{children}</Providers> <Providers >{children}</Providers>
</body> </body>
</html> </html>
); );
+10 -3
View File
@@ -5,14 +5,21 @@ import { ThemeProvider } from "@/features/theme/theme-provider";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {ThemeMetaUpdater} from "@/features/browser/theme-meta-updater";
export type ProviderProps = PropsWithChildren<{}>; export type ProviderProps = PropsWithChildren<{
}>;
const queryClient = new QueryClient(); const queryClient = new QueryClient();
export const Providers = (props: ProviderProps) => { export const Providers = (props: ProviderProps) => {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null} >
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> <ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
>
<ThemeMetaUpdater />
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Toaster /> <Toaster />
{props.children} {props.children}
@@ -15,7 +15,7 @@ import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/l
export function AppSidebar() { export function AppSidebar() {
const projectName = env.PROJECT_NAME; const projectName = env.PROJECT_NAME;
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon" >
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
@@ -1,6 +1,6 @@
"use client"; "use client";
import {useState} from "react"; import React, {useState} from "react";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -24,6 +24,7 @@ import z from "zod";
import {zPassword} from "@/lib/zod"; import {zPassword} from "@/lib/zod";
import {BackupCodesList} from "../components/backup-codes-list"; import {BackupCodesList} from "../components/backup-codes-list";
import {PasswordInput} from "@/components/ui/password-input"; import {PasswordInput} from "@/components/ui/password-input";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
const PasswordSchema = z.object({ const PasswordSchema = z.object({
password: zPassword(), password: zPassword(),
@@ -34,9 +35,10 @@ type Password = z.infer<typeof PasswordSchema>;
type Setup2FAModalProps = { type Setup2FAModalProps = {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
open: boolean; open: boolean;
disabled: boolean;
}; };
export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModalProps) { export function Setup2FAProfileProviderModal({onOpenChange, open, disabled}: Setup2FAModalProps) {
const router = useRouter(); const router = useRouter();
const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD"); const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD");
@@ -113,10 +115,9 @@ export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModal
}; };
return ( return (
<Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}> <Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}>
<DialogTrigger asChild> <DialogTrigger asChild disabled={disabled}>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<ShieldCheck className="w-4 h-4 mr-2"/> <ShieldCheck className="w-4 h-4 mr-2"/>
Enable Two-Factor Enable Two-Factor
@@ -177,7 +178,8 @@ export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModal
</div> </div>
<div className="w-full space-y-2"> <div className="w-full space-y-2">
<p className="text-xs text-muted-foreground text-center">If you are unable to scan the QR code, you can manually enter the secret key into your authentication app :</p> <p className="text-xs text-muted-foreground text-center">If you are unable to scan the
QR code, you can manually enter the secret key into your authentication app :</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code <code
className="flex-1 bg-muted p-2 rounded text-xs font-mono break-all text-center">{secret}</code> className="flex-1 bg-muted p-2 rounded text-xs font-mono break-all text-center">{secret}</code>
@@ -237,7 +239,8 @@ export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModal
className="border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-900"> className="border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-900">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400"/> <CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400"/>
<AlertDescription <AlertDescription
className="text-green-700 dark:text-green-400">Two Factor Authentication is now enabled on your account.</AlertDescription> className="text-green-700 dark:text-green-400">Two Factor Authentication is now enabled
on your account.</AlertDescription>
</Alert> </Alert>
<BackupCodesList codes={backupCodes}/> <BackupCodesList codes={backupCodes}/>
@@ -40,7 +40,7 @@ function ThemeSelector() {
)} )}
onClick={async () => { onClick={async () => {
// setTheme(item.value) // setTheme(item.value)
// await authClient.updateUser({theme: item.value}); await authClient.updateUser({theme: item.value});
}} }}
> >
<div <div
@@ -74,11 +74,9 @@ export function ProfileSecurity({user, sessions, credentialAccount, currentSessi
<div className="space-y-1"> <div className="space-y-1">
<div className="font-medium">Password</div> <div className="font-medium">Password</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{/*{user.lastChangedPasswordAt*/} {user.lastChangedPasswordAt
{/* ? t("sections.authentication.password.description.last_changed", {*/} ? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
{/* date: timeAgo(new Date(user.lastChangedPasswordAt), locale),*/} : "Never changed"}
{/* })*/}
{/* : "Never changed"}*/}
</div> </div>
</div> </div>
{credentialAccount ? ( {credentialAccount ? (
@@ -116,8 +114,11 @@ export function ProfileSecurity({user, sessions, credentialAccount, currentSessi
onOpenChange={setIsDisable2FADialogOpen}/> onOpenChange={setIsDisable2FADialogOpen}/>
</div> </div>
) : ( ) : (
<Setup2FAProfileProviderModal open={isSetup2FADialogOpen} <Setup2FAProfileProviderModal
onOpenChange={setIsSetup2FADialogOpen}/> disabled={!credentialAccount}
open={isSetup2FADialogOpen}
onOpenChange={setIsSetup2FADialogOpen}
/>
)} )}
</div> </div>
</div> </div>
@@ -0,0 +1,24 @@
CREATE TABLE "passkey" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text,
"publicKey" text NOT NULL,
"userId" uuid NOT NULL,
"credentialId" text NOT NULL,
"counter" integer NOT NULL,
"deviceType" text NOT NULL,
"backedUp" boolean NOT NULL,
"transports" text,
"updated_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"deleted_at" timestamp
);
--> statement-breakpoint
CREATE TABLE "two_factor" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"secret" text NOT NULL,
"backup_codes" text NOT NULL,
"user_id" uuid NOT NULL
);
--> statement-breakpoint
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,2 @@
CREATE TYPE "public"."user_themes" AS ENUM('light', 'dark', 'system');--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "theme" "user_themes" DEFAULT 'light' NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -99,6 +99,20 @@
"when": 1766327760039, "when": 1766327760039,
"tag": "0013_past_logan", "tag": "0013_past_logan",
"breakpoints": true "breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1766424611387,
"tag": "0014_strong_galactus",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1766426190521,
"tag": "0015_absurd_next_avengers",
"breakpoints": true
} }
] ]
} }
+30 -1
View File
@@ -1,5 +1,5 @@
import {relations} from "drizzle-orm"; import {relations} from "drizzle-orm";
import {boolean, pgTable, text, timestamp, uuid} from "drizzle-orm/pg-core"; import {boolean, integer, pgEnum, pgTable, text, timestamp, uuid} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod"; import {createSelectSchema} from "drizzle-zod";
import {z} from "zod"; import {z} from "zod";
import {project} from "./06_project"; import {project} from "./06_project";
@@ -9,6 +9,9 @@ import {organization} from "@/db/schema/03_organization";
import {Account as BetterAuthAccount} from "better-auth"; import {Account as BetterAuthAccount} from "better-auth";
import {timestamps} from "@/db/schema/00_common"; import {timestamps} from "@/db/schema/00_common";
export const userThemeEnum = pgEnum("user_themes", ["light", "dark", "system"]);
export const user = pgTable("user", { export const user = pgTable("user", {
id: uuid("id").defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(), name: text("name").notNull(),
@@ -16,6 +19,7 @@ export const user = pgTable("user", {
emailVerified: boolean("email_verified").notNull(), emailVerified: boolean("email_verified").notNull(),
image: text("image"), image: text("image"),
role: text("role"), role: text("role"),
theme: userThemeEnum().notNull().default("light"),
banned: boolean("banned"), banned: boolean("banned"),
banReason: text("ban_reason"), banReason: text("ban_reason"),
banExpires: timestamp("ban_expires"), banExpires: timestamp("ban_expires"),
@@ -66,6 +70,31 @@ export const verification = pgTable("verification", {
}); });
export const passkey = pgTable("passkey", {
id: uuid().defaultRandom().primaryKey(),
name: text(),
publicKey: text().notNull(),
userId: uuid()
.notNull()
.references(() => user.id, {onDelete: "cascade"}),
credentialId: text().notNull(),
counter: integer().notNull(),
deviceType: text().notNull(),
backedUp: boolean().notNull(),
transports: text(),
...timestamps,
});
export const twoFactor = pgTable("two_factor", {
id: uuid().defaultRandom().primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: uuid("user_id")
.notNull()
.references(() => user.id, {onDelete: "cascade"}),
});
export const userRelations = relations(user, ({many}) => ({ export const userRelations = relations(user, ({many}) => ({
sessions: many(session), sessions: many(session),
accounts: many(account), accounts: many(account),
@@ -0,0 +1,67 @@
"use client";
import {useTheme} from "next-themes";
import {useEffect} from "react";
import {authClient} from "@/lib/auth/auth-client";
export function ThemeMetaUpdater() {
const {resolvedTheme, setTheme} = useTheme();
const {data: session} = authClient.useSession();
useEffect(() => {
if (!resolvedTheme) return;
const color = resolvedTheme === "dark" ? "#000000" : "#ffffff";
let tag = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
if (!tag) {
tag = document.createElement("meta");
tag.setAttribute("name", "theme-color");
document.head.appendChild(tag);
}
tag.setAttribute("content", color);
}, [resolvedTheme]);
useEffect(() => {
if (!session?.user?.theme) return;
setTheme(session.user.theme);
}, [session, resolvedTheme, setTheme]);
return null;
}
//
// "use client"
// import {useEffect, useState} from "react";
// import {useTheme} from "next-themes";
// import {authClient} from "@/lib/auth/auth-client";
//
// export function ThemeMetaUpdater() {
// const [mounted, setMounted] = useState(false);
// const {resolvedTheme, setTheme} = useTheme();
// const {data: session} = authClient.useSession();
//
// useEffect(() => setMounted(true), []);
//
// useEffect(() => {
// if (!mounted || !resolvedTheme) return;
// const color = resolvedTheme === "dark" ? "#000000" : "#ffffff";
// let tag = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
// if (!tag) {
// tag = document.createElement("meta");
// tag.setAttribute("name", "theme-color");
// document.head.appendChild(tag);
// }
// tag.setAttribute("content", color);
// }, [mounted, resolvedTheme]);
//
// useEffect(() => {
// if (!mounted || !session?.user?.theme) return;
// if (resolvedTheme !== session.user.theme) {
// setTheme(session.user.theme);
// }
// }, [mounted, session, setTheme]);
//
// return null;
// }
+1 -1
View File
@@ -1,7 +1,7 @@
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {SidebarTrigger} from "@/components/ui/sidebar"; import {SidebarTrigger} from "@/components/ui/sidebar";
import {ModeToggle} from "@/features/theme/ModeToggle"; import {ModeToggle} from "@/features/theme/mode-toggle";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {BreadCrumbsWrapper} from "@/components/wrappers/common/bread-crumbs/bread-crumbs"; 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/github-button";
@@ -2,13 +2,17 @@
import * as React from "react" import * as React from "react"
import {Moon, Sun} from "lucide-react" import {Moon, Sun} from "lucide-react"
import {useTheme} from "next-themes"
import {Button} from "@/components/ui/button" import {Button} from "@/components/ui/button"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu" import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
export function ModeToggle() { export function ModeToggle() {
const {setTheme} = useTheme()
const setTheme = async (theme: "light" | "dark" | "system") => {
toast.success("Your theme preference has been updated.");
await authClient.updateUser({theme: theme});
};
return ( return (
<DropdownMenu> <DropdownMenu>
@@ -33,3 +37,5 @@ export function ModeToggle() {
</DropdownMenu> </DropdownMenu>
) )
} }
+10
View File
@@ -21,6 +21,7 @@ export const auth = betterAuth({
provider: "pg", provider: "pg",
schema: drizzleDb.schemas, schema: drizzleDb.schemas,
}), }),
appName: env.PROJECT_NAME!,
baseURL: env.PROJECT_URL, baseURL: env.PROJECT_URL,
secret: env.PROJECT_SECRET, secret: env.PROJECT_SECRET,
emailAndPassword: { emailAndPassword: {
@@ -131,6 +132,15 @@ export const auth = betterAuth({
nullable: true, nullable: true,
required: false, required: false,
}, },
theme: {
type: "string",
},
lastConnectedAt: {
type: "date",
},
lastChangedPasswordAt: {
type: "date",
},
}, },
}, },
databaseHooks: { databaseHooks: {