@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.
- package/dist/commands/generate/realtime/realtimeHandler.js +13 -5
- package/dist/commands/generate/sdl/sdlHandler.js +10 -2
- package/dist/commands/setup/realtime/realtimeHandler.js +43 -18
- package/dist/commands/setup/tenancy/authCodemod.js +181 -16
- package/dist/commands/setup/tenancy/authDirectives.js +101 -0
- package/dist/commands/setup/tenancy/permissionsPackage.js +240 -0
- package/dist/commands/setup/tenancy/templates/OrgScope.tsx.template +40 -1
- package/dist/commands/setup/tenancy/templates/organizations.sdl.ts.template +8 -6
- package/dist/commands/setup/tenancy/templates/organizations.test.ts.template +24 -28
- package/dist/commands/setup/tenancy/templates/organizations.ts.template +6 -6
- package/dist/commands/setup/tenancy/templates/permissions.ts.template +20 -0
- package/dist/commands/setup/tenancy/templates/permissionsPackage.index.ts.template +28 -0
- package/dist/commands/setup/tenancy/templates/requireOrgAuth.test.ts.template +56 -0
- package/dist/commands/setup/tenancy/templates/requireOrgAuth.ts.template +25 -0
- package/dist/commands/setup/tenancy/templates/requirePermission.test.ts.template +50 -0
- package/dist/commands/setup/tenancy/templates/requirePermission.ts.template +32 -0
- package/dist/commands/setup/tenancy/templates/requireUserAuth.test.ts.template +26 -0
- package/dist/commands/setup/tenancy/templates/requireUserAuth.ts.template +26 -0
- package/dist/commands/setup/tenancy/tenancyHandler.js +127 -13
- package/dist/commands/setup/tenancy/webHasRole.js +45 -0
- package/dist/commands/setup/uploads/uploadsHandler.js +4 -0
- package/dist/lib/authDirective.js +37 -0
- package/package.json +13 -13
- package/dist/commands/setup/tenancy/templates/requireMembership.test.ts.template +0 -35
- 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 = ({
|
|
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'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!]! @
|
|
46
|
+
myMemberships: [Membership!]! @requireUserAuth
|
|
47
47
|
|
|
48
48
|
"The current organization, resolved by resolveCurrentOrg for this request."
|
|
49
|
-
organization(id: String!): Organization
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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!
|
|
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
|
-
@
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
*
|
|
30
|
-
* `context` function does for an
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
+
mockCurrentUser({ id: firstUser.id })
|
|
291
287
|
await acceptInvitation({ token })
|
|
292
288
|
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// @
|
|
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: `@
|
|
76
|
-
*
|
|
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
|
-
// @
|
|
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 `@
|
|
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
|
-
// @
|
|
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
|