@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.
@@ -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
- * `OrgScope`, since the visitor is not a member of the organization yet.
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&apos;t have access to this organization.</p>,
42
+ })
@@ -434,15 +434,11 @@ ${SIGNUP_SNIPPET}`;
434
434
  }
435
435
  },
436
436
  {
437
- title: "Adding OrgScope and the invitation landing page...",
437
+ title: "Adding the tenancy and the invitation landing page...",
438
438
  task: async () => {
439
439
  await writeTemplateFile(
440
- "OrgScope.tsx.template",
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. Wrap your organization routes with the generated OrgScope in
524
- web/src/Routes.tsx:
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
- <Set wrap={OrgScope}>
546
+ <PrivateSet wrap={OrgLayout} unauthenticated="login">
527
547
  <Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
528
- </Set>
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
- 6. Every role check names its kind now. ${c.highlight("hasRole")} is
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 by role with ${c.highlight("OrgScope")}'s ${c.highlight("roles")} and
557
- ${c.highlight("unauthorized")}, from a small wrapper component.
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.3541",
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.3541",
40
- "@cedarjs/babel-config": "8.0.0-canary.3541",
41
- "@cedarjs/cli-helpers": "8.0.0-canary.3541",
42
- "@cedarjs/internal": "8.0.0-canary.3541",
43
- "@cedarjs/prerender": "8.0.0-canary.3541",
44
- "@cedarjs/project-config": "8.0.0-canary.3541",
45
- "@cedarjs/structure": "8.0.0-canary.3541",
46
- "@cedarjs/telemetry": "8.0.0-canary.3541",
47
- "@cedarjs/utils": "8.0.0-canary.3541",
48
- "@cedarjs/vite": "8.0.0-canary.3541",
49
- "@cedarjs/web-server": "8.0.0-canary.3541",
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.3541",
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&apos;t have access to this organization.</p>
73
- </div>
74
- }
75
- >
76
- {children}
77
- </TenancyOrgScope>
78
- )
79
- }