@cedarjs/cli 8.0.0-canary.3542 → 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 +9 -14
- 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 +25 -25
- package/package.json +13 -13
- package/dist/commands/setup/tenancy/templates/OrgScope.tsx.template +0 -88
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,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import j from "jscodeshift";
|
|
2
|
-
const
|
|
2
|
+
const TENANCY_MODULE = "src/tenancy";
|
|
3
3
|
function transform(fileInfo) {
|
|
4
4
|
const root = j.withParser("tsx")(fileInfo.source);
|
|
5
5
|
const routers = root.find(j.JSXOpeningElement, { name: { name: "Router" } });
|
|
@@ -12,9 +12,9 @@ function transform(fileInfo) {
|
|
|
12
12
|
}
|
|
13
13
|
const attributes = router.attributes ?? [];
|
|
14
14
|
const hasProp = attributes.some(
|
|
15
|
-
(attribute) => j.JSXAttribute.check(attribute) && attribute.name.name === "
|
|
15
|
+
(attribute) => j.JSXAttribute.check(attribute) && attribute.name.name === "tenancy"
|
|
16
16
|
);
|
|
17
|
-
const hasImport = root.find(j.ImportSpecifier, { local: { name: "
|
|
17
|
+
const hasImport = root.find(j.ImportSpecifier, { local: { name: "tenancy" } }).length > 0;
|
|
18
18
|
if (hasProp && hasImport) {
|
|
19
19
|
return fileInfo.source;
|
|
20
20
|
}
|
|
@@ -23,8 +23,8 @@ function transform(fileInfo) {
|
|
|
23
23
|
(attribute) => j.JSXAttribute.check(attribute) && attribute.name.name === "useAuth"
|
|
24
24
|
);
|
|
25
25
|
const prop = j.jsxAttribute(
|
|
26
|
-
j.jsxIdentifier("
|
|
27
|
-
j.jsxExpressionContainer(j.identifier("
|
|
26
|
+
j.jsxIdentifier("tenancy"),
|
|
27
|
+
j.jsxExpressionContainer(j.identifier("tenancy"))
|
|
28
28
|
);
|
|
29
29
|
attributes.splice(
|
|
30
30
|
useAuthIndex === -1 ? attributes.length : useAuthIndex + 1,
|
|
@@ -34,17 +34,12 @@ function transform(fileInfo) {
|
|
|
34
34
|
router.attributes = attributes;
|
|
35
35
|
}
|
|
36
36
|
if (!hasImport) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
root.find(j.ImportDeclaration).at(-1).insertAfter(
|
|
38
|
+
j.importDeclaration(
|
|
39
|
+
[j.importSpecifier(j.identifier("tenancy"))],
|
|
40
|
+
j.literal(TENANCY_MODULE)
|
|
41
41
|
)
|
|
42
42
|
);
|
|
43
|
-
if (tenancyImport?.specifiers) {
|
|
44
|
-
tenancyImport.specifiers.push(specifier);
|
|
45
|
-
} else {
|
|
46
|
-
root.find(j.ImportDeclaration).at(-1).insertAfter(j.importDeclaration([specifier], j.literal(TENANCY_WEB)));
|
|
47
|
-
}
|
|
48
43
|
}
|
|
49
44
|
return root.toSource();
|
|
50
45
|
}
|
|
@@ -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",
|
|
@@ -455,11 +451,11 @@ ${SIGNUP_SNIPPET}`;
|
|
|
455
451
|
}
|
|
456
452
|
},
|
|
457
453
|
{
|
|
458
|
-
title: "Passing
|
|
454
|
+
title: "Passing the tenancy to the Router in web/src/Routes...",
|
|
459
455
|
task: async (ctx, task) => {
|
|
460
456
|
const routesPath = paths.web.routes;
|
|
461
457
|
if (!routesPath || !fs.existsSync(routesPath)) {
|
|
462
|
-
ctx.
|
|
458
|
+
ctx.routerNeedsTenancy = true;
|
|
463
459
|
task.skip("No web/src/Routes file found; skipping.");
|
|
464
460
|
return;
|
|
465
461
|
}
|
|
@@ -469,7 +465,7 @@ ${SIGNUP_SNIPPET}`;
|
|
|
469
465
|
targetPaths: [routesPath]
|
|
470
466
|
});
|
|
471
467
|
if (transformResult.error) {
|
|
472
|
-
ctx.
|
|
468
|
+
ctx.routerNeedsTenancy = true;
|
|
473
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.";
|
|
474
470
|
} else if (fs.readFileSync(routesPath, "utf-8") !== source) {
|
|
475
471
|
writtenFiles.push(routesPath);
|
|
@@ -542,24 +538,28 @@ ${SIGNUP_SNIPPET}`;
|
|
|
542
538
|
schema.prisma, and switch its fields from ${c.highlight("@requireUserAuth")}
|
|
543
539
|
to ${c.highlight("@requireOrgAuth")}. For each one that stays global, add it
|
|
544
540
|
to the ${c.highlight("allExcept")} list in api/src/lib/db instead.
|
|
545
|
-
3.
|
|
546
|
-
|
|
547
|
-
|
|
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:
|
|
548
545
|
|
|
549
|
-
<PrivateSet wrap={
|
|
546
|
+
<PrivateSet wrap={OrgLayout} unauthenticated="login">
|
|
550
547
|
<Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
|
|
551
548
|
</PrivateSet>
|
|
552
549
|
|
|
553
550
|
and add the invitation route outside it:
|
|
554
551
|
|
|
555
552
|
<Route path="/invite/{token}" page={InvitePage} name="invite" />
|
|
556
|
-
${ctx.routerNeedsUseOrgRoles ? `
|
|
557
|
-
Your ${c.highlight("<Router>")} couldn't be found, so pass it ${c.highlight("useOrgRoles")} by
|
|
558
|
-
hand, for ${c.highlight("<PrivateSet orgRoles>")} below:
|
|
559
553
|
|
|
560
|
-
|
|
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:
|
|
561
559
|
|
|
562
|
-
|
|
560
|
+
import { tenancy } from 'src/tenancy'
|
|
561
|
+
|
|
562
|
+
<Router useAuth={useAuth} tenancy={tenancy}>
|
|
563
563
|
` : ""}
|
|
564
564
|
4. Wrap any plain api/src/functions/* handler that touches
|
|
565
565
|
tenant-owned models with ${c.highlight("withTenancy")} from ${c.highlight(
|
|
@@ -585,13 +585,13 @@ ${ctx.routerNeedsUseOrgRoles ? `
|
|
|
585
585
|
the organization. ${c.highlight("<PrivateSet roles>")} isn't available with
|
|
586
586
|
tenancy: guard routes with ${c.highlight("<PrivateSet userRoles>")} for
|
|
587
587
|
${c.highlight("currentUser.roles")}, and with ${c.highlight("<PrivateSet orgRoles>")} for the
|
|
588
|
-
role in the route's organization
|
|
589
|
-
|
|
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:
|
|
590
591
|
|
|
591
|
-
<
|
|
592
|
-
<
|
|
593
|
-
|
|
594
|
-
</PrivateSet>
|
|
592
|
+
<PrivateSet unauthenticated="login" unauthorized="projects" orgRoles="owner">
|
|
593
|
+
<Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
|
|
594
|
+
</PrivateSet>
|
|
595
595
|
|
|
596
596
|
${keptFiles.length > 0 ? `
|
|
597
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,88 +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
|
-
* `PrivateSet wrap`, so a user who isn't signed in is sent to log in:
|
|
31
|
-
*
|
|
32
|
-
* <PrivateSet wrap={OrgScope} unauthenticated="login">
|
|
33
|
-
* <Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
|
|
34
|
-
* </PrivateSet>
|
|
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
|
-
* roles in the organization may open, give the `PrivateSet` `orgRoles`. The
|
|
40
|
-
* router checks the role in the route's organization with the
|
|
41
|
-
* `useOrgRoles` passed to it, `<Router useAuth={useAuth}
|
|
42
|
-
* useOrgRoles={useOrgRoles}>`:
|
|
43
|
-
*
|
|
44
|
-
* <PrivateSet wrap={[OrgScope, OrgLayout]} unauthenticated="login" unauthorized="projects" orgRoles="owner">
|
|
45
|
-
* <Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
|
|
46
|
-
* </PrivateSet>
|
|
47
|
-
*
|
|
48
|
-
* For pages only members with a permission may open, wrap them in a
|
|
49
|
-
* component that passes `permission`:
|
|
50
|
-
*
|
|
51
|
-
* const MemberManagers = ({ children }: { children: ReactNode }) => (
|
|
52
|
-
* <OrgScope permission="members.manage" unauthorized="projects">
|
|
53
|
-
* {children}
|
|
54
|
-
* </OrgScope>
|
|
55
|
-
* )
|
|
56
|
-
*
|
|
57
|
-
* <Set wrap={MemberManagers}>
|
|
58
|
-
* <Route path="/org/{orgSlug}/members" page={MembersPage} name="members" />
|
|
59
|
-
* </Set>
|
|
60
|
-
*
|
|
61
|
-
* Routes under `OrgScope` must not carry `prerender`: `OrgScope`'s nested
|
|
62
|
-
* Apollo client shadows the per-route client the prerenderer injects above
|
|
63
|
-
* `App`, so a prerendered query under here would not go through it.
|
|
64
|
-
*/
|
|
65
|
-
export const OrgScope = ({
|
|
66
|
-
orgSlug,
|
|
67
|
-
roles,
|
|
68
|
-
permission,
|
|
69
|
-
unauthorized,
|
|
70
|
-
children,
|
|
71
|
-
}: OrgScopeProps) => {
|
|
72
|
-
return (
|
|
73
|
-
<TenancyOrgScope
|
|
74
|
-
useAuth={useAuth}
|
|
75
|
-
orgSlug={orgSlug}
|
|
76
|
-
roles={roles}
|
|
77
|
-
permission={permission}
|
|
78
|
-
unauthorized={unauthorized}
|
|
79
|
-
notAMember={
|
|
80
|
-
<div>
|
|
81
|
-
<p>You don't have access to this organization.</p>
|
|
82
|
-
</div>
|
|
83
|
-
}
|
|
84
|
-
>
|
|
85
|
-
{children}
|
|
86
|
-
</TenancyOrgScope>
|
|
87
|
-
)
|
|
88
|
-
}
|