From 6435b28e7f1362a62e2f7dc371f66d9e3b5c3db1 Mon Sep 17 00:00:00 2001 From: biersoeckli Date: Fri, 14 Mar 2025 10:45:36 +0000 Subject: [PATCH] feat: finished roles edit ui for new permissions management --- src/__tests__/shared/utils/role.utils.test.ts | 193 ++++----------- src/app/project/[projectId]/actions.ts | 5 +- src/app/project/[projectId]/apps-table.tsx | 28 ++- src/app/project/[projectId]/page.tsx | 5 +- src/app/projects/project-page.tsx | 2 +- src/app/settings/users/role-edit-overlay.tsx | 229 +++++++++++++----- src/app/sidebar.tsx | 2 +- src/server/services/app.service.ts | 2 + .../password-change.service.ts | 10 +- src/shared/utils/role.utils.ts | 4 + 10 files changed, 256 insertions(+), 224 deletions(-) diff --git a/src/__tests__/shared/utils/role.utils.test.ts b/src/__tests__/shared/utils/role.utils.test.ts index 9e3fc2c..5fce09b 100644 --- a/src/__tests__/shared/utils/role.utils.test.ts +++ b/src/__tests__/shared/utils/role.utils.test.ts @@ -6,166 +6,67 @@ describe(RoleUtils.name, () => { let adminSession: UserSession; let regularSession: UserSession; + const projectId = "project-123"; + beforeEach(() => { adminSession = { - roleName: adminRoleName, - permissions: [], - canAccessBackups: false, - canCreateNewApps: false, + role: { + name: adminRoleName, + }, } as any; + // Regular user session without any project permissions by default regularSession = { - roleName: "user", - permissions: [], - canAccessBackups: false, - canCreateNewApps: false, + role: { + name: "User", + roleProjectPermissions: [], + }, } as any; }); - /* describe("isAdmin", () => { - it("should return true for admin session", () => { - expect(RoleUtils.isAdmin(adminSession)).toBe(true); - }); - - it("should return false for non-admin session", () => { - expect(RoleUtils.isAdmin(regularSession)).toBe(false); - }); + test("should return true if user is admin", () => { + const result = RoleUtils.sessionHasReadAccessToProject(adminSession, projectId); + expect(result).toBe(true); }); - describe("getRolePermissionForApp", () => { - it("should return READWRITE for admin regardless of permission", () => { - expect(RoleUtils.getRolePermissionForApp(adminSession, "app1")).toBe( - RolePermissionEnum.READWRITE - ); - }); - - it("should return null for non-admin without permission", () => { - expect(RoleUtils.getRolePermissionForApp(regularSession, "app1")).toBe(null); - }); - - it("should return the specific permission for non-admin with permission", () => { - regularSession.permissions = [{ appId: "app1", permission: RolePermissionEnum.READ }]; - expect(RoleUtils.getRolePermissionForApp(regularSession, "app1")).toBe( - RolePermissionEnum.READ - ); - }); + test("should return false if non-admin user has no project permission", () => { + const result = RoleUtils.sessionHasReadAccessToProject(regularSession, projectId); + expect(result).toBe(false); }); - describe("sessionHasAccessToBackups", () => { - it("should return true for admin session", () => { - expect(RoleUtils.sessionHasAccessToBackups(adminSession)).toBe(true); - }); - - it("should return true for non-admin with backups access", () => { - regularSession.canAccessBackups = true; - expect(RoleUtils.sessionHasAccessToBackups(regularSession)).toBe(true); - }); - - it("should return false for non-admin without backups access", () => { - expect(RoleUtils.sessionHasAccessToBackups(regularSession)).toBe(false); - }); + test("should return true if non-admin user has project permission with non-empty roleAppPermissions", () => { + regularSession.role!.roleProjectPermissions = [ + { + projectId, + roleAppPermissions: [{ appId: "app1", permission: RolePermissionEnum.READ }], + readApps: false, + }, + ] as any; + const result = RoleUtils.sessionHasReadAccessToProject(regularSession, projectId); + expect(result).toBe(true); }); - describe("sessionCanCreateNewApps", () => { - it("should return true for admin session", () => { - expect(RoleUtils.sessionCanCreateNewAppsForProject(adminSession)).toBe(true); - }); - - it("should return true for non-admin with ability to create new apps", () => { - regularSession.canCreateNewApps = true; - expect(RoleUtils.sessionCanCreateNewAppsForProject(regularSession)).toBe(true); - }); - - it("should return false for non-admin without ability to create new apps", () => { - expect(RoleUtils.sessionCanCreateNewAppsForProject(regularSession)).toBe(false); - }); + test("should return true if non-admin user has project permission with empty roleAppPermissions and readApps true", () => { + regularSession.role!.roleProjectPermissions = [ + { + projectId, + roleAppPermissions: [], + readApps: true, + }, + ] as any; + const result = RoleUtils.sessionHasReadAccessToProject(regularSession, projectId); + expect(result).toBe(true); }); - describe("sessionIsReadOnlyForApp", () => { - it("should return false for admin session", () => { - expect(RoleUtils.sessionIsReadOnlyForApp(adminSession, "app1")).toBe(false); - }); - - it("should return true for non-admin with READ permission only", () => { - regularSession.permissions = [{ appId: "app1", permission: RolePermissionEnum.READ }]; - expect(RoleUtils.sessionIsReadOnlyForApp(regularSession, "app1")).toBe(true); - }); - - it("should return false for non-admin with READWRITE permission", () => { - regularSession.permissions = [{ appId: "app1", permission: RolePermissionEnum.READWRITE }]; - expect(RoleUtils.sessionIsReadOnlyForApp(regularSession, "app1")).toBe(false); - }); - - it("should return false when no permission is assigned", () => { - regularSession.permissions = []; - expect(RoleUtils.sessionIsReadOnlyForApp(regularSession, "app1")).toBe(false); - }); + test("should return false if non-admin user has project permission with empty roleAppPermissions and readApps false", () => { + regularSession.role!.roleProjectPermissions = [ + { + projectId, + roleAppPermissions: [], + readApps: false, + }, + ] as any; + const result = RoleUtils.sessionHasReadAccessToProject(regularSession, projectId); + expect(result).toBe(false); }); - - describe("sessionHasReadAccessForApp", () => { - it("should return true for admin session", () => { - expect(RoleUtils.sessionHasReadAccessForApp(adminSession, "app1")).toBe(true); - }); - - it("should return true for non-admin with READ permission", () => { - regularSession.permissions = [{ appId: "app1", permission: RolePermissionEnum.READ }]; - expect(RoleUtils.sessionHasReadAccessForApp(regularSession, "app1")).toBe(true); - }); - - it("should return true for non-admin with READWRITE permission", () => { - regularSession.permissions = [{ appId: "app1", permission: RolePermissionEnum.READWRITE }]; - expect(RoleUtils.sessionHasReadAccessForApp(regularSession, "app1")).toBe(true); - }); - - it("should return false when no permission is granted", () => { - regularSession.permissions = []; - expect(RoleUtils.sessionHasReadAccessForApp(regularSession, "app1")).toBe(false); - }); - }); - - describe("sessionHasWriteAccessForApp", () => { - it("should return true for admin session", () => { - expect(RoleUtils.sessionHasWriteAccessForApp(adminSession, "app1")).toBe(true); - }); - - it("should return true for non-admin with READWRITE permission", () => { - regularSession.permissions = [{ appId: "app1", permission: RolePermissionEnum.READWRITE }]; - expect(RoleUtils.sessionHasWriteAccessForApp(regularSession, "app1")).toBe(true); - }); - - it("should return false for non-admin with only READ permission", () => { - regularSession.permissions = [{ appId: "app1", permission: RolePermissionEnum.READ }]; - expect(RoleUtils.sessionHasWriteAccessForApp(regularSession, "app1")).toBe(false); - }); - - it("should return false when no permission is assigned", () => { - regularSession.permissions = []; - expect(RoleUtils.sessionHasWriteAccessForApp(regularSession, "app1")).toBe(false); - }); - }); - - describe("sessionHasReadAccessToProject", () => { - it("should return true if project has no apps and session can create new apps", () => { - regularSession.canCreateNewApps = true; - const project = { apps: [] }; - expect(RoleUtils.sessionHasReadAccessToProject(regularSession, project)).toBe(true); - }); - - it("should return true if project has no apps and session is admin", () => { - const project = { apps: [] }; - expect(RoleUtils.sessionHasReadAccessToProject(adminSession, project)).toBe(true); - }); - - it("should return true if at least one app grants read access", () => { - const project = { apps: [{ id: "app1" }, { id: "app2" }] }; - regularSession.permissions = [{ appId: "app2", permission: RolePermissionEnum.READ }]; - expect(RoleUtils.sessionHasReadAccessToProject(regularSession, project)).toBe(true); - }); - - it("should return false if no app grants read access", () => { - const project = { apps: [{ id: "app1" }] }; - regularSession.permissions = []; - expect(RoleUtils.sessionHasReadAccessToProject(regularSession, project)).toBe(false); - }); - });*/ -}); \ No newline at end of file +}); diff --git a/src/app/project/[projectId]/actions.ts b/src/app/project/[projectId]/actions.ts index 19a9b8b..862c932 100644 --- a/src/app/project/[projectId]/actions.ts +++ b/src/app/project/[projectId]/actions.ts @@ -48,8 +48,11 @@ export const createAppFromTemplate = async (prevState: any, inputData: AppTempla export const deleteApp = async (appId: string) => simpleAction(async () => { - await isAuthorizedWriteForApp(appId); + const session = await getAuthUserSession(); const app = await appService.getExtendedById(appId); + if (!RoleUtils.sessionCanDeleteAppsForProject(session, app.projectId)) { + throw new ServiceException("You are not allowed to delete apps in this project."); + } // First delete external services wich might be running await dbGateService.deleteToolForAppIfExists(appId); await phpMyAdminService.deleteToolForAppIfExists(appId); diff --git a/src/app/project/[projectId]/apps-table.tsx b/src/app/project/[projectId]/apps-table.tsx index 1841385..df1305f 100644 --- a/src/app/project/[projectId]/apps-table.tsx +++ b/src/app/project/[projectId]/apps-table.tsx @@ -13,10 +13,19 @@ import { deleteApp } from "./actions"; import { useBreadcrumbs, useConfirmDialog } from "@/frontend/states/zustand.states"; import { useEffect } from "react"; import { EditAppDialog } from "./edit-app-dialog"; +import { UserSession } from "@/shared/model/sim-session.model"; +import { RoleUtils } from "@/shared/utils/role.utils"; - -export default function AppTable({ app, projectId }: { app: App[], projectId: string }) { +export default function AppTable({ + app, + projectId, + session +}: { + app: App[], + projectId: string, + session: UserSession +}) { const { openConfirmDialog: openDialog } = useConfirmDialog(); @@ -54,18 +63,19 @@ export default function AppTable({ app, projectId }: { app: App[], projectId: st - - - Edit App Name - - - + + Edit App Name + + } + {RoleUtils.sessionCanDeleteAppsForProject(session, projectId) && openDialog({ title: "Delete App", description: "Are you sure you want to delete this app? All data will be lost and this action cannot be undone.", }).then((result) => result ? Toast.fromAction(() => deleteApp(item.id)) : undefined)}> Delete App - + } diff --git a/src/app/project/[projectId]/page.tsx b/src/app/project/[projectId]/page.tsx index fa8ca91..90b8ed3 100644 --- a/src/app/project/[projectId]/page.tsx +++ b/src/app/project/[projectId]/page.tsx @@ -33,9 +33,10 @@ export default async function AppsPage({ - + {RoleUtils.sessionCanCreateNewAppsForProject(session, params.projectId) && + } - + ) diff --git a/src/app/projects/project-page.tsx b/src/app/projects/project-page.tsx index e0a3436..0fd544f 100644 --- a/src/app/projects/project-page.tsx +++ b/src/app/projects/project-page.tsx @@ -25,7 +25,7 @@ export default async function ProjectPage() { const session = await getAuthUserSession(); const data = await projectService.getAllProjects(); const relevantProjectsForUser = data.filter((project) => - RoleUtils.sessionHasReadAccessToProject(session, project)); + RoleUtils.sessionHasReadAccessToProject(session, project.id)); return (
diff --git a/src/app/settings/users/role-edit-overlay.tsx b/src/app/settings/users/role-edit-overlay.tsx index 3dcc942..ea52009 100644 --- a/src/app/settings/users/role-edit-overlay.tsx +++ b/src/app/settings/users/role-edit-overlay.tsx @@ -35,9 +35,11 @@ type UiProjectPermission = { deleteApps: boolean; writeApps: boolean; readApps: boolean; + setPermissionsPerApp: boolean; roleAppPermissions: { appId: string; - permission: RolePermissionEnum; + appName: string; + permission?: RolePermissionEnum; }[]; }; @@ -61,19 +63,25 @@ export default function RoleEditOverlay({ children, role, projects }: { saveRole(state, { ...payload, id: role?.id, - /* roleProjectPermissions: projects.map((project) => ({ - projectId: project.id, - createApps: appPermissions.some((perm) => perm.appId === project.id && perm.readwrite), - deleteApps: appPermissions.some((perm) => perm.appId === project.id && perm.readwrite), - writeApps: appPermissions.some((perm) => perm.appId === project.id && perm.readwrite), - readApps: appPermissions.some((perm) => perm.appId === project.id && (perm.read || perm.readwrite)), - roleAppPermissions: appPermissions - .filter((perm) => perm.appId === project.id) - .map((perm) => ({ - appId: perm.appId, - permission: perm.readwrite ? RolePermissionEnum.READWRITE : (perm.read ? RolePermissionEnum.READ : '') - })) - }))*/ + roleProjectPermissions: projects.map((project) => { + const projectPermission = projectPermissions.find((perm) => perm.projectId === project.id); + if (!projectPermission) { + return undefined; + } + return { + projectId: project.id, + createApps: projectPermission.createApps, + deleteApps: projectPermission.deleteApps, + writeApps: projectPermission.writeApps, + readApps: projectPermission.readApps, + roleAppPermissions: projectPermission.roleAppPermissions.filter(ap => !!ap.permission).map((appPerm) => { + return { + appId: appPerm.appId, + permission: appPerm.permission!, + }; + }), + } + }).filter((perm) => perm !== undefined), }), FormUtils.getInitialFormState()); useEffect(() => { @@ -91,13 +99,20 @@ export default function RoleEditOverlay({ children, role, projects }: { // Initialize app permissions based on role data const initialPermissions = projects.map(project => { const existingPermission = role.roleProjectPermissions?.find(p => p.projectId === project.id); + const roleAppPermissions = project.apps.map(app => ({ + appId: app.id, + appName: app.name, + permission: existingPermission?.roleAppPermissions.find(appPerm => appPerm.appId === app.id)?.permission + })); + const hasNoAppRolePermissionsSet = roleAppPermissions.every(appPerm => !appPerm.permission); return { projectId: project.id, createApps: existingPermission?.createApps || false, deleteApps: existingPermission?.deleteApps || false, writeApps: existingPermission?.writeApps || false, readApps: existingPermission?.readApps || false, - roleAppPermissions: existingPermission?.roleAppPermissions || [] + setPermissionsPerApp: (existingPermission?.roleAppPermissions.length ?? 0) > 0 || false, + roleAppPermissions: hasNoAppRolePermissionsSet ? [] : roleAppPermissions } as UiProjectPermission; }); setProjectPermissions(initialPermissions); @@ -109,22 +124,17 @@ export default function RoleEditOverlay({ children, role, projects }: { deleteApps: false, writeApps: false, readApps: false, + setPermissionsPerApp: false, roleAppPermissions: [] } as UiProjectPermission)); setProjectPermissions(initialPermissions); - } - }, [role, projects]); + }, [role, projects, isOpen]); const handleReadChange = (projectId: string, checked: boolean) => { setProjectPermissions(prev => prev.map(perm => { if (perm.projectId === projectId) { - // If read is being turned off, also turn off readwrite - if (!checked) { - return { ...perm, readApps: false, readwrite: false }; - } - // If read is being turned on, just update read return { ...perm, readApps: checked }; } return perm; @@ -134,12 +144,7 @@ export default function RoleEditOverlay({ children, role, projects }: { const handleReadWriteChange = (projectId: string, checked: boolean) => { setProjectPermissions(prev => prev.map(perm => { if (perm.projectId === projectId) { - // If readwrite is being turned on, turn off read - if (checked) { - return { ...perm, readApps: true, writeApps: true } as UiProjectPermission; - } - // If readwrite is being turned off, just update read - return { ...perm, readApps: perm.readApps, writeApps: checked } as UiProjectPermission; + return { ...perm, writeApps: checked, readApps: checked ? true : perm.writeApps }; } return perm; })); @@ -148,7 +153,7 @@ export default function RoleEditOverlay({ children, role, projects }: { const handleCreateChange = (projectId: string, checked: boolean) => { setProjectPermissions(prev => prev.map(perm => { if (perm.projectId === projectId) { - return { ...perm, createApps: checked }; + return { ...perm, createApps: checked, readApps: checked ? true : perm.createApps }; } return perm; })); @@ -157,19 +162,73 @@ export default function RoleEditOverlay({ children, role, projects }: { const handleDeleteChange = (projectId: string, checked: boolean) => { setProjectPermissions(prev => prev.map(perm => { if (perm.projectId === projectId) { - return { ...perm, deleteApps: checked }; + return { ...perm, deleteApps: checked, readApps: checked ? true : perm.deleteApps }; } return perm; })); }; + const handleSetPermissionsPerAppChange = (projectId: string, checked: boolean) => { + setProjectPermissions(prev => prev.map(perm => { + if (perm.projectId === projectId) { + const appPermissions = checked ? projects.find(p => p.id === projectId)?.apps.map(app => ({ + appId: app.id, + appName: app.name, + permission: undefined + })) || [] : []; + return { + ...perm, + setPermissionsPerApp: checked, + roleAppPermissions: appPermissions, + createApps: false, + deleteApps: false, + writeApps: false, + readApps: false + }; + } + return perm; + })); + }; + + const handleAppReadChange = (appId: string, checked: boolean) => + setProjectPermissions(prev => prev.map(perm => { + if (perm.roleAppPermissions.some(appPerm => appPerm.appId === appId)) { + return { + ...perm, + roleAppPermissions: perm.roleAppPermissions.map(appPerm => { + if (appPerm.appId === appId) { + return { ...appPerm, permission: checked ? RolePermissionEnum.READ : undefined }; + } + return appPerm; + }) + }; + } + return perm; + })); + + const handleAppReadWriteChange = (appId: string, checked: boolean) => + setProjectPermissions(prev => prev.map(perm => { + if (perm.roleAppPermissions.some(appPerm => appPerm.appId === appId)) { + return { + ...perm, + roleAppPermissions: perm.roleAppPermissions.map(appPerm => { + if (appPerm.appId === appId) { + return { ...appPerm, permission: checked ? RolePermissionEnum.READWRITE : undefined }; + } + return appPerm; + }) + }; + } + return perm; + })); + return ( <>
setIsOpen(true)}> {children}
setIsOpen(isOpened)}> - + {role?.id ? 'Edit' : 'Create'} Role @@ -223,6 +282,7 @@ export default function RoleEditOverlay({ children, role, projects }: { Project + Individual Permissions Read Apps Write Apps Create Apps @@ -233,37 +293,82 @@ export default function RoleEditOverlay({ children, role, projects }: { {projects.map((project) => { const permission = projectPermissions.find(p => p.projectId === project.id); return ( - - {project.name} - - handleReadChange(project.id, !!checked)} - /> - - - handleReadWriteChange(project.id, !!checked)} - /> - - - handleCreateChange(project.id, !!checked)} - /> - - - handleDeleteChange(project.id, !!checked)} - /> - - + <> + + {project.name} + + handleSetPermissionsPerAppChange(project.id, !!checked)} + /> + + {permission?.setPermissionsPerApp ? + App + : + handleReadChange(project.id, !!checked)} + /> + } + + {!permission?.setPermissionsPerApp && + handleReadWriteChange(project.id, !!checked)} + />} + + {permission?.setPermissionsPerApp ? + Read + : + handleCreateChange(project.id, !!checked)} + /> + } + {permission?.setPermissionsPerApp ? + Read & Write + : + handleDeleteChange(project.id, !!checked)} + /> + } + + + + {(permission?.roleAppPermissions.length ?? 0) > 0 && + <> + {permission?.roleAppPermissions.map((roleAppPermission, index) => + + + + + {roleAppPermission.appName} + + handleAppReadChange(roleAppPermission.appId, !!checked)} + /> + + + handleAppReadWriteChange(roleAppPermission.appId, !!checked)} + /> + + + + )} + } + ); })} diff --git a/src/app/sidebar.tsx b/src/app/sidebar.tsx index 828d9df..5837757 100644 --- a/src/app/sidebar.tsx +++ b/src/app/sidebar.tsx @@ -14,7 +14,7 @@ export async function AppSidebar() { const projects = await projectService.getAllProjects(); const relevantProjectsForUser = projects.filter((project) => - RoleUtils.sessionHasReadAccessToProject(session, project)); + RoleUtils.sessionHasReadAccessToProject(session, project.id)); for (const project of relevantProjectsForUser) { project.apps = project.apps.filter((app) => RoleUtils.sessionHasReadAccessForApp(session, app.id)); } diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts index 7ced079..d051f9e 100644 --- a/src/server/services/app.service.ts +++ b/src/server/services/app.service.ts @@ -159,6 +159,8 @@ class AppService { revalidateTag(Tags.apps(item.projectId as string)); revalidateTag(Tags.app(item.id as string)); revalidateTag(Tags.projects()); + revalidateTag(Tags.roles()); + revalidateTag(Tags.users()); } return savedItem; } diff --git a/src/server/services/standalone-services/password-change.service.ts b/src/server/services/standalone-services/password-change.service.ts index fb2103c..2f7b898 100644 --- a/src/server/services/standalone-services/password-change.service.ts +++ b/src/server/services/standalone-services/password-change.service.ts @@ -2,18 +2,24 @@ import dataAccess from "../../adapter/db.client"; import bcrypt from "bcrypt"; import { randomBytes } from "crypto"; import quickStackService from "../qs.service"; +import { adminRoleName } from "../../../shared/model/role-extended.model.ts"; class PasswordChangeService { async changeAdminPasswordAndPrintNewPassword() { const firstCreatedUser = await dataAccess.client.user.findFirst({ + where: { + role: { + name: adminRoleName + } + }, orderBy: { createdAt: 'asc' } }); if (!firstCreatedUser) { - console.error("No users found. QuickStack is not configured yet. Open your browser to setup quickstack"); + console.error("No admin users found. QuickStack is not configured yet. Open your browser to setup quickstack"); return; } @@ -36,7 +42,7 @@ class PasswordChangeService { console.log('******* Password change *******'); console.log('*******************************'); console.log(``); - console.log(`New password for user ${firstCreatedUser.email} is: ${generatedPassword}`); + console.log(`New password for admin user ${firstCreatedUser.email} is: ${generatedPassword}`); console.log(``); console.log('*******************************'); console.log('*******************************'); diff --git a/src/shared/utils/role.utils.ts b/src/shared/utils/role.utils.ts index 5bc944e..429845d 100644 --- a/src/shared/utils/role.utils.ts +++ b/src/shared/utils/role.utils.ts @@ -13,6 +13,10 @@ export class RoleUtils { return false; } + if (projectPermission.roleAppPermissions.length > 0) { + return true; + } + return projectPermission.readApps; }