feat: add permissions page to settings
This commit is contained in:
@@ -14,8 +14,10 @@ import {
|
|||||||
HiOutlineBanknotes,
|
HiOutlineBanknotes,
|
||||||
HiOutlineCodeBracketSquare,
|
HiOutlineCodeBracketSquare,
|
||||||
HiOutlineRectangleGroup,
|
HiOutlineRectangleGroup,
|
||||||
|
HiOutlineShieldCheck,
|
||||||
HiOutlineUser,
|
HiOutlineUser,
|
||||||
} from "react-icons/hi2";
|
} from "react-icons/hi2";
|
||||||
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
interface SettingsLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -24,8 +26,11 @@ interface SettingsLayoutProps {
|
|||||||
|
|
||||||
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||||
|
|
||||||
|
const isAdmin = workspace.role === "admin";
|
||||||
|
|
||||||
const settingsTabs = [
|
const settingsTabs = [
|
||||||
{
|
{
|
||||||
key: "account",
|
key: "account",
|
||||||
@@ -39,11 +44,17 @@ export function SettingsLayout({ children, currentTab }: SettingsLayoutProps) {
|
|||||||
label: t`Workspace`,
|
label: t`Workspace`,
|
||||||
condition: true,
|
condition: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "permissions",
|
||||||
|
icon: <HiOutlineShieldCheck />,
|
||||||
|
label: t`Permissions`,
|
||||||
|
condition: isAdmin,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "billing",
|
key: "billing",
|
||||||
label: t`Billing`,
|
label: t`Billing`,
|
||||||
icon: <HiOutlineBanknotes />,
|
icon: <HiOutlineBanknotes />,
|
||||||
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud",
|
condition: env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isAdmin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "api",
|
key: "api",
|
||||||
|
|||||||
18
apps/web/src/pages/settings/permissions.tsx
Normal file
18
apps/web/src/pages/settings/permissions.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { NextPageWithLayout } from "~/pages/_app";
|
||||||
|
import { getDashboardLayout } from "~/components/Dashboard";
|
||||||
|
import { SettingsLayout } from "~/components/SettingsLayout";
|
||||||
|
import PermissionsSettings from "~/views/settings/PermissionsSettings";
|
||||||
|
|
||||||
|
const PermissionsSettingsPage: NextPageWithLayout = () => {
|
||||||
|
return (
|
||||||
|
<SettingsLayout currentTab="permissions">
|
||||||
|
<PermissionsSettings />
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionsSettingsPage.getLayout = (page) => getDashboardLayout(page);
|
||||||
|
|
||||||
|
export default PermissionsSettingsPage;
|
||||||
|
|
||||||
|
|
||||||
36
apps/web/src/views/settings/PermissionsSettings.tsx
Normal file
36
apps/web/src/views/settings/PermissionsSettings.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
|
import { PageHead } from "~/components/PageHead";
|
||||||
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
import { RolePermissions } from "./components/RolePermissions";
|
||||||
|
|
||||||
|
export default function PermissionsSettings() {
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
|
|
||||||
|
const isAdmin = workspace.role === "admin";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHead title={t`Settings | Permissions`} />
|
||||||
|
|
||||||
|
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||||
|
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||||
|
{t`Workspace permissions`}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-6 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
|
{t`Configure which actions are allowed for each workspace role. These permissions apply to all members with that role.`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isAdmin ? (
|
||||||
|
<RolePermissions />
|
||||||
|
) : (
|
||||||
|
<p className="mt-4 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
|
{t`You need to be an admin to manage workspace permissions.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +85,9 @@ export default function WorkspaceSettings() {
|
|||||||
</h2>
|
</h2>
|
||||||
<UpdateWorkspaceEmailVisibilityForm
|
<UpdateWorkspaceEmailVisibilityForm
|
||||||
workspacePublicId={workspace.publicId}
|
workspacePublicId={workspace.publicId}
|
||||||
showEmailsToMembers={workspaceData?.showEmailsToMembers ?? false}
|
showEmailsToMembers={Boolean(
|
||||||
|
workspaceData?.showEmailsToMembers ?? false,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||||
|
|||||||
196
apps/web/src/views/settings/components/RolePermissions.tsx
Normal file
196
apps/web/src/views/settings/components/RolePermissions.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
|
import { permissionCategories, roles } from "@kan/shared";
|
||||||
|
import type { Permission, Role } from "@kan/shared";
|
||||||
|
|
||||||
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
function formatRoleLabel(role: Role) {
|
||||||
|
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionLabels: Record<Permission, string> = {
|
||||||
|
"workspace:view": t`Can view workspace`,
|
||||||
|
"workspace:edit": t`Can edit workspace`,
|
||||||
|
"workspace:delete": t`Can delete workspace`,
|
||||||
|
"workspace:manage": t`Can manage workspace settings and billing`,
|
||||||
|
|
||||||
|
"board:view": t`Can view boards`,
|
||||||
|
"board:create": t`Can create boards`,
|
||||||
|
"board:edit": t`Can edit boards`,
|
||||||
|
"board:delete": t`Can delete boards`,
|
||||||
|
|
||||||
|
"list:view": t`Can view lists`,
|
||||||
|
"list:create": t`Can create lists`,
|
||||||
|
"list:edit": t`Can edit lists`,
|
||||||
|
"list:delete": t`Can delete lists`,
|
||||||
|
|
||||||
|
"card:view": t`Can view cards`,
|
||||||
|
"card:create": t`Can create cards`,
|
||||||
|
"card:edit": t`Can edit cards`,
|
||||||
|
"card:delete": t`Can delete cards`,
|
||||||
|
|
||||||
|
"comment:view": t`Can view comments`,
|
||||||
|
"comment:create": t`Can add comments`,
|
||||||
|
"comment:edit": t`Can edit comments`,
|
||||||
|
"comment:delete": t`Can delete comments`,
|
||||||
|
|
||||||
|
"member:view": t`Can view members`,
|
||||||
|
"member:invite": t`Can invite members`,
|
||||||
|
"member:edit": t`Can edit member roles and permissions`,
|
||||||
|
"member:remove": t`Can remove members`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RolePermissions() {
|
||||||
|
const { workspace } = useWorkspace();
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { data, isLoading } =
|
||||||
|
api.permission.getWorkspaceRolePermissions.useQuery(
|
||||||
|
{ workspacePublicId: workspace.publicId },
|
||||||
|
{ enabled: !!workspace.publicId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const systemRoles = (data?.roles ?? []).filter((role) =>
|
||||||
|
(roles).includes(role.name as Role),
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderedRoleNames: Role[] = ["admin", "member", "guest"].filter(
|
||||||
|
(role) => systemRoles.some((r) => r.name === role),
|
||||||
|
) as Role[];
|
||||||
|
|
||||||
|
const grantMutation = api.permission.grantRolePermission.useMutation({
|
||||||
|
onSettled: async () => {
|
||||||
|
if (!workspace.publicId) return;
|
||||||
|
await utils.permission.getWorkspaceRolePermissions.invalidate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const revokeMutation = api.permission.revokeRolePermission.useMutation({
|
||||||
|
onSettled: async () => {
|
||||||
|
if (!workspace.publicId) return;
|
||||||
|
await utils.permission.getWorkspaceRolePermissions.invalidate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBusy = grantMutation.isPending || revokeMutation.isPending;
|
||||||
|
|
||||||
|
const handleToggle = (
|
||||||
|
rolePublicId: string,
|
||||||
|
permission: Permission,
|
||||||
|
checked: boolean,
|
||||||
|
) => {
|
||||||
|
if (!workspace.publicId || !rolePublicId) return;
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
grantMutation.mutate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
rolePublicId,
|
||||||
|
permission,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
revokeMutation.mutate({
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
rolePublicId,
|
||||||
|
permission,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
{orderedRoleNames.length === 0 && !isLoading ? (
|
||||||
|
<p className="mb-4 text-sm text-neutral-500 dark:text-dark-800">
|
||||||
|
{t`No roles found for this workspace yet.`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-md border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-100">
|
||||||
|
<table className="min-w-full table-fixed divide-y divide-light-600 overflow-visible text-left text-sm dark:divide-dark-600">
|
||||||
|
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-300">
|
||||||
|
<tr>
|
||||||
|
<th className="w-1/2 rounded-tl-lg px-4 py-3 text-left text-xs font-semibold tracking-wide text-light-900 dark:text-dark-900">
|
||||||
|
{t`Permission`}
|
||||||
|
</th>
|
||||||
|
{orderedRoleNames.map((role) => (
|
||||||
|
<th
|
||||||
|
key={role}
|
||||||
|
className="w-1/6 px-4 py-3 text-center text-xs font-semibold tracking-wide text-light-900 dark:text-dark-900"
|
||||||
|
>
|
||||||
|
{formatRoleLabel(role)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{Object.values(permissionCategories).map((category) => (
|
||||||
|
<tbody
|
||||||
|
key={category.label}
|
||||||
|
className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100"
|
||||||
|
>
|
||||||
|
<tr className="bg-light-100 dark:bg-dark-200">
|
||||||
|
<td
|
||||||
|
colSpan={1 + orderedRoleNames.length}
|
||||||
|
className="px-4 py-2 text-xs font-semibold tracking-wide text-light-900 dark:text-dark-900"
|
||||||
|
>
|
||||||
|
{category.label}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{category.permissions.map((permission) => (
|
||||||
|
<tr key={permission}>
|
||||||
|
<td className="w-1/2 px-4 py-2 text-sm text-light-900 dark:text-dark-900">
|
||||||
|
{permissionLabels[permission] ?? permission}
|
||||||
|
</td>
|
||||||
|
{orderedRoleNames.map((roleName) => {
|
||||||
|
const role = systemRoles.find((r) => r.name === roleName);
|
||||||
|
const checked = role?.permissions.includes(permission);
|
||||||
|
const isAdminRole = roleName === "admin";
|
||||||
|
const isBillingOrDeletePermission =
|
||||||
|
permission === "workspace:manage" ||
|
||||||
|
permission === "workspace:delete";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={roleName}
|
||||||
|
className="w-1/6 px-4 py-2 text-center align-middle"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500 disabled:opacity-60"
|
||||||
|
disabled={
|
||||||
|
isAdminRole ||
|
||||||
|
isBillingOrDeletePermission ||
|
||||||
|
!role ||
|
||||||
|
isLoading ||
|
||||||
|
isBusy
|
||||||
|
}
|
||||||
|
checked={!!checked}
|
||||||
|
onChange={(e) =>
|
||||||
|
!isAdminRole &&
|
||||||
|
!isBillingOrDeletePermission &&
|
||||||
|
role &&
|
||||||
|
handleToggle(
|
||||||
|
role.publicId,
|
||||||
|
permission,
|
||||||
|
e.target.checked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
))}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,9 +5,7 @@ import * as memberRepo from "@kan/db/repository/member.repo";
|
|||||||
import * as permissionRepo from "@kan/db/repository/permission.repo";
|
import * as permissionRepo from "@kan/db/repository/permission.repo";
|
||||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||||
import type { Permission } from "@kan/shared";
|
import type { Permission } from "@kan/shared";
|
||||||
import {
|
import { allPermissions } from "@kan/shared";
|
||||||
allPermissions,
|
|
||||||
} from "@kan/shared";
|
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
import {
|
import {
|
||||||
@@ -291,6 +289,378 @@ export const permissionRouter = createTRPCRouter({
|
|||||||
input.permission as Permission,
|
input.permission as Permission,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
getWorkspaceRoles: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Get workspace roles",
|
||||||
|
method: "GET",
|
||||||
|
path: "/workspaces/{workspacePublicId}/roles",
|
||||||
|
description: "Get all roles for a workspace",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
roles: z.array(
|
||||||
|
z.object({
|
||||||
|
publicId: z.string().min(12),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
hierarchyLevel: z.number(),
|
||||||
|
isSystem: z.boolean(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||||
|
|
||||||
|
const roles = await permissionRepo.getRolesByWorkspaceId(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles: roles.map((role) => ({
|
||||||
|
publicId: role.publicId,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? null,
|
||||||
|
hierarchyLevel: role.hierarchyLevel,
|
||||||
|
isSystem: role.isSystem,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
getWorkspaceRolePermissions: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Get workspace role permissions",
|
||||||
|
method: "GET",
|
||||||
|
path: "/workspaces/{workspacePublicId}/roles/permissions",
|
||||||
|
description:
|
||||||
|
"Get all roles for a workspace with their granted permissions",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
roles: z.array(
|
||||||
|
z.object({
|
||||||
|
publicId: z.string().min(12),
|
||||||
|
name: z.string(),
|
||||||
|
permissions: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||||
|
|
||||||
|
const roles = await permissionRepo.getRolesByWorkspaceId(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rolesWithPermissions = await Promise.all(
|
||||||
|
roles.map(async (role) => {
|
||||||
|
const permissionsForRole = await permissionRepo.getPermissionsByRoleId(
|
||||||
|
ctx.db,
|
||||||
|
role.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicId: role.publicId,
|
||||||
|
name: role.name,
|
||||||
|
permissions: permissionsForRole,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles: rolesWithPermissions,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
getRolePermissions: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Get role permissions",
|
||||||
|
method: "GET",
|
||||||
|
path: "/workspaces/{workspacePublicId}/roles/{rolePublicId}/permissions",
|
||||||
|
description: "Get permissions granted to a specific role in a workspace",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
rolePublicId: z.string().min(12),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
rolePublicId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
permissions: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:view");
|
||||||
|
|
||||||
|
const role = await permissionRepo.getRoleByWorkspaceIdAndPublicId(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
input.rolePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Role not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionsForRole = await permissionRepo.getPermissionsByRoleId(
|
||||||
|
ctx.db,
|
||||||
|
role.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rolePublicId: role.publicId,
|
||||||
|
name: role.name,
|
||||||
|
permissions: permissionsForRole,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
grantRolePermission: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Grant permission to role",
|
||||||
|
method: "POST",
|
||||||
|
path: "/workspaces/{workspacePublicId}/roles/{rolePublicId}/permissions/grant",
|
||||||
|
description: "Grant a specific permission to a role",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
rolePublicId: z.string().min(12),
|
||||||
|
permission: z.enum(permissionsList),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require ability to edit members/roles
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||||
|
|
||||||
|
const role = await permissionRepo.getRoleByWorkspaceIdAndPublicId(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
input.rolePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Role not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.name === "admin" && role.isSystem) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Admin role permissions cannot be modified",
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never allow non-admin roles to manage billing or delete workspace
|
||||||
|
if (
|
||||||
|
(input.permission === "workspace:manage" ||
|
||||||
|
input.permission === "workspace:delete") &&
|
||||||
|
role.name !== "admin"
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message:
|
||||||
|
"Only the admin role can manage billing or delete the workspace",
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await permissionRepo.grantRolePermission(
|
||||||
|
ctx.db,
|
||||||
|
role.id,
|
||||||
|
input.permission as Permission,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
|
revokeRolePermission: protectedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
summary: "Revoke permission from role",
|
||||||
|
method: "POST",
|
||||||
|
path: "/workspaces/{workspacePublicId}/roles/{rolePublicId}/permissions/revoke",
|
||||||
|
description: "Revoke a specific permission from a role",
|
||||||
|
tags: ["Permissions"],
|
||||||
|
protect: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
workspacePublicId: z.string().min(12),
|
||||||
|
rolePublicId: z.string().min(12),
|
||||||
|
permission: z.enum(permissionsList),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "User not authenticated",
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await workspaceRepo.getByPublicId(
|
||||||
|
ctx.db,
|
||||||
|
input.workspacePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Workspace not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertPermission(ctx.db, userId, workspace.id, "member:edit");
|
||||||
|
|
||||||
|
const role = await permissionRepo.getRoleByWorkspaceIdAndPublicId(
|
||||||
|
ctx.db,
|
||||||
|
workspace.id,
|
||||||
|
input.rolePublicId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Role not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.name === "admin" && role.isSystem) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: "Admin role permissions cannot be modified",
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await permissionRepo.revokeRolePermission(
|
||||||
|
ctx.db,
|
||||||
|
role.id,
|
||||||
|
input.permission as Permission,
|
||||||
|
);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,6 +52,28 @@ export const getRoleByWorkspaceIdAndName = async (
|
|||||||
return role;
|
return role;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get role by workspace ID and publicId
|
||||||
|
*/
|
||||||
|
export const getRoleByWorkspaceIdAndPublicId = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceId: number,
|
||||||
|
rolePublicId: string,
|
||||||
|
) => {
|
||||||
|
const [role] = await db
|
||||||
|
.select()
|
||||||
|
.from(workspaceRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workspaceRoles.workspaceId, workspaceId),
|
||||||
|
eq(workspaceRoles.publicId, rolePublicId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return role;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all custom permission overrides for a workspace member
|
* Get all custom permission overrides for a workspace member
|
||||||
*/
|
*/
|
||||||
@@ -352,3 +374,83 @@ export const createRole = async (
|
|||||||
|
|
||||||
return role;
|
return role;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant a permission to a role
|
||||||
|
*/
|
||||||
|
export const grantRolePermission = async (
|
||||||
|
db: dbClient,
|
||||||
|
roleId: number,
|
||||||
|
permission: Permission,
|
||||||
|
) => {
|
||||||
|
const [result] = await db
|
||||||
|
.insert(workspaceRolePermissions)
|
||||||
|
.values({
|
||||||
|
workspaceRoleId: roleId,
|
||||||
|
permission,
|
||||||
|
granted: true,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
workspaceRolePermissions.workspaceRoleId,
|
||||||
|
workspaceRolePermissions.permission,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
granted: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a permission from a role
|
||||||
|
*/
|
||||||
|
export const revokeRolePermission = async (
|
||||||
|
db: dbClient,
|
||||||
|
roleId: number,
|
||||||
|
permission: Permission,
|
||||||
|
) => {
|
||||||
|
const [result] = await db
|
||||||
|
.insert(workspaceRolePermissions)
|
||||||
|
.values({
|
||||||
|
workspaceRoleId: roleId,
|
||||||
|
permission,
|
||||||
|
granted: false,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
workspaceRolePermissions.workspaceRoleId,
|
||||||
|
workspaceRolePermissions.permission,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
granted: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all roles for a workspace
|
||||||
|
*/
|
||||||
|
export const getRolesByWorkspaceId = async (
|
||||||
|
db: dbClient,
|
||||||
|
workspaceId: number,
|
||||||
|
) => {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
id: workspaceRoles.id,
|
||||||
|
publicId: workspaceRoles.publicId,
|
||||||
|
name: workspaceRoles.name,
|
||||||
|
description: workspaceRoles.description,
|
||||||
|
hierarchyLevel: workspaceRoles.hierarchyLevel,
|
||||||
|
isSystem: workspaceRoles.isSystem,
|
||||||
|
})
|
||||||
|
.from(workspaceRoles)
|
||||||
|
.where(eq(workspaceRoles.workspaceId, workspaceId));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user