feat: finished roles edit ui for new permissions management

This commit is contained in:
biersoeckli
2025-03-14 10:45:36 +00:00
parent 5be8971878
commit 6435b28e7f
10 changed files with 256 additions and 224 deletions

View File

@@ -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);
});
});*/
});
});

View File

@@ -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);

View File

@@ -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
</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<EditAppDialog projectId={projectId} existingItem={item}>
<DropdownMenuItem>
<Edit2 /> <span>Edit App Name</span>
</DropdownMenuItem>
</EditAppDialog>
<DropdownMenuItem className="text-red-500"
{RoleUtils.sessionCanCreateNewAppsForProject(session, projectId) &&
<EditAppDialog projectId={projectId} existingItem={item}>
<DropdownMenuItem>
<Edit2 /> <span>Edit App Name</span>
</DropdownMenuItem>
</EditAppDialog>}
{RoleUtils.sessionCanDeleteAppsForProject(session, projectId) && <DropdownMenuItem className="text-red-500"
onClick={() => 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)}>
<Trash /> <span >Delete App</span>
</DropdownMenuItem>
</DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -33,9 +33,10 @@ export default async function AppsPage({
<PageTitle
title="Apps"
subtitle={`All Apps for Project "${project.name}"`}>
<CreateProjectActions projectId={projectId} />
{RoleUtils.sessionCanCreateNewAppsForProject(session, params.projectId) &&
<CreateProjectActions projectId={projectId} />}
</PageTitle>
<AppTable app={relevantApps} projectId={project.id} />
<AppTable session={session} app={relevantApps} projectId={project.id} />
<ProjectBreadcrumbs project={project} />
</div>
)

View File

@@ -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 (
<div className="flex-1 space-y-4 pt-6">

View File

@@ -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<typeof roleEditZodModel>());
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 (
<>
<div onClick={() => setIsOpen(true)}>
{children}
</div>
<Dialog open={!!isOpen} onOpenChange={(isOpened) => setIsOpen(isOpened)}>
<DialogContent className="sm:max-w-[700px]">
<DialogContent className="sm:max-w-[900px]">
<DialogHeader>
<DialogTitle>{role?.id ? 'Edit' : 'Create'} Role</DialogTitle>
</DialogHeader>
@@ -223,6 +282,7 @@ export default function RoleEditOverlay({ children, role, projects }: {
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Individual Permissions</TableHead>
<TableHead>Read Apps</TableHead>
<TableHead>Write Apps</TableHead>
<TableHead>Create Apps</TableHead>
@@ -233,37 +293,82 @@ export default function RoleEditOverlay({ children, role, projects }: {
{projects.map((project) => {
const permission = projectPermissions.find(p => p.projectId === project.id);
return (
<TableRow key={project.id}>
<TableCell>{project.name}</TableCell>
<TableCell>
<Checkbox
id={`read-${project.id}`}
checked={permission?.readApps || false}
onCheckedChange={(checked) => handleReadChange(project.id, !!checked)}
/>
</TableCell>
<TableCell>
<Checkbox
id={`write-${project.id}`}
checked={permission?.writeApps || false}
onCheckedChange={(checked) => handleReadWriteChange(project.id, !!checked)}
/>
</TableCell>
<TableCell>
<Checkbox
id={`create-${project.id}`}
checked={permission?.createApps || false}
onCheckedChange={(checked) => handleCreateChange(project.id, !!checked)}
/>
</TableCell>
<TableCell>
<Checkbox
id={`delete-${project.id}`}
checked={permission?.deleteApps || false}
onCheckedChange={(checked) => handleDeleteChange(project.id, !!checked)}
/>
</TableCell>
</TableRow>
<>
<TableRow key={project.id} className={(permission?.roleAppPermissions.length ?? 0) === 0 ? 'border-b-gray-400' : ''} >
<TableCell className="font-semibold">{project.name}</TableCell>
<TableCell>
<Checkbox
id={`delete-${project.id}`}
checked={permission?.setPermissionsPerApp || false}
onCheckedChange={(checked) => handleSetPermissionsPerAppChange(project.id, !!checked)}
/>
</TableCell>
{permission?.setPermissionsPerApp ?
<TableHead>App</TableHead>
: <TableCell>
<Checkbox
id={`read-${project.id}`}
disabled={permission?.writeApps || permission?.deleteApps || permission?.createApps}
checked={permission?.readApps || false}
onCheckedChange={(checked) => handleReadChange(project.id, !!checked)}
/>
</TableCell>}
<TableCell>
{!permission?.setPermissionsPerApp &&
<Checkbox
id={`write-${project.id}`}
checked={permission?.writeApps || false}
onCheckedChange={(checked) => handleReadWriteChange(project.id, !!checked)}
/>}
</TableCell>
{permission?.setPermissionsPerApp ?
<TableHead>Read</TableHead>
: <TableCell>
<Checkbox
id={`create-${project.id}`}
checked={permission?.createApps || false}
onCheckedChange={(checked) => handleCreateChange(project.id, !!checked)}
/>
</TableCell>}
{permission?.setPermissionsPerApp ?
<TableHead>Read & Write</TableHead>
: <TableCell>
<Checkbox
id={`delete-${project.id}`}
checked={permission?.deleteApps || false}
onCheckedChange={(checked) => handleDeleteChange(project.id, !!checked)}
/>
</TableCell>}
</TableRow>
{(permission?.roleAppPermissions.length ?? 0) > 0 &&
<>
{permission?.roleAppPermissions.map((roleAppPermission, index) =>
<TableRow key={roleAppPermission.appId} className={permission.roleAppPermissions.length - 1 === index ? 'border-b-gray-400' : ''}>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell colSpan={2}>{roleAppPermission.appName}</TableCell>
<TableCell>
<Checkbox
id={`app-read-${roleAppPermission.appId}`}
checked={roleAppPermission.permission === RolePermissionEnum.READ}
onCheckedChange={(checked) => handleAppReadChange(roleAppPermission.appId, !!checked)}
/>
</TableCell>
<TableCell>
<Checkbox
id={`app-readwrite-${roleAppPermission.appId}`}
checked={roleAppPermission.permission === RolePermissionEnum.READWRITE}
onCheckedChange={(checked) => handleAppReadWriteChange(roleAppPermission.appId, !!checked)}
/>
</TableCell>
</TableRow>
)}
</>}
</>
);
})}
</TableBody>

View File

@@ -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));
}

View File

@@ -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;
}

View File

@@ -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('*******************************');

View File

@@ -13,6 +13,10 @@ export class RoleUtils {
return false;
}
if (projectPermission.roleAppPermissions.length > 0) {
return true;
}
return projectPermission.readApps;
}