feat: customisable workspace role permissions (#345)

* feat: setup schema for workspace roles

* chore: regen migration

* feat: add publicId to workspace roles

* feat: setup default permissions

* feat: add repo funcs

* feat: setup basic router interactions

* feat: add card permissions

* feat: assert permissions for lists

* feat: assert board permissions

* feat: assert permission for remaining routes

* feat: add permissions page to settings

* feat: enable updating member roles

* feat: order members by role and createdAt

* feat: allow editing individual permissions

* feat: reset role defaults

* feat: clear all permission overrides

* feat: allow users to delete entities they have created

* feat: set roleId when inviting new members

* feat: disable UI elements if user does not have permissions

* feat: allow admins to assign the admin role to other users

* feat: allow delete:list as default

* refactor: centre permissions modal

* chore: translations
This commit is contained in:
Henry
2026-02-01 21:17:18 +00:00
committed by GitHub
parent 5f2d409773
commit 7f5a1ab513
83 changed files with 10568 additions and 1442 deletions

View File

@@ -0,0 +1,172 @@
CREATE TABLE IF NOT EXISTS "workspace_member_permissions" (
"id" bigserial PRIMARY KEY NOT NULL,
"workspaceMemberId" bigint NOT NULL,
"permission" varchar(64) NOT NULL,
"granted" boolean DEFAULT true NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp
);
--> statement-breakpoint
ALTER TABLE "workspace_member_permissions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "workspace_role_permissions" (
"id" bigserial PRIMARY KEY NOT NULL,
"workspaceRoleId" bigint NOT NULL,
"permission" varchar(64) NOT NULL,
"granted" boolean DEFAULT true NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "workspace_role_permissions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "workspace_roles" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"workspaceId" bigint NOT NULL,
"name" varchar(64) NOT NULL,
"description" varchar(255),
"hierarchyLevel" integer NOT NULL,
"isSystem" boolean DEFAULT false NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
CONSTRAINT "workspace_roles_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
ALTER TABLE "workspace_roles" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "workspace_members" ADD COLUMN "roleId" bigint;--> statement-breakpoint
ALTER TABLE "workspace" ADD COLUMN "showEmailsToMembers" boolean DEFAULT true NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_role_permissions" ADD CONSTRAINT "workspace_role_permissions_workspaceRoleId_workspace_roles_id_fk" FOREIGN KEY ("workspaceRoleId") REFERENCES "public"."workspace_roles"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_roles" ADD CONSTRAINT "workspace_roles_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "unique_member_permission" ON "workspace_member_permissions" USING btree ("workspaceMemberId","permission");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "permission_member_idx" ON "workspace_member_permissions" USING btree ("workspaceMemberId");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "unique_role_permission" ON "workspace_role_permissions" USING btree ("workspaceRoleId","permission");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "role_permissions_role_idx" ON "workspace_role_permissions" USING btree ("workspaceRoleId");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "unique_role_per_workspace" ON "workspace_roles" USING btree ("workspaceId","name");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "workspace_roles_workspace_idx" ON "workspace_roles" USING btree ("workspaceId");--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_roleId_workspace_roles_id_fk" FOREIGN KEY ("roleId") REFERENCES "public"."workspace_roles"("id") ON DELETE restrict ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
-- Helper function to generate 12-character public IDs
CREATE OR REPLACE FUNCTION generate_public_id() RETURNS varchar(12) AS $$
DECLARE
chars text := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
result varchar(12) := '';
i integer;
BEGIN
FOR i IN 1..12 LOOP
result := result || substr(chars, floor(random() * length(chars) + 1)::integer, 1);
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql;
--> statement-breakpoint
-- Seed system roles for each existing workspace
INSERT INTO "workspace_roles" ("publicId", "workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt")
SELECT generate_public_id(), w.id, 'admin', 'Full access to all workspace features', 100, true, NOW()
FROM "workspace" w
WHERE NOT EXISTS (
SELECT 1 FROM "workspace_roles" wr
WHERE wr."workspaceId" = w.id AND wr."name" = 'admin'
);
--> statement-breakpoint
INSERT INTO "workspace_roles" ("publicId", "workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt")
SELECT generate_public_id(), w.id, 'member', 'Standard member with create and edit permissions', 50, true, NOW()
FROM "workspace" w
WHERE NOT EXISTS (
SELECT 1 FROM "workspace_roles" wr
WHERE wr."workspaceId" = w.id AND wr."name" = 'member'
);
--> statement-breakpoint
INSERT INTO "workspace_roles" ("publicId", "workspaceId", "name", "description", "hierarchyLevel", "isSystem", "createdAt")
SELECT generate_public_id(), w.id, 'guest', 'View-only access', 10, true, NOW()
FROM "workspace" w
WHERE NOT EXISTS (
SELECT 1 FROM "workspace_roles" wr
WHERE wr."workspaceId" = w.id AND wr."name" = 'guest'
);
--> statement-breakpoint
-- Seed admin role permissions (all permissions)
INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt")
SELECT wr.id, p.permission, true, NOW()
FROM "workspace_roles" wr
CROSS JOIN (
VALUES
('workspace:view'), ('workspace:edit'), ('workspace:delete'), ('workspace:manage'),
('board:view'), ('board:create'), ('board:edit'), ('board:delete'),
('list:view'), ('list:create'), ('list:edit'), ('list:delete'),
('card:view'), ('card:create'), ('card:edit'), ('card:delete'),
('comment:view'), ('comment:create'), ('comment:edit'), ('comment:delete'),
('member:view'), ('member:invite'), ('member:edit'), ('member:remove')
) AS p(permission)
WHERE wr."name" = 'admin' AND wr."isSystem" = true
AND NOT EXISTS (
SELECT 1 FROM "workspace_role_permissions" wrp
WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission
);
--> statement-breakpoint
-- Seed member role permissions
INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt")
SELECT wr.id, p.permission, true, NOW()
FROM "workspace_roles" wr
CROSS JOIN (
VALUES
('workspace:view'),
('board:view'), ('board:create'),
('list:view'), ('list:create'), ('list:edit'), ('list:delete'),
('card:view'), ('card:create'), ('card:edit'), ('card:delete'),
('comment:view'), ('comment:create'), ('comment:edit'), ('comment:delete'),
('member:view')
) AS p(permission)
WHERE wr."name" = 'member' AND wr."isSystem" = true
AND NOT EXISTS (
SELECT 1 FROM "workspace_role_permissions" wrp
WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission
);
--> statement-breakpoint
-- Seed guest role permissions (view only)
INSERT INTO "workspace_role_permissions" ("workspaceRoleId", "permission", "granted", "createdAt")
SELECT wr.id, p.permission, true, NOW()
FROM "workspace_roles" wr
CROSS JOIN (
VALUES
('workspace:view'),
('board:view'),
('list:view'),
('card:view'),
('comment:view'),
('member:view')
) AS p(permission)
WHERE wr."name" = 'guest' AND wr."isSystem" = true
AND NOT EXISTS (
SELECT 1 FROM "workspace_role_permissions" wrp
WHERE wrp."workspaceRoleId" = wr.id AND wrp."permission" = p.permission
);
--> statement-breakpoint
-- Migrate existing workspace_members to use roleId
UPDATE "workspace_members" wm
SET "roleId" = wr.id
FROM "workspace_roles" wr
WHERE wm."workspaceId" = wr."workspaceId"
AND wm."role"::text = wr."name"
AND wm."roleId" IS NULL;
--> statement-breakpoint
-- Clean up helper function
DROP FUNCTION IF EXISTS generate_public_id();

File diff suppressed because it is too large Load Diff

View File

@@ -162,6 +162,13 @@
"when": 1768858977000,
"tag": "20260119164257_AddShowEmailsToMembersToWorkspace",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1769435949476,
"tag": "20260126135909_AddWorkspaceRoles",
"breakpoints": true
}
]
}

View File

@@ -518,6 +518,7 @@ export const getWithListIdsByPublicId = (
columns: {
id: true,
workspaceId: true,
createdBy: true,
},
with: {
lists: {
@@ -671,6 +672,7 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
columns: {
id: true,
workspaceId: true,
createdBy: true,
},
where: eq(boards.publicId, boardPublicId),
});

View File

@@ -424,6 +424,7 @@ export const getWithListAndMembersByPublicId = async (
title: true,
description: true,
dueDate: true,
createdBy: true,
},
with: {
labels: {
@@ -938,7 +939,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
cardPublicId: string,
) => {
const result = await db.query.cards.findFirst({
columns: { id: true },
columns: { id: true, createdBy: true },
where: and(eq(cards.publicId, cardPublicId), isNull(cards.deletedAt)),
with: {
list: {
@@ -958,6 +959,7 @@ export const getWorkspaceAndCardIdByCardPublicId = async (
return result
? {
id: result.id,
createdBy: result.createdBy,
workspaceId: result.list.board.workspaceId,
workspaceVisibility: result.list.board.visibility,
}

View File

@@ -419,7 +419,7 @@ export const getWorkspaceAndListIdByListPublicId = async (
listPublicId: string,
) => {
const result = await db.query.lists.findFirst({
columns: { id: true },
columns: { id: true, createdBy: true },
where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)),
with: {
board: {
@@ -431,6 +431,10 @@ export const getWorkspaceAndListIdByListPublicId = async (
});
return result
? { id: result.id, workspaceId: result.board.workspaceId }
? {
id: result.id,
createdBy: result.createdBy,
workspaceId: result.board.workspaceId,
}
: null;
};

View File

@@ -27,6 +27,7 @@ export const create = async (
workspaceId: number;
createdBy: string;
role: MemberRole;
roleId?: number | null;
status: MemberStatus;
},
) => {
@@ -39,6 +40,7 @@ export const create = async (
workspaceId: memberInput.workspaceId,
createdBy: memberInput.createdBy,
role: memberInput.role,
roleId: memberInput.roleId ?? null,
status: memberInput.status,
})
.returning({
@@ -55,6 +57,12 @@ export const getByPublicId = async (db: dbClient, publicId: string) => {
});
};
export const getById = async (db: dbClient, memberId: number) => {
return db.query.workspaceMembers.findFirst({
where: eq(workspaceMembers.id, memberId),
});
};
export const getByEmailAndStatus = async (
db: dbClient,
email: string,
@@ -133,3 +141,28 @@ export const pauseAllMembers = async (db: dbClient, workspaceId: number) => {
),
);
};
export const updateRole = async (
db: dbClient,
args: {
memberId: number;
role: MemberRole;
roleId: number | null;
},
) => {
const [result] = await db
.update(workspaceMembers)
.set({
role: args.role,
roleId: args.roleId,
updatedAt: new Date(),
})
.where(eq(workspaceMembers.id, args.memberId))
.returning({
id: workspaceMembers.id,
publicId: workspaceMembers.publicId,
role: workspaceMembers.role,
});
return result;
};

View File

@@ -0,0 +1,494 @@
import { and, eq, isNull, inArray } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import {
workspaceMemberPermissions,
workspaceMembers,
workspaceRolePermissions,
workspaceRoles,
} from "@kan/db/schema";
import type { Permission, Role } from "@kan/shared";
import { generateUID, getDefaultPermissions } from "@kan/shared";
/**
* Get permissions by role ID
*/
export const getPermissionsByRoleId = async (
db: dbClient,
roleId: number,
): Promise<Permission[]> => {
const permissions = await db
.select({ permission: workspaceRolePermissions.permission })
.from(workspaceRolePermissions)
.where(
and(
eq(workspaceRolePermissions.workspaceRoleId, roleId),
eq(workspaceRolePermissions.granted, true),
),
);
return permissions.map((p) => p.permission as Permission);
};
/**
* Get role by workspace ID and name
*/
export const getRoleByWorkspaceIdAndName = async (
db: dbClient,
workspaceId: number,
name: string,
) => {
const [role] = await db
.select()
.from(workspaceRoles)
.where(
and(
eq(workspaceRoles.workspaceId, workspaceId),
eq(workspaceRoles.name, name),
),
)
.limit(1);
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
*/
export const getMemberPermissionOverrides = async (
db: dbClient,
workspaceMemberId: number,
) => {
return db
.select()
.from(workspaceMemberPermissions)
.where(eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId));
};
/**
* Get a single permission override for a member
*/
export const getMemberPermissionOverride = async (
db: dbClient,
workspaceMemberId: number,
permission: string,
) => {
const [override] = await db
.select()
.from(workspaceMemberPermissions)
.where(
and(
eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId),
eq(workspaceMemberPermissions.permission, permission),
),
)
.limit(1);
return override;
};
/**
* Get effective permissions for a workspace member
* Combines role template (from DB) with custom overrides
*/
export const getMemberEffectivePermissions = async (
db: dbClient,
workspaceMemberId: number,
roleId: number | null,
roleName: string,
): Promise<Permission[]> => {
let roleDefaults: Set<Permission>;
// Try to get role permissions from database first
if (roleId) {
const dbPermissions = await getPermissionsByRoleId(db, roleId);
roleDefaults = new Set<Permission>(dbPermissions);
} else {
// Fallback to code-based defaults if roleId not set
const codeDefaults = getDefaultPermissions(roleName as Role);
roleDefaults = new Set<Permission>([...codeDefaults]);
}
// Get custom overrides
const overrides = await getMemberPermissionOverrides(db, workspaceMemberId);
// Apply overrides
for (const override of overrides) {
if (override.granted) {
roleDefaults.add(override.permission as Permission);
} else {
roleDefaults.delete(override.permission as Permission);
}
}
return Array.from(roleDefaults);
};
/**
* Check if a member has a specific permission
*/
export const memberHasPermission = async (
db: dbClient,
workspaceMemberId: number,
roleId: number | null,
roleName: string,
permission: Permission,
): Promise<boolean> => {
let hasRoleDefault: boolean;
// Check role permission from database first
if (roleId) {
const dbPermissions = await getPermissionsByRoleId(db, roleId);
hasRoleDefault = dbPermissions.includes(permission);
} else {
// Fallback to code-based defaults
const codeDefaults = getDefaultPermissions(roleName as Role);
hasRoleDefault = codeDefaults.includes(permission);
}
// Check for override
const [override] = await db
.select()
.from(workspaceMemberPermissions)
.where(
and(
eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId),
eq(workspaceMemberPermissions.permission, permission),
),
)
.limit(1);
// Override takes precedence
if (override) {
return override.granted;
}
return hasRoleDefault;
};
/**
* Grant a permission to a member
*/
export const grantPermission = async (
db: dbClient,
workspaceMemberId: number,
permission: Permission,
) => {
const [result] = await db
.insert(workspaceMemberPermissions)
.values({
workspaceMemberId,
permission,
granted: true,
})
.onConflictDoUpdate({
target: [
workspaceMemberPermissions.workspaceMemberId,
workspaceMemberPermissions.permission,
],
set: {
granted: true,
updatedAt: new Date(),
},
})
.returning();
return result;
};
/**
* Revoke a permission from a member
*/
export const revokePermission = async (
db: dbClient,
workspaceMemberId: number,
permission: Permission,
) => {
const [result] = await db
.insert(workspaceMemberPermissions)
.values({
workspaceMemberId,
permission,
granted: false,
})
.onConflictDoUpdate({
target: [
workspaceMemberPermissions.workspaceMemberId,
workspaceMemberPermissions.permission,
],
set: {
granted: false,
updatedAt: new Date(),
},
})
.returning();
return result;
};
/**
* Clear all permission overrides for a workspace member
*/
export const clearMemberPermissionOverrides = async (
db: dbClient,
workspaceMemberId: number,
) => {
await db
.delete(workspaceMemberPermissions)
.where(eq(workspaceMemberPermissions.workspaceMemberId, workspaceMemberId));
};
/**
* Clear all permission overrides for all members in a workspace
*/
export const clearAllMemberPermissionOverridesForWorkspace = async (
db: dbClient,
workspaceId: number,
) => {
const memberIds = await db
.select({ id: workspaceMembers.id })
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.workspaceId, workspaceId),
isNull(workspaceMembers.deletedAt),
),
);
if (memberIds.length === 0) return;
const ids = memberIds.map((m) => m.id);
await db
.delete(workspaceMemberPermissions)
.where(inArray(workspaceMemberPermissions.workspaceMemberId, ids));
};
/**
* Get member with their role by userId and workspaceId
*/
export const getMemberWithRole = async (
db: dbClient,
userId: string,
workspaceId: number,
) => {
const [member] = await db
.select({
id: workspaceMembers.id,
publicId: workspaceMembers.publicId,
role: workspaceMembers.role,
roleId: workspaceMembers.roleId,
})
.from(workspaceMembers)
.where(
and(
eq(workspaceMembers.userId, userId),
eq(workspaceMembers.workspaceId, workspaceId),
isNull(workspaceMembers.deletedAt),
),
)
.limit(1);
return member;
};
/**
* Check if user has permission in workspace
*/
export const userHasPermissionInWorkspace = async (
db: dbClient,
userId: string,
workspaceId: number,
permission: Permission,
): Promise<boolean> => {
const member = await getMemberWithRole(db, userId, workspaceId);
if (!member) {
return false;
}
return memberHasPermission(
db,
member.id,
member.roleId,
member.role,
permission,
);
};
/**
* Get all permissions for a user in a workspace
*/
export const getUserPermissionsInWorkspace = async (
db: dbClient,
userId: string,
workspaceId: number,
): Promise<{
permissions: Permission[];
role: string;
roleId: number | null;
} | null> => {
const member = await getMemberWithRole(db, userId, workspaceId);
if (!member) {
return null;
}
const permissions = await getMemberEffectivePermissions(
db,
member.id,
member.roleId,
member.role,
);
return {
permissions,
role: member.role,
roleId: member.roleId,
};
};
/**
* Create a role for a workspace
*/
export const createRole = async (
db: dbClient,
args: {
workspaceId: number;
name: string;
description: string;
hierarchyLevel: number;
isSystem: boolean;
permissions: Permission[];
},
) => {
const [role] = await db
.insert(workspaceRoles)
.values({
publicId: generateUID(),
workspaceId: args.workspaceId,
name: args.name,
description: args.description,
hierarchyLevel: args.hierarchyLevel,
isSystem: args.isSystem,
})
.returning({
id: workspaceRoles.id,
name: workspaceRoles.name,
publicId: workspaceRoles.publicId,
});
if (role && args.permissions.length > 0) {
await db.insert(workspaceRolePermissions).values(
args.permissions.map((perm) => ({
workspaceRoleId: role.id,
permission: perm,
granted: true,
})),
);
}
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));
};

View File

@@ -18,7 +18,33 @@ import {
workspaceMembers,
workspaces,
} from "@kan/db/schema";
import { generateUID } from "@kan/shared/utils";
import type { Permission, Role } from "@kan/shared";
import { generateUID, getDefaultPermissions } from "@kan/shared";
import * as permissionRepo from "./permission.repo";
// System role definitions
const SYSTEM_ROLES: {
name: Role;
description: string;
hierarchyLevel: number;
}[] = [
{
name: "admin",
description: "Full access to all workspace features",
hierarchyLevel: 100,
},
{
name: "member",
description: "Standard member with create and edit permissions",
hierarchyLevel: 50,
},
{
name: "guest",
description: "View-only access",
hierarchyLevel: 10,
},
];
export const getCount = async (db: dbClient) => {
const result = await db
@@ -57,6 +83,22 @@ export const create = async (
});
if (workspace) {
// Create system roles for the workspace
let adminRoleId: number | null = null;
for (const roleData of SYSTEM_ROLES) {
const role = await permissionRepo.createRole(db, {
workspaceId: workspace.id,
name: roleData.name,
description: roleData.description,
hierarchyLevel: roleData.hierarchyLevel,
isSystem: true,
permissions: [...getDefaultPermissions(roleData.name)] as Permission[],
});
if (roleData.name === "admin" && role) {
adminRoleId = role.id;
}
}
await db.insert(workspaceMembers).values({
publicId: generateUID(),
userId: workspaceInput.createdBy,
@@ -64,6 +106,7 @@ export const create = async (
workspaceId: workspace.id,
createdBy: workspaceInput.createdBy,
role: "admin",
roleId: adminRoleId,
status: "active",
});
}
@@ -151,8 +194,13 @@ export const getByPublicIdWithMembers = (
email: true,
role: true,
status: true,
createdAt: true,
},
where: isNull(workspaceMembers.deletedAt),
orderBy: (member, { desc }) => [
desc(sql`CASE WHEN ${member.role} = 'admin' THEN 1 ELSE 0 END`),
desc(member.createdAt),
],
with: {
user: {
columns: {

View File

@@ -12,3 +12,4 @@ export * from "./integrations";
export * from "./workspaces";
export * from "./subscriptions";
export * from "./workspaceInviteLinks";
export * from "./permissions";

View File

@@ -0,0 +1,98 @@
import { relations } from "drizzle-orm";
import {
bigint,
bigserial,
boolean,
index,
integer,
pgTable,
timestamp,
uniqueIndex,
varchar,
} from "drizzle-orm/pg-core";
import { workspaces } from "./workspaces";
export const workspaceRoles = pgTable(
"workspace_roles",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
workspaceId: bigint("workspaceId", { mode: "number" })
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
name: varchar("name", { length: 64 }).notNull(),
description: varchar("description", { length: 255 }),
hierarchyLevel: integer("hierarchyLevel").notNull(),
isSystem: boolean("isSystem").notNull().default(false),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
},
(table) => [
uniqueIndex("unique_role_per_workspace").on(table.workspaceId, table.name),
index("workspace_roles_workspace_idx").on(table.workspaceId),
],
).enableRLS();
export const workspaceRolesRelations = relations(
workspaceRoles,
({ one, many }) => ({
workspace: one(workspaces, {
fields: [workspaceRoles.workspaceId],
references: [workspaces.id],
relationName: "workspaceRoles",
}),
permissions: many(workspaceRolePermissions),
}),
);
export const workspaceRolePermissions = pgTable(
"workspace_role_permissions",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
workspaceRoleId: bigint("workspaceRoleId", { mode: "number" })
.notNull()
.references(() => workspaceRoles.id, { onDelete: "cascade" }),
permission: varchar("permission", { length: 64 }).notNull(),
granted: boolean("granted").notNull().default(true),
createdAt: timestamp("createdAt").defaultNow().notNull(),
},
(table) => [
uniqueIndex("unique_role_permission").on(
table.workspaceRoleId,
table.permission,
),
index("role_permissions_role_idx").on(table.workspaceRoleId),
],
).enableRLS();
export const workspaceRolePermissionsRelations = relations(
workspaceRolePermissions,
({ one }) => ({
role: one(workspaceRoles, {
fields: [workspaceRolePermissions.workspaceRoleId],
references: [workspaceRoles.id],
relationName: "rolePermissions",
}),
}),
);
export const workspaceMemberPermissions = pgTable(
"workspace_member_permissions",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" }).notNull(),
permission: varchar("permission", { length: 64 }).notNull(),
granted: boolean("granted").notNull().default(true),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
},
(table) => [
uniqueIndex("unique_member_permission").on(
table.workspaceMemberId,
table.permission,
),
index("permission_member_idx").on(table.workspaceMemberId),
],
).enableRLS();

View File

@@ -12,6 +12,7 @@ import {
} from "drizzle-orm/pg-core";
import { boards } from "./boards";
import { workspaceMemberPermissions, workspaceRoles } from "./permissions";
import { subscription } from "./subscriptions";
import { users } from "./users";
@@ -69,6 +70,7 @@ export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
members: many(workspaceMembers),
boards: many(boards),
subscriptions: many(subscription),
roles: many(workspaceRoles),
}));
export const workspaceMembers = pgTable("workspace_members", {
@@ -86,13 +88,18 @@ export const workspaceMembers = pgTable("workspace_members", {
deletedBy: uuid("deletedBy").references(() => users.id, {
onDelete: "set null",
}),
// Legacy role enum
role: memberRoleEnum("role").notNull(),
roleId: bigint("roleId", { mode: "number" }).references(
() => workspaceRoles.id,
{ onDelete: "restrict" },
),
status: memberStatusEnum("status").default("invited").notNull(),
}).enableRLS();
export const workspaceMembersRelations = relations(
workspaceMembers,
({ one }) => ({
({ one, many }) => ({
user: one(users, {
fields: [workspaceMembers.userId],
references: [users.id],
@@ -103,6 +110,23 @@ export const workspaceMembersRelations = relations(
references: [workspaces.id],
relationName: "workspaceMembersWorkspace",
}),
workspaceRole: one(workspaceRoles, {
fields: [workspaceMembers.roleId],
references: [workspaceRoles.id],
relationName: "workspaceMemberRole",
}),
permissions: many(workspaceMemberPermissions),
}),
);
export const workspaceMemberPermissionsRelations = relations(
workspaceMemberPermissions,
({ one }) => ({
member: one(workspaceMembers, {
fields: [workspaceMemberPermissions.workspaceMemberId],
references: [workspaceMembers.id],
relationName: "memberPermissions",
}),
}),
);