@cedarjs/cli 8.0.0-canary.3538 → 8.0.0-canary.3540

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/commands/generate/realtime/realtimeHandler.js +13 -5
  2. package/dist/commands/generate/sdl/sdlHandler.js +10 -2
  3. package/dist/commands/setup/realtime/realtimeHandler.js +43 -18
  4. package/dist/commands/setup/tenancy/authCodemod.js +181 -16
  5. package/dist/commands/setup/tenancy/authDirectives.js +101 -0
  6. package/dist/commands/setup/tenancy/permissionsPackage.js +240 -0
  7. package/dist/commands/setup/tenancy/templates/OrgScope.tsx.template +40 -1
  8. package/dist/commands/setup/tenancy/templates/organizations.sdl.ts.template +8 -6
  9. package/dist/commands/setup/tenancy/templates/organizations.test.ts.template +24 -28
  10. package/dist/commands/setup/tenancy/templates/organizations.ts.template +6 -6
  11. package/dist/commands/setup/tenancy/templates/permissions.ts.template +20 -0
  12. package/dist/commands/setup/tenancy/templates/permissionsPackage.index.ts.template +28 -0
  13. package/dist/commands/setup/tenancy/templates/requireOrgAuth.test.ts.template +56 -0
  14. package/dist/commands/setup/tenancy/templates/requireOrgAuth.ts.template +25 -0
  15. package/dist/commands/setup/tenancy/templates/requirePermission.test.ts.template +50 -0
  16. package/dist/commands/setup/tenancy/templates/requirePermission.ts.template +32 -0
  17. package/dist/commands/setup/tenancy/templates/requireUserAuth.test.ts.template +26 -0
  18. package/dist/commands/setup/tenancy/templates/requireUserAuth.ts.template +26 -0
  19. package/dist/commands/setup/tenancy/tenancyHandler.js +127 -13
  20. package/dist/commands/setup/tenancy/webHasRole.js +45 -0
  21. package/dist/commands/setup/uploads/uploadsHandler.js +4 -0
  22. package/dist/lib/authDirective.js +37 -0
  23. package/package.json +13 -13
  24. package/dist/commands/setup/tenancy/templates/requireMembership.test.ts.template +0 -35
  25. package/dist/commands/setup/tenancy/templates/requireMembership.ts.template +0 -25
@@ -0,0 +1,240 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { Listr } from "listr2";
4
+ import * as toml from "smol-toml";
5
+ import { runScript } from "@cedarjs/cli-helpers/packageManager/exec";
6
+ import { installPackages } from "@cedarjs/cli-helpers/packageManager/packages";
7
+ import { addWorkspaceDir } from "@cedarjs/cli-helpers/packageManager/workspaces";
8
+ import { getConfigPath } from "@cedarjs/project-config";
9
+ import { getPackageManager } from "@cedarjs/project-config/packageManager";
10
+ import { getPaths, writeFile } from "../../../lib/index.js";
11
+ import {
12
+ addDependencyToPackageJson,
13
+ nameVariants,
14
+ updateGitignore,
15
+ updateTsconfig,
16
+ updateWorkspaceTsconfigReferences
17
+ } from "../../generate/package/packageHandler.js";
18
+ const PERMISSIONS_PACKAGE_FOLDER = "permissions";
19
+ const TABLE_HEADER = "[experimental.packagesWorkspace]";
20
+ function enablePackagesWorkspace(tomlContent) {
21
+ const config = toml.parse(tomlContent);
22
+ const packagesWorkspace = config.experimental?.packagesWorkspace;
23
+ if (packagesWorkspace?.enabled === true) {
24
+ return null;
25
+ }
26
+ const lines = tomlContent.split("\n");
27
+ const headerIndex = lines.findIndex((line) => line.trim() === TABLE_HEADER);
28
+ if (headerIndex === -1) {
29
+ if (packagesWorkspace) {
30
+ throw new Error(
31
+ "CEDAR_TENANCY_ERR_PACKAGES_WORKSPACE: set `experimental.packagesWorkspace.enabled = true` in cedar.toml and run this command again."
32
+ );
33
+ }
34
+ return tomlContent.replace(/\n*$/, "") + `
35
+
36
+ ${TABLE_HEADER}
37
+ enabled = true
38
+ `;
39
+ }
40
+ const tableEnd = lines.findIndex(
41
+ (line, index) => index > headerIndex && /^\s*\[/.test(line)
42
+ );
43
+ const end = tableEnd === -1 ? lines.length : tableEnd;
44
+ const enabledIndex = lines.findIndex(
45
+ (line, index) => index > headerIndex && index < end && /^\s*enabled\s*=/.test(line)
46
+ );
47
+ if (enabledIndex === -1) {
48
+ lines.splice(headerIndex + 1, 0, " enabled = true");
49
+ } else {
50
+ lines[enabledIndex] = lines[enabledIndex].replace(
51
+ /enabled\s*=.*/,
52
+ "enabled = true"
53
+ );
54
+ }
55
+ return lines.join("\n");
56
+ }
57
+ const packageNameIn = (packageDir) => {
58
+ try {
59
+ const { name } = JSON.parse(
60
+ fs.readFileSync(path.join(packageDir, "package.json"), "utf-8")
61
+ );
62
+ return typeof name === "string" ? name : void 0;
63
+ } catch {
64
+ return void 0;
65
+ }
66
+ };
67
+ function permissionsPackageName(packagesDir = path.join(getPaths().base, "packages")) {
68
+ const existing = packageNameIn(
69
+ path.join(packagesDir, PERMISSIONS_PACKAGE_FOLDER)
70
+ );
71
+ if (existing) {
72
+ return existing;
73
+ }
74
+ const otherPackages = fs.existsSync(packagesDir) ? fs.readdirSync(packagesDir, { withFileTypes: true }) : [];
75
+ const scope = otherPackages.filter((entry) => entry.isDirectory()).map((entry) => packageNameIn(path.join(packagesDir, entry.name))).find((name) => name?.startsWith("@") && name.includes("/"))?.split("/")[0];
76
+ return scope ? `${scope}/${PERMISSIONS_PACKAGE_FOLDER}` : nameVariants(PERMISSIONS_PACKAGE_FOLDER).packageName;
77
+ }
78
+ function permissionsPackageFiles({
79
+ packageName,
80
+ cedarVersion,
81
+ indexSource
82
+ }) {
83
+ const packageJson = {
84
+ name: packageName,
85
+ version: "0.0.0",
86
+ private: true,
87
+ type: "module",
88
+ main: "dist/index.js",
89
+ types: "./dist/index.d.ts",
90
+ exports: {
91
+ ".": {
92
+ types: "./dist/index.d.ts",
93
+ default: "./dist/index.js"
94
+ }
95
+ },
96
+ scripts: {
97
+ build: "tsc",
98
+ watch: "tsc --watch"
99
+ },
100
+ dependencies: {
101
+ "@cedarjs/tenancy": cedarVersion
102
+ },
103
+ devDependencies: {
104
+ typescript: "5.9.3"
105
+ }
106
+ };
107
+ const tsconfig = {
108
+ compilerOptions: {
109
+ composite: true,
110
+ target: "es2023",
111
+ module: "node20",
112
+ esModuleInterop: true,
113
+ strict: true,
114
+ skipLibCheck: true,
115
+ rootDir: "src",
116
+ outDir: "dist",
117
+ sourceMap: true,
118
+ declaration: true,
119
+ declarationMap: true
120
+ },
121
+ include: ["src"]
122
+ };
123
+ return {
124
+ "package.json": JSON.stringify(packageJson, null, 2) + "\n",
125
+ "tsconfig.json": JSON.stringify(tsconfig, null, 2) + "\n",
126
+ "src/index.ts": indexSource
127
+ };
128
+ }
129
+ function writePermissionsPackageFiles(packageDir, files, { force }) {
130
+ const kept = [];
131
+ for (const [file, content] of Object.entries(files)) {
132
+ const filePath = path.join(packageDir, file);
133
+ if (fs.existsSync(filePath) && !force) {
134
+ kept.push(filePath);
135
+ continue;
136
+ }
137
+ writeFile(filePath, content, { overwriteExisting: force });
138
+ }
139
+ return kept;
140
+ }
141
+ function addPermissionsPackageTasks({
142
+ cedarVersion,
143
+ indexSource,
144
+ force
145
+ }) {
146
+ const paths = getPaths();
147
+ const packageDir = path.join(
148
+ paths.base,
149
+ "packages",
150
+ PERMISSIONS_PACKAGE_FOLDER
151
+ );
152
+ const packageName = permissionsPackageName();
153
+ return new Listr([
154
+ {
155
+ title: "Turning on the packages workspace in cedar.toml...",
156
+ task: (ctx, task) => {
157
+ const configPath = getConfigPath();
158
+ const updated = enablePackagesWorkspace(
159
+ fs.readFileSync(configPath, "utf-8")
160
+ );
161
+ if (updated === null) {
162
+ task.skip("Already on");
163
+ return;
164
+ }
165
+ fs.writeFileSync(configPath, updated);
166
+ ctx.packagesWorkspaceTurnedOn = true;
167
+ }
168
+ },
169
+ {
170
+ title: "Updating workspace config...",
171
+ task: (_ctx, task) => {
172
+ const result = addWorkspaceDir(
173
+ paths.base,
174
+ `packages/${PERMISSIONS_PACKAGE_FOLDER}`,
175
+ getPackageManager()
176
+ );
177
+ if (result === "exists") {
178
+ task.skip("Workspaces already configured");
179
+ }
180
+ }
181
+ },
182
+ {
183
+ title: "Updating tsconfig files...",
184
+ task: (_ctx, task) => updateTsconfig(task)
185
+ },
186
+ {
187
+ title: "Updating .gitignore...",
188
+ task: (_ctx, task) => updateGitignore(task)
189
+ },
190
+ {
191
+ title: `Writing ${packageName}...`,
192
+ task: (_ctx, task) => {
193
+ const files = permissionsPackageFiles({
194
+ packageName,
195
+ cedarVersion,
196
+ indexSource
197
+ });
198
+ const kept = writePermissionsPackageFiles(packageDir, files, { force });
199
+ if (kept.length === Object.keys(files).length) {
200
+ task.skip("Already there (run with --force to overwrite)");
201
+ }
202
+ }
203
+ },
204
+ {
205
+ title: "Adding it to the api and web sides...",
206
+ task: async (_ctx, task) => {
207
+ for (const side of [paths.api.base, paths.web.base]) {
208
+ await addDependencyToPackageJson(
209
+ task,
210
+ path.join(side, "package.json"),
211
+ packageName
212
+ );
213
+ }
214
+ }
215
+ },
216
+ {
217
+ title: "Updating tsconfig references...",
218
+ task: (_ctx, task) => updateWorkspaceTsconfigReferences(
219
+ task,
220
+ PERMISSIONS_PACKAGE_FOLDER,
221
+ "both"
222
+ )
223
+ },
224
+ {
225
+ title: "Installing and building it...",
226
+ task: async () => {
227
+ await installPackages({ cwd: paths.base });
228
+ await runScript("build", [], { cwd: packageDir });
229
+ }
230
+ }
231
+ ]);
232
+ }
233
+ export {
234
+ PERMISSIONS_PACKAGE_FOLDER,
235
+ addPermissionsPackageTasks,
236
+ enablePackagesWorkspace,
237
+ permissionsPackageFiles,
238
+ permissionsPackageName,
239
+ writePermissionsPackageFiles
240
+ };
@@ -1,12 +1,26 @@
1
1
  import type { ReactNode } from 'react'
2
2
 
3
3
  import { OrgScope as TenancyOrgScope } from '@cedarjs/tenancy/web'
4
+ import type { OrgScopeProps as TenancyOrgScopeProps } from '@cedarjs/tenancy/web'
4
5
 
5
6
  import { useAuth } from 'src/auth'
6
7
 
7
8
  export interface OrgScopeProps {
8
9
  /** Overrides the `orgSlug` route param. */
9
10
  orgSlug?: string
11
+ /** Only members with one of these roles in the organization get in. */
12
+ roles?: string | string[]
13
+ /**
14
+ * Only members whose role has this permission (api/src/lib/permissions)
15
+ * get in.
16
+ */
17
+ permission?: TenancyOrgScopeProps['permission']
18
+ /**
19
+ * The name of the route a member without one of `roles`, or without
20
+ * `permission`, is sent to. It gets the `orgSlug` param when its path has
21
+ * one.
22
+ */
23
+ unauthorized?: string
10
24
  children: ReactNode
11
25
  }
12
26
 
@@ -19,15 +33,40 @@ export interface OrgScopeProps {
19
33
  * <Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
20
34
  * </Set>
21
35
  *
36
+ * Under it, `useAuth().hasPermission` and `useCurrentOrg().hasOrgRole`
37
+ * check the user's membership in the organization, and
38
+ * `useAuth().hasUserRole` checks `currentUser.roles`. For pages only some
39
+ * members may open, wrap them in a component that passes `permission` (or
40
+ * `roles`; there's no `<PrivateSet roles>` with tenancy):
41
+ *
42
+ * const MemberManagers = ({ children }: { children: ReactNode }) => (
43
+ * <OrgScope permission="members.manage" unauthorized="projects">
44
+ * {children}
45
+ * </OrgScope>
46
+ * )
47
+ *
48
+ * <Set wrap={MemberManagers}>
49
+ * <Route path="/org/{orgSlug}/members" page={MembersPage} name="members" />
50
+ * </Set>
51
+ *
22
52
  * Routes under `OrgScope` must not carry `prerender`: `OrgScope`'s nested
23
53
  * Apollo client shadows the per-route client the prerenderer injects above
24
54
  * `App`, so a prerendered query under here would not go through it.
25
55
  */
26
- export const OrgScope = ({ orgSlug, children }: OrgScopeProps) => {
56
+ export const OrgScope = ({
57
+ orgSlug,
58
+ roles,
59
+ permission,
60
+ unauthorized,
61
+ children,
62
+ }: OrgScopeProps) => {
27
63
  return (
28
64
  <TenancyOrgScope
29
65
  useAuth={useAuth}
30
66
  orgSlug={orgSlug}
67
+ roles={roles}
68
+ permission={permission}
69
+ unauthorized={unauthorized}
31
70
  notAMember={
32
71
  <div>
33
72
  <p>You don&apos;t have access to this organization.</p>
@@ -43,16 +43,17 @@ export const schema = gql`
43
43
 
44
44
  type Query {
45
45
  "Every membership belonging to the signed-in user, across all organizations they belong to."
46
- myMemberships: [Membership!]! @requireAuth
46
+ myMemberships: [Membership!]! @requireUserAuth
47
47
 
48
48
  "The current organization, resolved by resolveCurrentOrg for this request."
49
- organization(id: String!): Organization @requireMembership
49
+ organization(id: String!): Organization
50
+ @requirePermission(permission: "organization.read")
50
51
  }
51
52
 
52
53
  type Mutation {
53
54
  "Creates a new organization with the caller as its owner."
54
55
  createOrganization(input: CreateOrganizationInput!): Organization!
55
- @requireAuth
56
+ @requireUserAuth
56
57
 
57
58
  """
58
59
  Creates a pending membership (an invitation) in the current organization,
@@ -64,7 +65,7 @@ export const schema = gql`
64
65
  the invite shows up if the inviter is also the invitee.
65
66
  """
66
67
  inviteMember(input: InviteMemberInput!): Invitation!
67
- @requireMembership(roles: ["owner", "admin"])
68
+ @requirePermission(permission: "members.manage")
68
69
 
69
70
  """
70
71
  Attaches the pending membership matching token to the signed-in user, or
@@ -73,7 +74,8 @@ export const schema = gql`
73
74
  Web callers must call reauthenticate() from useAuth() in onCompleted so
74
75
  the new membership is reflected in currentUser.memberships.
75
76
  """
76
- acceptInvitation(token: String!): Organization! @requireAuth
77
+ acceptInvitation(token: String!): Organization!
78
+ @requireUserAuth
77
79
 
78
80
  """
79
81
  Removes a membership from the current organization, for a role the
@@ -84,6 +86,6 @@ export const schema = gql`
84
86
  removing their own membership.
85
87
  """
86
88
  removeMember(membershipId: String!): Membership!
87
- @requireMembership(roles: ["owner", "admin"])
89
+ @requirePermission(permission: "members.manage")
88
90
  }
89
91
  `
@@ -1,5 +1,3 @@
1
- import { setContext } from '@cedarjs/context'
2
-
3
1
  import { db } from 'src/lib/db'
4
2
 
5
3
  import {
@@ -18,39 +16,37 @@ import type { StandardScenario } from './organizations.scenarios.js'
18
16
  // CedarJS Testing Docs:
19
17
  // https://cedarjs.com/docs/testing#testing-services
20
18
 
21
- /**
22
- * `context.currentUser` is typed from this app's `getCurrentUser`, which
23
- * selects more fields than these tests need.
24
- */
25
- const asCurrentUser = (user: { id: string; memberships?: unknown[] }) =>
26
- user as unknown as typeof context.currentUser
19
+ /** The id of the user a scenario membership belongs to */
20
+ const userIdOf = (membership: { userId: string | null }) => {
21
+ if (!membership.userId) {
22
+ throw new Error('Expected the scenario membership to have a user')
23
+ }
24
+
25
+ return membership.userId
26
+ }
27
27
 
28
28
  /**
29
- * Sets `context.currentUser` and `context.currentOrg` the way the GraphQL
30
- * `context` function does for an authenticated, in-organization request.
29
+ * Signs in as `membership`'s user, in its organization, the way the GraphQL
30
+ * `context` function does for an in-organization request
31
31
  */
32
32
  const asMember = (
33
33
  scenario: StandardScenario,
34
34
  membership: { id: string; userId: string | null; role: string },
35
35
  ) => {
36
- setContext({
37
- currentUser: asCurrentUser({ id: membership.userId! }),
38
- currentOrg: {
39
- id: scenario.organization.acme.id,
40
- slug: scenario.organization.acme.slug,
41
- role: membership.role,
42
- membershipId: membership.id,
43
- },
44
- })
36
+ mockCurrentUser({ id: userIdOf(membership) })
37
+ context.currentOrg = {
38
+ id: scenario.organization.acme.id,
39
+ slug: scenario.organization.acme.slug,
40
+ role: membership.role,
41
+ membershipId: membership.id,
42
+ }
45
43
  }
46
44
 
47
45
  describe('organizations service', () => {
48
46
  scenario(
49
47
  "myMemberships returns the signed-in user's memberships",
50
48
  async (scenario: StandardScenario) => {
51
- setContext({
52
- currentUser: asCurrentUser({ id: scenario.membership.owner.userId! }),
53
- })
49
+ mockCurrentUser({ id: userIdOf(scenario.membership.owner) })
54
50
 
55
51
  const result = await myMemberships()
56
52
 
@@ -235,7 +231,7 @@ describe('organizations service', () => {
235
231
  },
236
232
  })
237
233
 
238
- setContext({ currentUser: asCurrentUser({ id: invitee.id }) })
234
+ mockCurrentUser({ id: invitee.id })
239
235
 
240
236
  const result = await acceptInvitation({
241
237
  token: scenario.membership.pending.invitationToken!,
@@ -256,7 +252,7 @@ describe('organizations service', () => {
256
252
  },
257
253
  })
258
254
 
259
- setContext({ currentUser: asCurrentUser({ id: invitee.id }) })
255
+ mockCurrentUser({ id: invitee.id })
260
256
 
261
257
  const token = scenario.membership.pending.invitationToken!
262
258
 
@@ -287,10 +283,10 @@ describe('organizations service', () => {
287
283
 
288
284
  const token = scenario.membership.pending.invitationToken!
289
285
 
290
- setContext({ currentUser: asCurrentUser({ id: firstUser.id }) })
286
+ mockCurrentUser({ id: firstUser.id })
291
287
  await acceptInvitation({ token })
292
288
 
293
- setContext({ currentUser: asCurrentUser({ id: secondUser.id }) })
289
+ mockCurrentUser({ id: secondUser.id })
294
290
  await expect(acceptInvitation({ token })).rejects.toThrow()
295
291
  },
296
292
  )
@@ -308,7 +304,7 @@ describe('organizations service', () => {
308
304
 
309
305
  const token = scenario.membership.pending.invitationToken!
310
306
 
311
- setContext({ currentUser: asCurrentUser({ id: invitee.id }) })
307
+ mockCurrentUser({ id: invitee.id })
312
308
  await acceptInvitation({ token })
313
309
 
314
310
  const claimed = await db.membership.findUnique({
@@ -321,7 +317,7 @@ describe('organizations service', () => {
321
317
  )
322
318
 
323
319
  it('rejects a token that never matched a pending invitation', async () => {
324
- setContext({ currentUser: asCurrentUser({ id: 'some-user-id' }) })
320
+ mockCurrentUser({ id: 'some-user-id' })
325
321
 
326
322
  await expect(
327
323
  acceptInvitation({ token: 'not-a-real-token' }),
@@ -62,7 +62,7 @@ const canGrantRole = (granterRole: string, targetRole: string): boolean =>
62
62
  * organizations. `Membership` is a global model, so this reads plain `db`.
63
63
  */
64
64
  export const myMemberships: QueryResolvers['myMemberships'] = () => {
65
- // @requireAuth guarantees a signed-in user before this resolver runs.
65
+ // @requireUserAuth guarantees a signed-in user before this resolver runs.
66
66
  return db.membership.findMany({
67
67
  where: { userId: context.currentUser!.id },
68
68
  orderBy: { createdAt: 'asc' },
@@ -72,8 +72,8 @@ export const myMemberships: QueryResolvers['myMemberships'] = () => {
72
72
  /**
73
73
  * The current organization (the one `resolveCurrentOrg` resolved for this
74
74
  * request). `id` is required so callers state which organization they mean,
75
- * but it must match the current organization: `@requireMembership` only
76
- * checks that the caller belongs to *some* organization, not to `id`.
75
+ * but it must match the current organization: `@requirePermission` checks
76
+ * the caller's membership in the current organization, not in `id`.
77
77
  */
78
78
  export const organization: QueryResolvers['organization'] = async ({ id }) => {
79
79
  const currentOrg = requireCurrentOrg()
@@ -89,7 +89,7 @@ export const organization: QueryResolvers['organization'] = async ({ id }) => {
89
89
  export const createOrganization: MutationResolvers['createOrganization'] =
90
90
  async ({ input }) => {
91
91
  const baseSlug = slugify(input.name)
92
- // @requireAuth guarantees a signed-in user before this resolver runs.
92
+ // @requireUserAuth guarantees a signed-in user before this resolver runs.
93
93
  const userId = context.currentUser!.id
94
94
 
95
95
  let slug = baseSlug
@@ -135,7 +135,7 @@ export const createOrganization: MutationResolvers['createOrganization'] =
135
135
  * Creates a pending membership (an invitation) in the current organization,
136
136
  * for a role the caller's own role may grant per `ROLE_GRANTS` -- an
137
137
  * `admin` can invite an `admin` or a `member`, but not an `owner`, even
138
- * though `@requireMembership(roles: ["owner", "admin"])` lets an admin call
138
+ * though `@requirePermission(permission: "members.manage")` lets an admin call
139
139
  * this mutation at all. A role that isn't in `ROLE_GRANTS` is rejected
140
140
  * outright, so a typo in `input.role` can never create a membership nobody
141
141
  * can check against.
@@ -232,7 +232,7 @@ const claimInvitation = async (
232
232
  export const acceptInvitation: MutationResolvers['acceptInvitation'] = async ({
233
233
  token,
234
234
  }) => {
235
- // @requireAuth guarantees a signed-in user before this resolver runs.
235
+ // @requireUserAuth guarantees a signed-in user before this resolver runs.
236
236
  const userId = context.currentUser!.id
237
237
 
238
238
  const claim = await claimInvitation(token, userId)
@@ -0,0 +1,20 @@
1
+ import { permissions } from '__PERMISSIONS_PACKAGE__'
2
+
3
+ import { createPermissions } from '@cedarjs/tenancy'
4
+
5
+ /**
6
+ * Checks the permissions defined in __PERMISSIONS_PACKAGE__
7
+ * (packages/permissions). `getCurrentUser` adds each membership's
8
+ * permissions with `withPermissions`, so the web can check them with
9
+ * `useAuth().hasPermission()`. The web only decides what to show; these
10
+ * checks are the guard.
11
+ *
12
+ * Who may grant which role is `ROLE_GRANTS` in
13
+ * api/src/services/organizations/organizations.
14
+ */
15
+ export const {
16
+ hasPermission,
17
+ requirePermission,
18
+ permissionsOf,
19
+ withPermissions,
20
+ } = createPermissions(permissions)
@@ -0,0 +1,28 @@
1
+ import { definePermissions } from '@cedarjs/tenancy/permissions'
2
+ import type { PermissionName } from '@cedarjs/tenancy/permissions'
3
+
4
+ /**
5
+ * Every permission the app checks, and what each role may do. Fields ask
6
+ * for a permission (`@requirePermission(permission: "members.manage")`, or
7
+ * `requirePermission()` in a service) instead of listing roles, so a new
8
+ * role is one entry here: it can do exactly what its list says, and a
9
+ * field added later is closed to it unless its permission is on the list.
10
+ *
11
+ * `roles` are per organization: a membership's role decides what the user
12
+ * may do in that organization. Fields about the user rather than an
13
+ * organization (their own account, say) use `@requireUserAuth` instead.
14
+ *
15
+ * This package is shared: the api checks these permissions
16
+ * (api/src/lib/permissions), and the web can import the names. The web gets each
17
+ * membership's permissions from the api, through `getCurrentUser`.
18
+ */
19
+ export const permissions = definePermissions({
20
+ permissions: ['organization.read', 'members.manage'],
21
+ roles: {
22
+ owner: ['organization.read', 'members.manage'],
23
+ admin: ['organization.read', 'members.manage'],
24
+ member: ['organization.read'],
25
+ },
26
+ })
27
+
28
+ export type Permission = PermissionName<typeof permissions>
@@ -0,0 +1,56 @@
1
+ import { mockRedwoodDirective, getDirectiveName } from '@cedarjs/testing/api'
2
+
3
+ import requireOrgAuth from './requireOrgAuth.js'
4
+
5
+ const currentOrg = (role: string) => ({
6
+ id: 'org-1',
7
+ slug: 'org-1',
8
+ role,
9
+ membershipId: 'membership-1',
10
+ })
11
+
12
+ describe('requireOrgAuth directive', () => {
13
+ it('declares the directive sdl as schema, with the correct name', () => {
14
+ expect(requireOrgAuth.schema).toBeTruthy()
15
+ expect(getDirectiveName(requireOrgAuth.schema)).toBe('requireOrgAuth')
16
+ })
17
+
18
+ it('throws when there is no current user', () => {
19
+ const mockExecution = mockRedwoodDirective(requireOrgAuth, { context: {} })
20
+
21
+ expect(mockExecution).toThrowError("You don't have permission to do that.")
22
+ })
23
+
24
+ it('throws when there is no current organization', () => {
25
+ const mockExecution = mockRedwoodDirective(requireOrgAuth, {
26
+ context: { currentUser: { id: 'user-1' } },
27
+ })
28
+
29
+ expect(mockExecution).toThrowError(
30
+ 'You must be a member of an organization to do that.',
31
+ )
32
+ })
33
+
34
+ it('does not throw for a member of the current organization', () => {
35
+ const mockExecution = mockRedwoodDirective(requireOrgAuth, {
36
+ context: {
37
+ currentUser: { id: 'user-1' },
38
+ currentOrg: currentOrg('member'),
39
+ },
40
+ })
41
+
42
+ expect(mockExecution).not.toThrowError()
43
+ })
44
+
45
+ it("throws when the role in the current organization isn't listed", () => {
46
+ const mockExecution = mockRedwoodDirective(requireOrgAuth, {
47
+ context: {
48
+ currentUser: { id: 'user-1' },
49
+ currentOrg: currentOrg('member'),
50
+ },
51
+ directiveArgs: { roles: ['owner'] },
52
+ })
53
+
54
+ expect(mockExecution).toThrowError("You don't have access to do that.")
55
+ })
56
+ })
@@ -0,0 +1,25 @@
1
+ import { gql } from 'graphql-tag'
2
+
3
+ import type { ValidatorDirectiveFunc } from '@cedarjs/graphql-server'
4
+ import { createValidatorDirective } from '@cedarjs/graphql-server'
5
+
6
+ import { requireOrgAuth as applicationRequireOrgAuth } from 'src/lib/auth'
7
+
8
+ export const schema = gql`
9
+ """
10
+ Use to check that the signed-in user is a member of the current
11
+ organization, with one of an optional set of roles there.
12
+ """
13
+ directive @requireOrgAuth(roles: [String]) on FIELD_DEFINITION
14
+ `
15
+
16
+ type RequireOrgAuthValidate = ValidatorDirectiveFunc<{ roles?: string[] }>
17
+
18
+ const validate: RequireOrgAuthValidate = ({ directiveArgs }) => {
19
+ const { roles } = directiveArgs
20
+ applicationRequireOrgAuth({ roles })
21
+ }
22
+
23
+ const requireOrgAuth = createValidatorDirective(schema, validate)
24
+
25
+ export default requireOrgAuth