Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
144
planned
1,362
shipped

Motir · Work items

MOTIR-2257Done

Custom project roles — author a role from a base, choose its permissions, assign members to it

Let a project admin create their own roles. A custom role is named by its author, scoped to one project, started from a built-in BASE, and defined by the permissions it holds over the catalog established in the catalog story and expanded per domain in the per-domain split. The read-only Roles & permissions page gains its write affordances; the Members admin's role picker gains the project's custom roles alongside the three built-ins.

The shape (rung 2 — verified on origin/main, 2026-08-08)

  • Schema. A ProjectRoleDefinition row per custom role — { id, workspaceId, projectId, name, basedOn, permissions[] } — with a per-project unique name. It carries its own workspace_id column and the FOR ALL USING ("workspace_id" = current_setting('app.workspace_id', true)) policy every workspace-bearing table takes; prisma/migrations/20260608224711_add_project_membership_and_roles/migration.sql is the pattern to copy verbatim, not an RLS join through project. ProjectMembership gains a nullable pointer to a role definition; a null pointer means the membership still names a BUILT-IN role through its existing role enum column, so no existing row is migrated and no member loses access on deploy.
  • Built-ins stay immutable and stay in code. lib/permissions/builtinRoles.ts keeps owning them; they are not seeded as editable rows. Cloning one produces a custom role; the built-in itself can never be edited or deleted, which is what makes "reset to the default" always available and what the mirror does.
  • A role is its NAME and its SET. Nothing records where it started (Yue, 2026-08-09). The editor still offers a built-in to start from — an empty grid of twenty-eight checkboxes is a worse starting point than a role that already works, which is GitHub's base-role pattern and what the epic cites — but that pick SEEDS THE GRID AND IS NOT STORED. An earlier revision kept a basedOn column feeding a Based on Viewer · +2 chip; it was provenance, not inheritance — a snapshot that never re-flows, so a claim about how the role was once authored rather than a fact about what it is, and stale the moment either side was edited. A role that lists its permissions already says everything true about itself, and the list row's N of 28 gives the coarse comparison.
  • A membership on a custom role sits at ONE tier (member), so the ACCESS LEVEL SUBTRACTS NOTHING from it — a custom role grants exactly what it lists, on every level. This is the one behaviour consequence of the line above, and it is deliberate: the level's tier subtraction exists to narrow the COARSE built-in roles, and a set an admin enumerated by hand is not coarse. A role that listed work_item:edit and silently did not have it would be the bug. private still requires a membership to see the project at all, and a BUILT-IN role is narrowed exactly as it always was.
  • The grid is grouped by DOMAIN, and it is the read screen's list with its marks swapped for checkboxes. The design asset measured and rejected inventing a second grammar for authoring, so the editor walks permissionsByDomain({ include: ROLE_GATED_PERMISSIONS }) in exactly the order the detail screen already walks. Whether a domain heading also carries a bulk grant-all / clear-all control — the affordance Jira's named bundles amount to on a catalog that is already grouped — is not drawn today, and the design amendment in this story decides and draws it rather than a code card improvising one.
  • Deleting a role reassigns its members. A role in use cannot vanish under the people holding it: deletion requires choosing the role its members move to, in the shape workflowsService.deleteStatus already shipped for workflow statuses — a typed in-use refusal carrying the affected count when no target is given, and a reassign-then-delete in one transaction when one is (lib/services/workflowsService.ts, StatusInUseError, and the ReassignModal in WorkflowEditor.tsx). Custom-field options are the counter-example, not the precedent: customFieldsService.deleteOption throws OptionInUseError and the UI offers archive instead. There is no per-project issue-type vocabulary at all.
  • Caps. A bounded number of custom roles per project, refused with a typed error rather than a silent truncation — the same posture as lib/customFields/limits.ts' MAX_FIELDS_PER_PROJECT / FieldLimitReachedError.
  • A permission no gate consults can never be granted. The server refuses any key outside the role-gated, enforced set, read from the constants rather than a literal list. PLANNED_PERMISSIONS is empty on origin/main today (MOTIR-2356 emptied it), so the editor's non-interactive treatment for a planned key is currently inert — the refusal is the standing invariant, and it must be written so the next planned key is refused without a code change.

Scope boundary

In: the schema, migration and repository; the resolution arm that reads a membership's custom permission set; the role-definition service and its project-admin-gated API; the Checkbox primitive the editor needs; the role editor page (create from base, rename, edit permissions); the list and detail screens' admin write affordances and the delete-with-reassign dialog; the Members admin role picker extended to custom roles; the design amendment for the surfaces the shipped asset does not draw; the story's two test subtasks.

Out: workspace-scoped custom roles — the workspace MemberRole is untouched, as the epic records, and the workspace-manager always-pass rail in lib/permissions/resolve.ts survives intact; sharing a role definition across projects (Jira's reusable permission SCHEME) — a project's roles are its own, and a scheme that spans projects is not planned; role assignment through groups; any NEW catalog key — the catalog is the model story's and this story only lets a role choose among keys that already exist; hiding settings entries the actor cannot use, which is the permission-gated UI story's; and the product-wide --el-text-faint sweep, which is its own bug.

Verification recipe

  • Pull the story branch, pnpm install, pnpm prisma migrate dev, pnpm dev.
  • As a project admin, open Project settings → Access → Roles & permissions → Create role. Start from Member, name it Contributor, and remove work_item:triage and sprint:manage. Save. The role appears in the list with a Custom chip and its N of M permissions.
  • Drill into Contributor: the detail screen shows its Custom chip, its N of M and its member count, and carries Edit and Delete. There is no "based on" chip — nothing records which built-in seeded it.
  • Open Members, change a teammate to Contributor, and sign in as them: they can create and edit work items and comment, and the sprint controls refuse.
  • Back as admin, delete Contributor: the dialog names how many members hold it and requires choosing a replacement role, and the teammate holds that replacement afterwards — never no role at all.
  • Confirm the built-ins cannot be damaged: Admin, Member and Viewer offer no edit or delete affordance, and a direct PATCH/DELETE against one is refused.
  • pnpm test — the permission-resolution matrix covers a membership on a custom role, a membership on a built-in, and the workspace-manager rail overriding both.