Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions apps/web/src/page/settings/kit/org-membership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,22 @@ describe("leaveEligibility", () => {
})

describe("toOrgRole", () => {
it("reads unknown Clerk roles as member", () => {
it("reads unknown Clerk roles as observer, the least a person can hold", () => {
expect(toOrgRole("org:admin")).toBe("org:admin")
expect(toOrgRole("org:billing")).toBe("org:member")
expect(orgRoleLabel("org:viewer")).toBe("Member")
expect(toOrgRole("org:billing")).toBe("org:observer")
expect(orgRoleLabel("org:viewer")).toBe("Observer")
})

it("shows root, which has no entry of its own, as a member", () => {
expect(orgRoleLabel("root")).toBe("Member")
})

it("names the observer role in a role change prompt", () => {
expect(roleChangePrompt("Ada", "org:observer")).toEqual({
question:
"Make Ada an observer? Follows incidents, reliability, product analytics and cost, without changing anything.",
confirmLabel: "Make observer",
})
})
})

Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/page/settings/kit/org-membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
/** The assignable Clerk role keys, lowest role first. */
export const orgRoleKeys: ReadonlyArray<OrgRole> = Arr.map(Policy.assignableRoles, (entry) => entry.clerkKey)

const [memberEntry] = Policy.assignableRoles
// `root`, which Clerk never assigns, has no entry of its own and shows as a member.
const memberEntry = Policy.assignableRoles[1]

Check notice on line 11 in apps/web/src/page/settings/kit/org-membership.ts

View check run for this annotation

Maple Review Bot / Maple / review

maintainability: `memberEntry` is looked up by array position, not by role

`Policy.assignableRoles[1]` hard-codes member as second in a list the same module defines as rank-ordered; this change already shifted every role down one. A role inserted between observer and member would make a `root` membership read as that role, and its row picker would then write it.

/** A Clerk role string's entry, read through `Policy.roleOf` (unknown roles are members). */
/** A Clerk role string's entry, read through `Policy.roleOf` (unknown roles are observers). */
const entryOf = (role: string): Policy.AssignableRole => {
const policyRole = Policy.roleOf(role)
return Option.getOrElse(
Expand Down
13 changes: 8 additions & 5 deletions packages/domain/src/policy/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,17 +113,20 @@ describe("Policy.grantsOf", () => {
describe("roleOf", () => {
it("maps the Clerk keys the Members page assigns", () => {
expect(Arr.map(Policy.assignableRoles, (entry) => Policy.roleOf(entry.clerkKey))).toEqual([
"observer",
"member",
"engineer",
"admin",
])
})

it("reads the observer key as observer, below every assignable role", () => {
expect(Policy.roleOf("org:observer")).toBe("observer")
for (const entry of Policy.assignableRoles) {
expect(Policy.outranks(entry.role, "observer")).toBe(true)
}
it("lists the assignable roles lowest first, so each outranks the one before it", () => {
const roles = Arr.map(Policy.assignableRoles, (entry) => entry.role)
expect(
Arr.every(Arr.zip(roles, Arr.drop(roles, 1)), ([lower, higher]) =>
Policy.outranks(higher, lower),
),
).toBe(true)
})
})

Expand Down
18 changes: 7 additions & 11 deletions packages/domain/src/policy/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const byRank: Order.Order<Role> = Order.mapInput(Order.Number, (role) => RANK[ro

/** What the Members page shows for a role a person can be given in Clerk. */
export const assignableRoles = [
{
role: "observer",
clerkKey: "org:observer",
label: "Observer",
summary: "Follows incidents, reliability, product analytics and cost, without changing anything.",
},
{
role: "member",
clerkKey: "org:member",
Expand All @@ -43,16 +49,6 @@ export const assignableRoles = [
export type AssignableRole = (typeof assignableRoles)[number]
export type ClerkRoleKey = AssignableRole["clerkKey"]

/** Read-only, for people who follow the product without operating it. Not offered on the Members page yet. */
const observerRole = {
role: "observer",
clerkKey: "org:observer",
label: "Observer",
summary: "Follows incidents, reliability, product analytics and cost. Changes nothing.",
} as const satisfies { role: Role; clerkKey: string; label: string; summary: string }

const clerkRoles = [observerRole, ...assignableRoles] as const

/**
* A Clerk or session role string as a {@link Role}. A role this code does not know reads as
* observer, the least it can hold.
Expand All @@ -61,7 +57,7 @@ export const roleOf = (name: string): Role => {
if (name === "root") return "root"
return Option.getOrElse(
Option.map(
Arr.findFirst(clerkRoles, (entry) => entry.clerkKey === name),
Arr.findFirst(assignableRoles, (entry) => entry.clerkKey === name),
(entry): Role => entry.role,
),
(): Role => "observer",
Expand Down
Loading