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
+14 -8
View File
@@ -8,21 +8,27 @@ import { currentUser } from "@/lib/auth/current-user";
import { ThemeMetaUpdater } from "@/features/theme/theme-meta-updater";
import { ModeToggle } from "@/features/theme/mode-toggle";
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 }) {
const user = await currentUser();
if (!user) redirect("/login");
const isDemoEnabled = env.DEMO_ENABLED
return (
<SidebarProvider>
<div className="flex flex-col lg:flex-row w-full">
<ThemeMetaUpdater />
<AppSidebar updateNotification={<UpdateNotification />} />
<SidebarInset>
<Header actions={<ModeToggle />} />
<main className="h-full">{children}</main>
</SidebarInset>
</div>
<AclProvider demo={isDemoEnabled} user={user}>
<div className="flex flex-col lg:flex-row w-full">
<ThemeMetaUpdater />
<AppSidebar updateNotification={<UpdateNotification />} />
<SidebarInset>
<Header actions={<ModeToggle />} />
<main className="h-full">{children}</main>
</SidebarInset>
</div>
</AclProvider>
</SidebarProvider>
);
}
+43 -32
View File
@@ -1,32 +1,32 @@
"use client";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {UploadIcon} from "lucide-react";
import {toast} from "sonner";
import {uploadUserImageAction} from "@/features/upload/upload.action";
import {useMutation} from "@tanstack/react-query";
import {updateImageUserAction} from "@/features/profile/avatar.action";
import {useRouter} from "next/navigation";
import {User} from "@/db/schema/02_user";
import React, {ChangeEvent} from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { UploadIcon } from "lucide-react";
import { toast } from "sonner";
import { uploadUserImageAction } from "@/features/upload/upload.action";
import { useMutation } from "@tanstack/react-query";
import { updateImageUserAction } from "@/features/profile/avatar.action";
import { useRouter } from "next/navigation";
import { User } from "@/db/schema/02_user";
import React, { ChangeEvent } from "react";
export type AvatarWithUploadProps = {
user: User;
disabled?: boolean;
};
export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
const user = props.user;
export const AvatarWithUpload = ({ user, disabled = false }: AvatarWithUploadProps) => {
const router = useRouter();
const submitImage = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.set("file", file);
const result = await uploadUserImageAction(formData);
const result = await uploadUserImageAction(formData);
const inner = result?.data;
if (inner?.success) {
const updateUser = await updateImageUserAction(inner.value ?? "");
const dataUser = updateUser?.data;
@@ -40,12 +40,14 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
} else {
toast.error(inner?.actionError?.message);
}
},
});
const isUploadDisabled = disabled || submitImage.isPending;
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
if (isUploadDisabled) return;
const file = event.target.files?.[0];
if (!file) return;
@@ -57,6 +59,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
}
const maxSizeInMB = 5;
if (file.size > maxSizeInMB * 1024 * 1024) {
toast.error(`Image must be smaller than ${maxSizeInMB}MB.`);
return;
@@ -65,29 +68,37 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
submitImage.mutate(file);
};
const openFilePicker = () => {
if (isUploadDisabled) return;
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".jpg,.jpeg,.png,.webp";
fileInput.disabled = isUploadDisabled;
fileInput.onchange = (event: Event) =>
handleImageUpload(event as unknown as React.ChangeEvent<HTMLInputElement>);
fileInput.click();
};
return (
<div className="relative ">
<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>
<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");
fileInput.type = "file";
fileInput.accept = ".jpg,.jpeg,.png,.webp";
fileInput.onchange = (e: Event) =>
handleImageUpload(e as unknown as React.ChangeEvent<HTMLInputElement>);
fileInput.click();
}}
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"/>
</div>
{!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"
>
<UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary" />
</div>
)}
</div>
);
};
+5 -4
View File
@@ -22,6 +22,7 @@ import {updateProfileSettingsAction} from "./profile.action";
import {User} from "@/db/schema/02_user";
import {ProfileSchema, ProfileSchemaType} from "./general.schema";
import {AvatarWithUpload} from "@/features/profile/avatar-with-upload";
import {useAcl} from "@/lib/acl/acl-context";
interface ProfileGeneralProps {
user: User;
@@ -29,6 +30,7 @@ interface ProfileGeneralProps {
export function ProfileGeneral({user}: ProfileGeneralProps) {
const router = useRouter();
const {isSuperAdminAndDemo} = useAcl()
const profileForm = useZodForm({
schema: ProfileSchema,
@@ -60,14 +62,15 @@ export function ProfileGeneral({user}: ProfileGeneralProps) {
</div>
<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
disabled={isSuperAdminAndDemo}
user={user}
/>
</div>
<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">
<FormField
control={profileForm.control}
@@ -119,9 +122,7 @@ export function ProfileGeneral({user}: ProfileGeneralProps) {
}
function RoleBadge({role}: { role: string }) {
const variant = role === "admin" ? "default" : "secondary";
return (
<Badge variant={variant} className="capitalize px-2 py-0.5 text-xs">
{role}
@@ -45,6 +45,7 @@ import { Icon } from "@iconify/react";
import Image from "next/image";
import type { AuthProviderConfig } from "@/lib/auth/config";
import { is } from "date-fns/locale";
import {useAcl} from "@/lib/acl/acl-context";
interface ProfileSecurityProps {
user: User;
@@ -66,6 +67,7 @@ export function ProfileSecurity({
providers,
}: ProfileSecurityProps) {
const router = useRouter();
const {isSuperAdminAndDemo} = useAcl()
const [isBackupCodesDialogOpen, setIsBackupCodesDialogOpen] = useState(false);
const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false);
+1 -1
View File
@@ -33,7 +33,7 @@ export const updateProfileSettingsAction = userAction.schema(UpdateProfileSchema
await db
.update(user)
.set({
...(parsedInput.name ? { name: parsedInput.name } : {}),
...(parsedInput.name ? { name: parsedInput.name.trim() } : {}),
})
.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;
};
+39 -39
View File
@@ -1,60 +1,60 @@
import type { SystemRole } from "@/lib/acl/role";
import type {SystemRole} from "@/lib/acl/role";
import {User} from "@/db/schema/02_user";
export type SystemPermissions = {
role: SystemRole | null;
role: SystemRole | null;
isSuperAdmin: boolean;
isAdmin: boolean;
isUser: boolean;
isSuperAdmin: boolean;
isAdmin: boolean;
isUser: boolean;
canAccessSystem: boolean;
canAccessSystem: boolean;
canCreateUser: boolean;
canUpdateUser: boolean;
canDeleteUser: boolean;
canCreateUser: boolean;
canUpdateUser: boolean;
canDeleteUser: boolean;
canAssignSuperAdmin: boolean;
canAssignAdmin: boolean;
canAssignUser: boolean;
canAssignSuperAdmin: boolean;
canAssignAdmin: boolean;
canAssignUser: boolean;
canCreateOrganization: boolean;
canDeleteOrganization: boolean;
canUpdateOrganization: boolean;
canCreateOrganization: boolean;
canDeleteOrganization: boolean;
canUpdateOrganization: boolean;
canManageOrganizationUsers: boolean;
canManageOrganizationUsers: boolean;
};
export const computeSystemPermissions = (
user: User | null,
user: User | null,
): SystemPermissions => {
const role = (user?.role as SystemRole) ?? null;
const isSuperAdmin = role === "superadmin";
const isAdmin = role === "admin";
const isUser = role === "user";
const role = (user?.role as SystemRole) ?? null;
return {
role,
const isSuperAdmin = role === "superadmin";
const isAdmin = role === "admin";
const isUser = role === "user";
isSuperAdmin,
isAdmin,
isUser,
return {
role,
canAccessSystem: isSuperAdmin,
isSuperAdmin,
isAdmin,
isUser,
canCreateUser: isSuperAdmin || isAdmin,
canUpdateUser: isSuperAdmin || isAdmin,
canDeleteUser: isSuperAdmin || isAdmin,
canAccessSystem: isSuperAdmin,
canAssignSuperAdmin: isSuperAdmin,
canAssignAdmin: isSuperAdmin || isAdmin,
canAssignUser: isSuperAdmin || isAdmin,
canCreateUser: isSuperAdmin || isAdmin,
canUpdateUser: isSuperAdmin || isAdmin,
canDeleteUser: isSuperAdmin || isAdmin,
canCreateOrganization: isSuperAdmin,
canDeleteOrganization: isSuperAdmin,
canUpdateOrganization: isSuperAdmin || isAdmin,
canAssignSuperAdmin: isSuperAdmin,
canAssignAdmin: isSuperAdmin || isAdmin,
canAssignUser: isSuperAdmin || isAdmin,
canManageOrganizationUsers: isSuperAdmin || isAdmin,
};
canCreateOrganization: isSuperAdmin,
canDeleteOrganization: isSuperAdmin,
canUpdateOrganization: isSuperAdmin || isAdmin,
canManageOrganizationUsers: isSuperAdmin || isAdmin,
};
};