fix: demo acl

This commit is contained in:
charles-gauthereau
2026-06-10 21:10:33 +02:00
parent b9924f511f
commit be12220574
7 changed files with 138 additions and 85 deletions
+6
View File
@@ -8,13 +8,18 @@ import { currentUser } from "@/lib/auth/current-user";
import { ThemeMetaUpdater } from "@/features/theme/theme-meta-updater"; import { ThemeMetaUpdater } from "@/features/theme/theme-meta-updater";
import { ModeToggle } from "@/features/theme/mode-toggle"; import { ModeToggle } from "@/features/theme/mode-toggle";
import { UpdateNotification } from "@/features/updates/update-notification"; import { UpdateNotification } from "@/features/updates/update-notification";
import {AclProvider} from "@/lib/acl/acl-context";
import {env} from "@/env.mjs";
export default async function Layout({ children }: { children: 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");
const isDemoEnabled = env.DEMO_ENABLED
return ( return (
<SidebarProvider> <SidebarProvider>
<AclProvider demo={isDemoEnabled} user={user}>
<div className="flex flex-col lg:flex-row w-full"> <div className="flex flex-col lg:flex-row w-full">
<ThemeMetaUpdater /> <ThemeMetaUpdater />
<AppSidebar updateNotification={<UpdateNotification />} /> <AppSidebar updateNotification={<UpdateNotification />} />
@@ -23,6 +28,7 @@ export default async function Layout({ children }: { children: ReactNode }) {
<main className="h-full">{children}</main> <main className="h-full">{children}</main>
</SidebarInset> </SidebarInset>
</div> </div>
</AclProvider>
</SidebarProvider> </SidebarProvider>
); );
} }
+31 -20
View File
@@ -1,4 +1,5 @@
"use client"; "use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { UploadIcon } from "lucide-react"; import { UploadIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -11,22 +12,21 @@ import React, {ChangeEvent} from "react";
export type AvatarWithUploadProps = { export type AvatarWithUploadProps = {
user: User; user: User;
disabled?: boolean;
}; };
export const AvatarWithUpload = (props: AvatarWithUploadProps) => { export const AvatarWithUpload = ({ user, disabled = false }: AvatarWithUploadProps) => {
const user = props.user;
const router = useRouter(); const router = useRouter();
const submitImage = useMutation({ const submitImage = useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
const formData = new FormData(); const formData = new FormData();
formData.set("file", file); formData.set("file", file);
const result = await uploadUserImageAction(formData);
const result = await uploadUserImageAction(formData);
const inner = result?.data; const inner = result?.data;
if (inner?.success) { if (inner?.success) {
const updateUser = await updateImageUserAction(inner.value ?? ""); const updateUser = await updateImageUserAction(inner.value ?? "");
const dataUser = updateUser?.data; const dataUser = updateUser?.data;
@@ -40,12 +40,14 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
} else { } else {
toast.error(inner?.actionError?.message); toast.error(inner?.actionError?.message);
} }
}, },
}); });
const isUploadDisabled = disabled || submitImage.isPending;
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => { const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
if (isUploadDisabled) return;
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (!file) return; if (!file) return;
@@ -57,6 +59,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
} }
const maxSizeInMB = 5; const maxSizeInMB = 5;
if (file.size > maxSizeInMB * 1024 * 1024) { if (file.size > maxSizeInMB * 1024 * 1024) {
toast.error(`Image must be smaller than ${maxSizeInMB}MB.`); toast.error(`Image must be smaller than ${maxSizeInMB}MB.`);
return; return;
@@ -65,29 +68,37 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
submitImage.mutate(file); submitImage.mutate(file);
}; };
const openFilePicker = () => {
if (isUploadDisabled) return;
return (
<div className="relative ">
<Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20">
<AvatarImage className="object-cover" src={user.image || undefined}/>
<AvatarFallback className="text-3xl">{user.name.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div
onClick={() => {
const fileInput = document.createElement("input"); const fileInput = document.createElement("input");
fileInput.type = "file"; fileInput.type = "file";
fileInput.accept = ".jpg,.jpeg,.png,.webp"; fileInput.accept = ".jpg,.jpeg,.png,.webp";
fileInput.onchange = (e: Event) => fileInput.disabled = isUploadDisabled;
handleImageUpload(e as unknown as React.ChangeEvent<HTMLInputElement>);
fileInput.onchange = (event: Event) =>
handleImageUpload(event as unknown as React.ChangeEvent<HTMLInputElement>);
fileInput.click(); fileInput.click();
}} };
return (
<div className="relative">
<Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20">
<AvatarImage className="object-cover" src={user.image || undefined} />
<AvatarFallback className="text-3xl">
{user.name.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{!isUploadDisabled && (
<div
onClick={openFilePicker}
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32" className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32"
> >
<UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary" /> <UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary" />
</div> </div>
)}
</div> </div>
); );
}; };
+4 -3
View File
@@ -22,6 +22,7 @@ import {updateProfileSettingsAction} from "./profile.action";
import {User} from "@/db/schema/02_user"; import {User} from "@/db/schema/02_user";
import {ProfileSchema, ProfileSchemaType} from "./general.schema"; import {ProfileSchema, ProfileSchemaType} from "./general.schema";
import {AvatarWithUpload} from "@/features/profile/avatar-with-upload"; import {AvatarWithUpload} from "@/features/profile/avatar-with-upload";
import {useAcl} from "@/lib/acl/acl-context";
interface ProfileGeneralProps { interface ProfileGeneralProps {
user: User; user: User;
@@ -29,6 +30,7 @@ interface ProfileGeneralProps {
export function ProfileGeneral({user}: ProfileGeneralProps) { export function ProfileGeneral({user}: ProfileGeneralProps) {
const router = useRouter(); const router = useRouter();
const {isSuperAdminAndDemo} = useAcl()
const profileForm = useZodForm({ const profileForm = useZodForm({
schema: ProfileSchema, schema: ProfileSchema,
@@ -62,12 +64,13 @@ export function ProfileGeneral({user}: ProfileGeneralProps) {
<div className="flex flex-col sm:flex-row gap-8 items-start"> <div className="flex flex-col sm:flex-row gap-8 items-start">
<div className="flex flex-col items-center gap-4" > <div className="flex flex-col items-center gap-4" >
<AvatarWithUpload <AvatarWithUpload
disabled={isSuperAdminAndDemo}
user={user} user={user}
/> />
</div> </div>
<div className="flex-1 w-full max-w-lg"> <div className="flex-1 w-full max-w-lg">
<Form form={profileForm} onSubmit={(values) => updateProfile(values)}> <Form disabled={isSuperAdminAndDemo} form={profileForm} onSubmit={(values) => updateProfile(values)}>
<div className="space-y-6"> <div className="space-y-6">
<FormField <FormField
control={profileForm.control} control={profileForm.control}
@@ -119,9 +122,7 @@ export function ProfileGeneral({user}: ProfileGeneralProps) {
} }
function RoleBadge({role}: { role: string }) { function RoleBadge({role}: { role: string }) {
const variant = role === "admin" ? "default" : "secondary"; const variant = role === "admin" ? "default" : "secondary";
return ( return (
<Badge variant={variant} className="capitalize px-2 py-0.5 text-xs"> <Badge variant={variant} className="capitalize px-2 py-0.5 text-xs">
{role} {role}
@@ -45,6 +45,7 @@ import { Icon } from "@iconify/react";
import Image from "next/image"; import Image from "next/image";
import type { AuthProviderConfig } from "@/lib/auth/config"; import type { AuthProviderConfig } from "@/lib/auth/config";
import { is } from "date-fns/locale"; import { is } from "date-fns/locale";
import {useAcl} from "@/lib/acl/acl-context";
interface ProfileSecurityProps { interface ProfileSecurityProps {
user: User; user: User;
@@ -66,6 +67,7 @@ export function ProfileSecurity({
providers, providers,
}: ProfileSecurityProps) { }: ProfileSecurityProps) {
const router = useRouter(); const router = useRouter();
const {isSuperAdminAndDemo} = useAcl()
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false); const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false); const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
+1 -1
View File
@@ -33,7 +33,7 @@ export const updateProfileSettingsAction = userAction.schema(UpdateProfileSchema
await db await db
.update(user) .update(user)
.set({ .set({
...(parsedInput.name ? { name: parsedInput.name } : {}), ...(parsedInput.name ? { name: parsedInput.name.trim() } : {}),
}) })
.where(eq(user.id, session.user.id)); .where(eq(user.id, session.user.id));
+33
View File
@@ -0,0 +1,33 @@
"use client";
import {createContext, useContext, ReactNode} from "react";
import {User} from "@/db/schema/02_user";
import {useSystemPermissions} from "@/hooks/acl/use-system-acl";
import {SystemPermissions} from "@/lib/acl/system-acl";
type AclContextType = {
isDemoEnabled: boolean;
isSuperAdminAndDemo: boolean;
user: User;
permissions: SystemPermissions;
};
const AclContext = createContext<AclContextType | undefined>(undefined);
export const AclProvider = ({demo, user, children}: { demo: boolean, user:User, children: ReactNode }) => {
const permissions = useSystemPermissions(user);
const isSuperAdminAndDemo = demo && permissions.isSuperAdmin;
return (
<AclContext.Provider value={{isDemoEnabled: demo,user, permissions, isSuperAdminAndDemo}}>
{children}
</AclContext.Provider>
);
};
export const useAcl = () => {
const context = useContext(AclContext);
if (!context) throw new Error("useAcl must be used within AclProvider");
return context;
};
+1 -1
View File
@@ -7,7 +7,6 @@ export type SystemPermissions = {
isSuperAdmin: boolean; isSuperAdmin: boolean;
isAdmin: boolean; isAdmin: boolean;
isUser: boolean; isUser: boolean;
canAccessSystem: boolean; canAccessSystem: boolean;
canCreateUser: boolean; canCreateUser: boolean;
@@ -28,6 +27,7 @@ export type SystemPermissions = {
export const computeSystemPermissions = ( export const computeSystemPermissions = (
user: User | null, user: User | null,
): SystemPermissions => { ): SystemPermissions => {
const role = (user?.role as SystemRole) ?? null; const role = (user?.role as SystemRole) ?? null;
const isSuperAdmin = role === "superadmin"; const isSuperAdmin = role === "superadmin";