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
@@ -15,7 +15,7 @@ import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/l
export function AppSidebar() {
const projectName = env.PROJECT_NAME;
return (
<Sidebar collapsible="icon">
<Sidebar collapsible="icon" >
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -1,6 +1,6 @@
"use client";
import {useState} from "react";
import React, {useState} from "react";
import {Button} from "@/components/ui/button";
import {
Dialog,
@@ -24,6 +24,7 @@ import z from "zod";
import {zPassword} from "@/lib/zod";
import {BackupCodesList} from "../components/backup-codes-list";
import {PasswordInput} from "@/components/ui/password-input";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
const PasswordSchema = z.object({
password: zPassword(),
@@ -34,9 +35,10 @@ type Password = z.infer<typeof PasswordSchema>;
type Setup2FAModalProps = {
onOpenChange: (open: boolean) => void;
open: boolean;
disabled: boolean;
};
export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModalProps) {
export function Setup2FAProfileProviderModal({onOpenChange, open, disabled}: Setup2FAModalProps) {
const router = useRouter();
const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD");
@@ -113,10 +115,9 @@ export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModal
};
return (
<Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}>
<DialogTrigger asChild>
<DialogTrigger asChild disabled={disabled}>
<Button variant="outline" size="sm">
<ShieldCheck className="w-4 h-4 mr-2"/>
Enable Two-Factor
@@ -177,7 +178,8 @@ export function Setup2FAProfileProviderModal({onOpenChange, open}: Setup2FAModal
</div>
<div className="w-full space-y-2">
<p className="text-xs text-muted-foreground text-center">If you are unable to scan the QR code, you can manually enter the secret key into your authentication app :</p>
<p className="text-xs text-muted-foreground text-center">If you are unable to scan the
QR code, you can manually enter the secret key into your authentication app :</p>
<div className="flex items-center gap-2">
<code
className="flex-1 bg-muted p-2 rounded text-xs font-mono break-all text-center">{secret}</code>
@@ -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">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400"/>
<AlertDescription
className="text-green-700 dark:text-green-400">Two Factor Authentication is now enabled on your account.</AlertDescription>
className="text-green-700 dark:text-green-400">Two Factor Authentication is now enabled
on your account.</AlertDescription>
</Alert>
<BackupCodesList codes={backupCodes}/>
@@ -40,7 +40,7 @@ function ThemeSelector() {
)}
onClick={async () => {
// setTheme(item.value)
// await authClient.updateUser({theme: item.value});
await authClient.updateUser({theme: item.value});
}}
>
<div
@@ -74,11 +74,9 @@ export function ProfileSecurity({user, sessions, credentialAccount, currentSessi
<div className="space-y-1">
<div className="font-medium">Password</div>
<div className="text-sm text-muted-foreground">
{/*{user.lastChangedPasswordAt*/}
{/* ? t("sections.authentication.password.description.last_changed", {*/}
{/* date: timeAgo(new Date(user.lastChangedPasswordAt), locale),*/}
{/* })*/}
{/* : "Never changed"}*/}
{user.lastChangedPasswordAt
? `Last changed ${timeAgo(new Date(user.lastChangedPasswordAt))}`
: "Never changed"}
</div>
</div>
{credentialAccount ? (
@@ -116,8 +114,11 @@ export function ProfileSecurity({user, sessions, credentialAccount, currentSessi
onOpenChange={setIsDisable2FADialogOpen}/>
</div>
) : (
<Setup2FAProfileProviderModal open={isSetup2FADialogOpen}
onOpenChange={setIsSetup2FADialogOpen}/>
<Setup2FAProfileProviderModal
disabled={!credentialAccount}
open={isSetup2FADialogOpen}
onOpenChange={setIsSetup2FADialogOpen}
/>
)}
</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,
"tag": "0013_past_logan",
"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 {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 {z} from "zod";
import {project} from "./06_project";
@@ -9,6 +9,9 @@ import {organization} from "@/db/schema/03_organization";
import {Account as BetterAuthAccount} from "better-auth";
import {timestamps} from "@/db/schema/00_common";
export const userThemeEnum = pgEnum("user_themes", ["light", "dark", "system"]);
export const user = pgTable("user", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
@@ -16,6 +19,7 @@ export const user = pgTable("user", {
emailVerified: boolean("email_verified").notNull(),
image: text("image"),
role: text("role"),
theme: userThemeEnum().notNull().default("light"),
banned: boolean("banned"),
banReason: text("ban_reason"),
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}) => ({
sessions: many(session),
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 {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 {BreadCrumbsWrapper} from "@/components/wrappers/common/bread-crumbs/bread-crumbs";
import GitHubStarsButtonCustom from "@/components/wrappers/common/github/github-button";
@@ -2,13 +2,17 @@
import * as React from "react"
import {Moon, Sun} from "lucide-react"
import {useTheme} from "next-themes"
import {Button} from "@/components/ui/button"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
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 (
<DropdownMenu>
@@ -33,3 +37,5 @@ export function ModeToggle() {
</DropdownMenu>
)
}
+10
View File
@@ -21,6 +21,7 @@ export const auth = betterAuth({
provider: "pg",
schema: drizzleDb.schemas,
}),
appName: env.PROJECT_NAME!,
baseURL: env.PROJECT_URL,
secret: env.PROJECT_SECRET,
emailAndPassword: {
@@ -131,6 +132,15 @@ export const auth = betterAuth({
nullable: true,
required: false,
},
theme: {
type: "string",
},
lastConnectedAt: {
type: "date",
},
lastChangedPasswordAt: {
type: "date",
},
},
},
databaseHooks: {