mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
fix: demo acl
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user