@cedarjs/cli 8.0.0-canary.3541 → 8.0.0-canary.3545
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/lint.js +25 -0
- package/dist/commands/setup/tenancy/routerCodemod.js +48 -0
- package/dist/commands/setup/tenancy/templates/InvitePage.tsx.template +3 -2
- package/dist/commands/setup/tenancy/templates/tenancy.tsx.template +42 -0
- package/dist/commands/setup/tenancy/tenancyHandler.js +51 -14
- package/package.json +13 -13
- package/dist/commands/setup/tenancy/templates/OrgScope.tsx.template +0 -79
package/dist/commands/lint.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { terminalLink } from "termi-link";
|
|
3
4
|
import { runBin } from "@cedarjs/cli-helpers/packageManager/exec";
|
|
4
5
|
import { recordTelemetryAttributes } from "@cedarjs/cli-helpers/telemetry";
|
|
@@ -32,6 +33,7 @@ const handler = async ({
|
|
|
32
33
|
}) => {
|
|
33
34
|
recordTelemetryAttributes({ command: "lint", fix, format });
|
|
34
35
|
try {
|
|
36
|
+
await generateMissingGraphQLSchema();
|
|
35
37
|
const sbPath = getPaths().web.storybook;
|
|
36
38
|
const eslintArgs = [
|
|
37
39
|
fix && "--fix",
|
|
@@ -64,6 +66,29 @@ const handler = async ({
|
|
|
64
66
|
process.exitCode = 1;
|
|
65
67
|
}
|
|
66
68
|
};
|
|
69
|
+
async function generateMissingGraphQLSchema() {
|
|
70
|
+
const paths = getPaths();
|
|
71
|
+
if (fs.existsSync(paths.generated.schema) || !fs.existsSync(paths.api.graphql) || !fs.existsSync(paths.web.src)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let messages;
|
|
75
|
+
try {
|
|
76
|
+
const { generateGraphQLSchema } = await import("@cedarjs/internal/dist/generate/graphqlSchema");
|
|
77
|
+
fs.mkdirSync(path.dirname(paths.generated.schema), { recursive: true });
|
|
78
|
+
const { errors } = await generateGraphQLSchema();
|
|
79
|
+
messages = errors.map(({ message }) => message);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
messages = [error instanceof Error ? error.message : String(error)];
|
|
82
|
+
}
|
|
83
|
+
if (messages.length > 0) {
|
|
84
|
+
console.warn(
|
|
85
|
+
"Could not generate the GraphQL schema, so the GraphQL documents in web/src are not linted against it:"
|
|
86
|
+
);
|
|
87
|
+
for (const message of messages) {
|
|
88
|
+
console.warn(message);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
67
92
|
export {
|
|
68
93
|
builder,
|
|
69
94
|
command,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import j from "jscodeshift";
|
|
2
|
+
const TENANCY_MODULE = "src/tenancy";
|
|
3
|
+
function transform(fileInfo) {
|
|
4
|
+
const root = j.withParser("tsx")(fileInfo.source);
|
|
5
|
+
const routers = root.find(j.JSXOpeningElement, { name: { name: "Router" } });
|
|
6
|
+
if (routers.length !== 1) {
|
|
7
|
+
throw new Error("CEDAR_CODEMOD_ERR_ROUTER_NOT_FOUND");
|
|
8
|
+
}
|
|
9
|
+
const router = routers.get().node;
|
|
10
|
+
if (!j.JSXOpeningElement.check(router)) {
|
|
11
|
+
throw new Error("CEDAR_CODEMOD_ERR_ROUTER_NOT_FOUND");
|
|
12
|
+
}
|
|
13
|
+
const attributes = router.attributes ?? [];
|
|
14
|
+
const hasProp = attributes.some(
|
|
15
|
+
(attribute) => j.JSXAttribute.check(attribute) && attribute.name.name === "tenancy"
|
|
16
|
+
);
|
|
17
|
+
const hasImport = root.find(j.ImportSpecifier, { local: { name: "tenancy" } }).length > 0;
|
|
18
|
+
if (hasProp && hasImport) {
|
|
19
|
+
return fileInfo.source;
|
|
20
|
+
}
|
|
21
|
+
if (!hasProp) {
|
|
22
|
+
const useAuthIndex = attributes.findIndex(
|
|
23
|
+
(attribute) => j.JSXAttribute.check(attribute) && attribute.name.name === "useAuth"
|
|
24
|
+
);
|
|
25
|
+
const prop = j.jsxAttribute(
|
|
26
|
+
j.jsxIdentifier("tenancy"),
|
|
27
|
+
j.jsxExpressionContainer(j.identifier("tenancy"))
|
|
28
|
+
);
|
|
29
|
+
attributes.splice(
|
|
30
|
+
useAuthIndex === -1 ? attributes.length : useAuthIndex + 1,
|
|
31
|
+
0,
|
|
32
|
+
prop
|
|
33
|
+
);
|
|
34
|
+
router.attributes = attributes;
|
|
35
|
+
}
|
|
36
|
+
if (!hasImport) {
|
|
37
|
+
root.find(j.ImportDeclaration).at(-1).insertAfter(
|
|
38
|
+
j.importDeclaration(
|
|
39
|
+
[j.importSpecifier(j.identifier("tenancy"))],
|
|
40
|
+
j.literal(TENANCY_MODULE)
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return root.toSource();
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
transform as default
|
|
48
|
+
};
|
|
@@ -25,8 +25,9 @@ const ACCEPT_INVITATION_MUTATION: TypedDocumentNode<
|
|
|
25
25
|
`
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* Landing page for an invitation link (`/invite/{token}`). Outside
|
|
29
|
-
* `
|
|
28
|
+
* Landing page for an invitation link (`/invite/{token}`). Outside any
|
|
29
|
+
* `PrivateSet` with `{orgSlug}` routes, since the visitor is not a member of
|
|
30
|
+
* the organization yet.
|
|
30
31
|
*/
|
|
31
32
|
const InvitePage = () => {
|
|
32
33
|
const { token } = useParams()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createTenancy } from '@cedarjs/tenancy/web'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How the Router handles organizations, passed to it in Routes.tsx:
|
|
5
|
+
*
|
|
6
|
+
* <Router useAuth={useAuth} tenancy={tenancy}>
|
|
7
|
+
*
|
|
8
|
+
* The Router renders the organization's provider (its Apollo client with the
|
|
9
|
+
* `cedar-org` header, `useCurrentOrg()`, and `useAuth().hasPermission`)
|
|
10
|
+
* around the layouts and page of every `PrivateSet` whose routes have
|
|
11
|
+
* `{orgSlug}` in their path, so there is nothing to wrap them with:
|
|
12
|
+
*
|
|
13
|
+
* <PrivateSet wrap={OrgLayout} unauthenticated="login">
|
|
14
|
+
* <Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
|
|
15
|
+
* </PrivateSet>
|
|
16
|
+
*
|
|
17
|
+
* A user who isn't signed in is sent to log in, and one who isn't a member
|
|
18
|
+
* of the organization sees `notAMember`. Pages only some members may open
|
|
19
|
+
* take `orgRoles` or `orgPermission` (see api/src/lib/permissions), and the
|
|
20
|
+
* one a member without them is sent to is `unauthorized`:
|
|
21
|
+
*
|
|
22
|
+
* <PrivateSet unauthenticated="login" unauthorized="projects" orgRoles="owner">
|
|
23
|
+
* <Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
|
|
24
|
+
* </PrivateSet>
|
|
25
|
+
*
|
|
26
|
+
* <PrivateSet unauthenticated="login" unauthorized="projects" orgPermission="members.manage">
|
|
27
|
+
* <Route path="/org/{orgSlug}/members" page={MembersPage} name="members" />
|
|
28
|
+
* </PrivateSet>
|
|
29
|
+
*
|
|
30
|
+
* Routes with `{orgSlug}` outside a `PrivateSet` are public pages with no
|
|
31
|
+
* organization context: they can't use `useCurrentOrg()` and their queries
|
|
32
|
+
* carry no `cedar-org` header.
|
|
33
|
+
*
|
|
34
|
+
* Customize the tenancy here, with the options of `createTenancy`:
|
|
35
|
+
* - `useOrg` finds the organization of a route, for one that doesn't come
|
|
36
|
+
* from the `orgSlug` path param, such as from the host
|
|
37
|
+
* - `onSetOrg` is called by `useCurrentOrg().setOrg` instead of navigating,
|
|
38
|
+
* for an organization selected from state rather than the URL
|
|
39
|
+
*/
|
|
40
|
+
export const tenancy = createTenancy({
|
|
41
|
+
notAMember: <p>You don't have access to this organization.</p>,
|
|
42
|
+
})
|
|
@@ -434,15 +434,11 @@ ${SIGNUP_SNIPPET}`;
|
|
|
434
434
|
}
|
|
435
435
|
},
|
|
436
436
|
{
|
|
437
|
-
title: "Adding
|
|
437
|
+
title: "Adding the tenancy and the invitation landing page...",
|
|
438
438
|
task: async () => {
|
|
439
439
|
await writeTemplateFile(
|
|
440
|
-
"
|
|
441
|
-
path.join(
|
|
442
|
-
paths.web.components,
|
|
443
|
-
"OrgScope",
|
|
444
|
-
`OrgScope.${componentExt}`
|
|
445
|
-
)
|
|
440
|
+
"tenancy.tsx.template",
|
|
441
|
+
path.join(paths.web.src, `tenancy.${componentExt}`)
|
|
446
442
|
);
|
|
447
443
|
await writeTemplateFile(
|
|
448
444
|
"InvitePage.tsx.template",
|
|
@@ -454,6 +450,28 @@ ${SIGNUP_SNIPPET}`;
|
|
|
454
450
|
);
|
|
455
451
|
}
|
|
456
452
|
},
|
|
453
|
+
{
|
|
454
|
+
title: "Passing the tenancy to the Router in web/src/Routes...",
|
|
455
|
+
task: async (ctx, task) => {
|
|
456
|
+
const routesPath = paths.web.routes;
|
|
457
|
+
if (!routesPath || !fs.existsSync(routesPath)) {
|
|
458
|
+
ctx.routerNeedsTenancy = true;
|
|
459
|
+
task.skip("No web/src/Routes file found; skipping.");
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const source = fs.readFileSync(routesPath, "utf-8");
|
|
463
|
+
const transformResult = await runTransform({
|
|
464
|
+
transformPath: path.join(import.meta.dirname, "routerCodemod.js"),
|
|
465
|
+
targetPaths: [routesPath]
|
|
466
|
+
});
|
|
467
|
+
if (transformResult.error) {
|
|
468
|
+
ctx.routerNeedsTenancy = true;
|
|
469
|
+
task.output = "Could not find the app's <Router> in web/src/Routes, so left it as it is. See the next steps for what to add.";
|
|
470
|
+
} else if (fs.readFileSync(routesPath, "utf-8") !== source) {
|
|
471
|
+
writtenFiles.push(routesPath);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
},
|
|
457
475
|
{
|
|
458
476
|
title: "Formatting and linting changed files...",
|
|
459
477
|
task: async (_ctx, task) => {
|
|
@@ -520,17 +538,29 @@ ${SIGNUP_SNIPPET}`;
|
|
|
520
538
|
schema.prisma, and switch its fields from ${c.highlight("@requireUserAuth")}
|
|
521
539
|
to ${c.highlight("@requireOrgAuth")}. For each one that stays global, add it
|
|
522
540
|
to the ${c.highlight("allExcept")} list in api/src/lib/db instead.
|
|
523
|
-
3.
|
|
524
|
-
|
|
541
|
+
3. Put your organization routes in a PrivateSet in web/src/Routes.tsx.
|
|
542
|
+
The Router renders the organization for it, using the generated
|
|
543
|
+
web/src/tenancy, so there is nothing to wrap them with. A user
|
|
544
|
+
who isn't signed in is sent to log in:
|
|
525
545
|
|
|
526
|
-
<
|
|
546
|
+
<PrivateSet wrap={OrgLayout} unauthenticated="login">
|
|
527
547
|
<Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
|
|
528
|
-
</
|
|
548
|
+
</PrivateSet>
|
|
529
549
|
|
|
530
550
|
and add the invitation route outside it:
|
|
531
551
|
|
|
532
552
|
<Route path="/invite/{token}" page={InvitePage} name="invite" />
|
|
533
553
|
|
|
554
|
+
Routes with {orgSlug} outside a PrivateSet are public pages
|
|
555
|
+
with no organization context.
|
|
556
|
+
${ctx.routerNeedsTenancy ? `
|
|
557
|
+
Your ${c.highlight("<Router>")} couldn't be found, so pass it ${c.highlight("tenancy")} by
|
|
558
|
+
hand, so it renders the organization for the routes below:
|
|
559
|
+
|
|
560
|
+
import { tenancy } from 'src/tenancy'
|
|
561
|
+
|
|
562
|
+
<Router useAuth={useAuth} tenancy={tenancy}>
|
|
563
|
+
` : ""}
|
|
534
564
|
4. Wrap any plain api/src/functions/* handler that touches
|
|
535
565
|
tenant-owned models with ${c.highlight("withTenancy")} from ${c.highlight(
|
|
536
566
|
"@cedarjs/tenancy"
|
|
@@ -549,12 +579,19 @@ ${SIGNUP_SNIPPET}`;
|
|
|
549
579
|
it and your @requireAuth fields were left as they are: move
|
|
550
580
|
its checks to requireUserAuth or requireOrgAuth, switch the
|
|
551
581
|
fields over, then delete it.` : ""}
|
|
552
|
-
|
|
582
|
+
7. Every role check names its kind now. ${c.highlight("hasRole")} is
|
|
553
583
|
${c.highlight("hasUserRole")} on both sides (your existing uses were renamed),
|
|
554
584
|
checking ${c.highlight("currentUser.roles")}; ${c.highlight("hasOrgRole")} checks the role in
|
|
555
585
|
the organization. ${c.highlight("<PrivateSet roles>")} isn't available with
|
|
556
|
-
tenancy: guard routes
|
|
557
|
-
${c.highlight("
|
|
586
|
+
tenancy: guard routes with ${c.highlight("<PrivateSet userRoles>")} for
|
|
587
|
+
${c.highlight("currentUser.roles")}, and with ${c.highlight("<PrivateSet orgRoles>")} for the
|
|
588
|
+
role in the route's organization. A member who lacks it is sent
|
|
589
|
+
to ${c.highlight("unauthorized")}, and ${c.highlight("<PrivateSet orgPermission>")} checks a
|
|
590
|
+
permission of their membership the same way:
|
|
591
|
+
|
|
592
|
+
<PrivateSet unauthenticated="login" unauthorized="projects" orgRoles="owner">
|
|
593
|
+
<Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
|
|
594
|
+
</PrivateSet>
|
|
558
595
|
|
|
559
596
|
${keptFiles.length > 0 ? `
|
|
560
597
|
Kept these existing files rather than overwriting them (run with
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cedarjs/cli",
|
|
3
|
-
"version": "8.0.0-canary.
|
|
3
|
+
"version": "8.0.0-canary.3545",
|
|
4
4
|
"description": "The CedarJS Command Line",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -36,17 +36,17 @@
|
|
|
36
36
|
"@babel/preset-typescript": "7.29.7",
|
|
37
37
|
"@babel/traverse": "7.29.10",
|
|
38
38
|
"@babel/types": "7.29.11",
|
|
39
|
-
"@cedarjs/api-server": "8.0.0-canary.
|
|
40
|
-
"@cedarjs/babel-config": "8.0.0-canary.
|
|
41
|
-
"@cedarjs/cli-helpers": "8.0.0-canary.
|
|
42
|
-
"@cedarjs/internal": "8.0.0-canary.
|
|
43
|
-
"@cedarjs/prerender": "8.0.0-canary.
|
|
44
|
-
"@cedarjs/project-config": "8.0.0-canary.
|
|
45
|
-
"@cedarjs/structure": "8.0.0-canary.
|
|
46
|
-
"@cedarjs/telemetry": "8.0.0-canary.
|
|
47
|
-
"@cedarjs/utils": "8.0.0-canary.
|
|
48
|
-
"@cedarjs/vite": "8.0.0-canary.
|
|
49
|
-
"@cedarjs/web-server": "8.0.0-canary.
|
|
39
|
+
"@cedarjs/api-server": "8.0.0-canary.3545",
|
|
40
|
+
"@cedarjs/babel-config": "8.0.0-canary.3545",
|
|
41
|
+
"@cedarjs/cli-helpers": "8.0.0-canary.3545",
|
|
42
|
+
"@cedarjs/internal": "8.0.0-canary.3545",
|
|
43
|
+
"@cedarjs/prerender": "8.0.0-canary.3545",
|
|
44
|
+
"@cedarjs/project-config": "8.0.0-canary.3545",
|
|
45
|
+
"@cedarjs/structure": "8.0.0-canary.3545",
|
|
46
|
+
"@cedarjs/telemetry": "8.0.0-canary.3545",
|
|
47
|
+
"@cedarjs/utils": "8.0.0-canary.3545",
|
|
48
|
+
"@cedarjs/vite": "8.0.0-canary.3545",
|
|
49
|
+
"@cedarjs/web-server": "8.0.0-canary.3545",
|
|
50
50
|
"@listr2/prompt-adapter-enquirer": "4.4.3",
|
|
51
51
|
"@opentelemetry/api": "1.9.1",
|
|
52
52
|
"@opentelemetry/core": "2.12.0",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"yargs": "18.2.0"
|
|
94
94
|
},
|
|
95
95
|
"devDependencies": {
|
|
96
|
-
"@cedarjs/framework-tools": "8.0.0-canary.
|
|
96
|
+
"@cedarjs/framework-tools": "8.0.0-canary.3545",
|
|
97
97
|
"@prisma/dmmf": "7.10.1",
|
|
98
98
|
"@types/archiver": "^7.0.0",
|
|
99
99
|
"memfs": "4.80.0",
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from 'react'
|
|
2
|
-
|
|
3
|
-
import { OrgScope as TenancyOrgScope } from '@cedarjs/tenancy/web'
|
|
4
|
-
import type { OrgScopeProps as TenancyOrgScopeProps } from '@cedarjs/tenancy/web'
|
|
5
|
-
|
|
6
|
-
import { useAuth } from 'src/auth'
|
|
7
|
-
|
|
8
|
-
export interface OrgScopeProps {
|
|
9
|
-
/** Overrides the `orgSlug` route param. */
|
|
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
|
|
24
|
-
children: ReactNode
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Wraps organization routes with a per-organization Apollo client and a
|
|
29
|
-
* membership check, using this app's `useAuth`. Use with the router's
|
|
30
|
-
* `Set wrap`:
|
|
31
|
-
*
|
|
32
|
-
* <Set wrap={OrgScope}>
|
|
33
|
-
* <Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
|
|
34
|
-
* </Set>
|
|
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
|
-
*
|
|
52
|
-
* Routes under `OrgScope` must not carry `prerender`: `OrgScope`'s nested
|
|
53
|
-
* Apollo client shadows the per-route client the prerenderer injects above
|
|
54
|
-
* `App`, so a prerendered query under here would not go through it.
|
|
55
|
-
*/
|
|
56
|
-
export const OrgScope = ({
|
|
57
|
-
orgSlug,
|
|
58
|
-
roles,
|
|
59
|
-
permission,
|
|
60
|
-
unauthorized,
|
|
61
|
-
children,
|
|
62
|
-
}: OrgScopeProps) => {
|
|
63
|
-
return (
|
|
64
|
-
<TenancyOrgScope
|
|
65
|
-
useAuth={useAuth}
|
|
66
|
-
orgSlug={orgSlug}
|
|
67
|
-
roles={roles}
|
|
68
|
-
permission={permission}
|
|
69
|
-
unauthorized={unauthorized}
|
|
70
|
-
notAMember={
|
|
71
|
-
<div>
|
|
72
|
-
<p>You don't have access to this organization.</p>
|
|
73
|
-
</div>
|
|
74
|
-
}
|
|
75
|
-
>
|
|
76
|
-
{children}
|
|
77
|
-
</TenancyOrgScope>
|
|
78
|
-
)
|
|
79
|
-
}
|