@cedarjs/tenancy 8.0.0-canary.3541 → 8.0.0-canary.3542

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/README.md CHANGED
@@ -135,12 +135,29 @@ rather than an organization use `@requireUserAuth`.
135
135
 
136
136
  ## Web
137
137
 
138
- `@cedarjs/tenancy/web` exports `OrgScope`, `useCurrentOrg` and
138
+ `@cedarjs/tenancy/web` exports `OrgScope`, `useCurrentOrg`, `useOrgRoles` and
139
139
  `getMemberships`/`hasOrgRole` for the client side: a per-organization Apollo
140
140
  client provided under organization routes, keyed by the immutable organization
141
141
  id so switching organizations never mixes caches. `useCurrentOrg().hasOrgRole`
142
- checks the user's role in the organization, and `OrgScope`'s `roles` and
143
- `unauthorized` props guard routes by it. `useAuth().hasUserRole` checks
144
- `currentUser.roles`; there's no `useAuth().hasRole` or `<PrivateSet roles>` in
145
- an app with tenancy. See the how-to for the full
146
- `<Set wrap={OrgScope}>` setup `yarn cedar setup tenancy` generates.
142
+ checks the user's role in the organization. To guard routes by it, pass
143
+ `useOrgRoles` to the router and give a `PrivateSet` `orgRoles` and
144
+ `unauthorized`:
145
+
146
+ <!-- prettier-ignore -->
147
+ ```tsx
148
+ <Router useAuth={useAuth} useOrgRoles={useOrgRoles}>
149
+ <PrivateSet wrap={[OrgScope, OrgLayout]} unauthenticated="login">
150
+ <Route path="/org/{orgSlug}/projects" page={ProjectsPage} name="projects" />
151
+ </PrivateSet>
152
+ <PrivateSet wrap={[OrgScope, OrgLayout]} unauthenticated="login" unauthorized="projects" orgRoles="owner">
153
+ <Route path="/org/{orgSlug}/settings" page={SettingsPage} name="settings" />
154
+ </PrivateSet>
155
+ </Router>
156
+ ```
157
+
158
+ The router checks the role in the organization of the route's `orgSlug` param,
159
+ so a `PrivateSet` with `orgRoles` throws without `useOrgRoles` on the router or
160
+ an `{orgSlug}` in the route's path. `useAuth().hasUserRole` and
161
+ `<PrivateSet userRoles>` check `currentUser.roles`; there's no
162
+ `useAuth().hasRole` or `<PrivateSet roles>` in an app with tenancy. See the
163
+ how-to for the full setup `yarn cedar setup tenancy` generates.
@@ -7,8 +7,8 @@ export interface OrgScopeProps {
7
7
  notAMember?: React.ReactNode;
8
8
  /**
9
9
  * Only members with one of these roles in the organization get in.
10
- * Checked after the organization is resolved, which `<PrivateSet roles>`
11
- * around `OrgScope` can't do.
10
+ * Checked after the organization is resolved. For routes, prefer
11
+ * `<PrivateSet orgRoles>`.
12
12
  */
13
13
  roles?: string | string[];
14
14
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"OrgScope.d.ts","sourceRoot":"","sources":["../../src/web/OrgScope.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAIzB,OAAO,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AAqB5D,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,kEAAkE;IAClE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC5B;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACzB;;;OAGG;IACH,UAAU,CAAC,EAAE,cAAc,CAAA;IAC3B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAA;IACrC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC1B;AAwCD;;;;;;;;;;GAUG;AACH,wBAAgB,QAAQ,CAAC,EACvB,OAAO,EAAE,WAAW,EACpB,UAAiB,EACjB,KAAK,EACL,UAAU,EACV,YAAY,EACZ,OAAmB,EACnB,QAAQ,EACR,QAAQ,GACT,EAAE,aAAa,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CA8KnC"}
1
+ {"version":3,"file":"OrgScope.d.ts","sourceRoot":"","sources":["../../src/web/OrgScope.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAIzB,OAAO,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AAsB5D,MAAM,WAAW,aAAa;IAC5B,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,kEAAkE;IAClE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;IAC5B;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IACzB;;;OAGG;IACH,UAAU,CAAC,EAAE,cAAc,CAAA;IAC3B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAA;IACrC,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC1B;AAwCD;;;;;;;;;;GAUG;AACH,wBAAgB,QAAQ,CAAC,EACvB,OAAO,EAAE,WAAW,EACpB,UAAiB,EACjB,KAAK,EACL,UAAU,EACV,YAAY,EACZ,OAAmB,EACnB,QAAQ,EACR,QAAQ,GACT,EAAE,aAAa,GAAG,KAAK,CAAC,GAAG,CAAC,OAAO,CA4KnC"}
@@ -10,6 +10,7 @@ import {
10
10
  useParams
11
11
  } from "@cedarjs/router";
12
12
  import { useCreateApolloClient } from "@cedarjs/web/apollo";
13
+ import { findMembership } from "./findMembership.js";
13
14
  import { getMemberships } from "./getMemberships.js";
14
15
  import { hasOrgRole } from "./hasOrgRole.js";
15
16
  import { clearOrgClients, dropOrgClient, getOrgClient } from "./orgClients.js";
@@ -70,9 +71,8 @@ function OrgScope({
70
71
  const nextMemberships = membershipRoleMap(memberships);
71
72
  let dropped = false;
72
73
  if (userId) {
73
- for (const [organizationId, role] of lastMembershipsRef.current) {
74
- const currentRole = nextMemberships.get(organizationId);
75
- if (currentRole === void 0 || currentRole !== role) {
74
+ for (const organizationId of lastMembershipsRef.current.keys()) {
75
+ if (!nextMemberships.has(organizationId)) {
76
76
  void dropOrgClient(userId, organizationId);
77
77
  dropped = true;
78
78
  }
@@ -100,7 +100,7 @@ function OrgScope({
100
100
  },
101
101
  [onSetOrg, params, location.pathname, location.search, location.hash]
102
102
  );
103
- const membership = orgSlug ? memberships.find((m) => m.organization.slug === orgSlug) : void 0;
103
+ const membership = findMembership(memberships, orgSlug);
104
104
  const membershipPermissions = membership?.permissions;
105
105
  const hasPermission = React.useCallback(
106
106
  (permissionToCheck) => membershipPermissions?.includes(permissionToCheck) ?? false,
@@ -131,6 +131,7 @@ function OrgScope({
131
131
  const orgClient = getOrgClient({
132
132
  userId,
133
133
  organizationId: membership.organizationId,
134
+ role: membership.role,
134
135
  createClient: () => createClient({
135
136
  headers: { [CEDAR_ORG_HEADER]: membership.organizationId }
136
137
  })
@@ -0,0 +1,8 @@
1
+ import type { OrgMembership } from './types.js';
2
+ /**
3
+ * The membership in the organization whose slug is `orgSlug`, if the user
4
+ * has one. `OrgScope` and `useOrgRoles` both find the organization with it,
5
+ * so they agree on which one a route is in.
6
+ */
7
+ export declare function findMembership(memberships: OrgMembership[], orgSlug: string | undefined): OrgMembership | undefined;
8
+ //# sourceMappingURL=findMembership.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"findMembership.d.ts","sourceRoot":"","sources":["../../src/web/findMembership.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAE/C;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,WAAW,EAAE,aAAa,EAAE,EAC5B,OAAO,EAAE,MAAM,GAAG,SAAS,GAC1B,aAAa,GAAG,SAAS,CAM3B"}
@@ -0,0 +1,9 @@
1
+ function findMembership(memberships, orgSlug) {
2
+ if (!orgSlug) {
3
+ return void 0;
4
+ }
5
+ return memberships.find((m) => m.organization.slug === orgSlug);
6
+ }
7
+ export {
8
+ findMembership
9
+ };
@@ -5,4 +5,5 @@ export { OrgScope } from './OrgScope.js';
5
5
  export { clearOrgClients } from './orgClients.js';
6
6
  export type { CurrentOrgSummary, OrgContextValue, OrgMembership, } from './types.js';
7
7
  export { useCurrentOrg } from './useCurrentOrg.js';
8
+ export { useOrgRoles } from './useOrgRoles.js';
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/web/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AACpD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,YAAY,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAClD,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AACjD,YAAY,EACV,iBAAiB,EACjB,eAAe,EACf,aAAa,GACd,MAAM,YAAY,CAAA;AACnB,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/web/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AACpD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,YAAY,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAClD,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AACjD,YAAY,EACV,iBAAiB,EACjB,eAAe,EACf,aAAa,GACd,MAAM,YAAY,CAAA;AACnB,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA"}
package/dist/web/index.js CHANGED
@@ -3,10 +3,12 @@ import { hasOrgRole } from "./hasOrgRole.js";
3
3
  import { OrgScope } from "./OrgScope.js";
4
4
  import { clearOrgClients } from "./orgClients.js";
5
5
  import { useCurrentOrg } from "./useCurrentOrg.js";
6
+ import { useOrgRoles } from "./useOrgRoles.js";
6
7
  export {
7
8
  OrgScope,
8
9
  clearOrgClients,
9
10
  getMemberships,
10
11
  hasOrgRole,
11
- useCurrentOrg
12
+ useCurrentOrg,
13
+ useOrgRoles
12
14
  };
@@ -2,6 +2,12 @@ import type { ApolloClient } from '@apollo/client';
2
2
  export interface GetOrgClientOptions {
3
3
  userId: string;
4
4
  organizationId: string;
5
+ /**
6
+ * The user's role in the organization. A client cached for another role is
7
+ * replaced, so data cached under that role doesn't outlive it, whether or
8
+ * not an `OrgScope` was mounted to see the role change.
9
+ */
10
+ role: string;
5
11
  /** Builds a new client. Only called on a cache miss. */
6
12
  createClient: () => ApolloClient;
7
13
  }
@@ -10,11 +16,11 @@ export interface GetOrgClientOptions {
10
16
  * creating and caching it on first use so returning to an organization
11
17
  * reuses its client and cache.
12
18
  */
13
- export declare function getOrgClient({ userId, organizationId, createClient, }: GetOrgClientOptions): ApolloClient;
19
+ export declare function getOrgClient({ userId, organizationId, role, createClient, }: GetOrgClientOptions): ApolloClient;
14
20
  /**
15
21
  * Drops one organization's client, clearing its cache first. Called when a
16
- * memberships refresh shows the membership gone or its role changed, so
17
- * data cached under the previous authorization does not outlive it.
22
+ * memberships refresh shows the membership gone, so data cached under it
23
+ * does not outlive it. (A role change is handled by `getOrgClient`.)
18
24
  */
19
25
  export declare function dropOrgClient(userId: string, organizationId: string): Promise<void>;
20
26
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"orgClients.d.ts","sourceRoot":"","sources":["../../src/web/orgClients.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAgBlD,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAA;IACd,cAAc,EAAE,MAAM,CAAA;IACtB,wDAAwD;IACxD,YAAY,EAAE,MAAM,YAAY,CAAA;CACjC;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,EAC3B,MAAM,EACN,cAAc,EACd,YAAY,GACb,EAAE,mBAAmB,GAAG,YAAY,CAYpC;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CAUf;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAKrD"}
1
+ {"version":3,"file":"orgClients.d.ts","sourceRoot":"","sources":["../../src/web/orgClients.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAiBlD,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAA;IACd,cAAc,EAAE,MAAM,CAAA;IACtB;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAA;IACZ,wDAAwD;IACxD,YAAY,EAAE,MAAM,YAAY,CAAA;CACjC;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,EAC3B,MAAM,EACN,cAAc,EACd,IAAI,EACJ,YAAY,GACb,EAAE,mBAAmB,GAAG,YAAY,CAgBpC;AAED;;;;GAIG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CAUf;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAKrD"}
@@ -5,30 +5,34 @@ function clientKey(userId, organizationId) {
5
5
  function getOrgClient({
6
6
  userId,
7
7
  organizationId,
8
+ role,
8
9
  createClient
9
10
  }) {
10
11
  const key = clientKey(userId, organizationId);
11
12
  const existing = clients.get(key);
13
+ if (existing?.role === role) {
14
+ return existing.client;
15
+ }
12
16
  if (existing) {
13
- return existing;
17
+ void existing.client.clearStore();
14
18
  }
15
19
  const client = createClient();
16
- clients.set(key, client);
20
+ clients.set(key, { client, role });
17
21
  return client;
18
22
  }
19
23
  async function dropOrgClient(userId, organizationId) {
20
24
  const key = clientKey(userId, organizationId);
21
- const client = clients.get(key);
22
- if (!client) {
25
+ const entry = clients.get(key);
26
+ if (!entry) {
23
27
  return;
24
28
  }
25
29
  clients.delete(key);
26
- await client.clearStore();
30
+ await entry.client.clearStore();
27
31
  }
28
32
  async function clearOrgClients() {
29
33
  const clientsToClear = Array.from(clients.values());
30
34
  clients.clear();
31
- await Promise.all(clientsToClear.map((client) => client.clearStore()));
35
+ await Promise.all(clientsToClear.map(({ client }) => client.clearStore()));
32
36
  }
33
37
  export {
34
38
  clearOrgClients,
@@ -0,0 +1,22 @@
1
+ import type { UseAuth } from '@cedarjs/auth';
2
+ /**
3
+ * Checks the user's role in the organization of the current route, for
4
+ * `<PrivateSet orgRoles>`. Pass it to the router, next to `useAuth`:
5
+ *
6
+ * <Router useAuth={useAuth} useOrgRoles={useOrgRoles}>
7
+ *
8
+ * The router calls it with its `useAuth` and the current route's path
9
+ * params. It returns `null` when the route has no `orgSlug` param, so there's
10
+ * no organization to check a role in. Otherwise it returns a check of whether
11
+ * the user's role in the organization with that slug is one of `roles` (a
12
+ * role, or a list of roles): `false` when they aren't signed in or aren't a
13
+ * member of it.
14
+ *
15
+ * An app can pass the router a hook of its own instead, such as one that
16
+ * wraps this one with the organization from somewhere other than the path.
17
+ * Besides returning a check or `null`, it can throw an error of its own,
18
+ * which reaches the app's error boundary like any error thrown while
19
+ * rendering. See `UseOrgRoles` in `@cedarjs/router`.
20
+ */
21
+ export declare function useOrgRoles(useAuth: UseAuth, params: Record<string, string>): ((roles: string | string[]) => boolean) | null;
22
+ //# sourceMappingURL=useOrgRoles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useOrgRoles.d.ts","sourceRoot":"","sources":["../../src/web/useOrgRoles.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AAM5C;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,KAAK,OAAO,CAAC,GAAG,IAAI,CAehD"}
@@ -0,0 +1,16 @@
1
+ import { findMembership } from "./findMembership.js";
2
+ import { getMemberships } from "./getMemberships.js";
3
+ import { hasOrgRole } from "./hasOrgRole.js";
4
+ function useOrgRoles(useAuth, params) {
5
+ const { isAuthenticated, currentUser } = useAuth();
6
+ const orgSlug = Object.hasOwn(params, "orgSlug") ? params["orgSlug"] : void 0;
7
+ if (orgSlug === void 0) {
8
+ return null;
9
+ }
10
+ const memberships = isAuthenticated ? getMemberships(currentUser) : [];
11
+ const membership = findMembership(memberships, orgSlug);
12
+ return (roles) => !!membership && hasOrgRole(memberships, roles, membership.organizationId);
13
+ }
14
+ export {
15
+ useOrgRoles
16
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cedarjs/tenancy",
3
- "version": "8.0.0-canary.3541",
3
+ "version": "8.0.0-canary.3542",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/cedarjs/cedar.git",
@@ -44,17 +44,17 @@
44
44
  "test:watch": "vitest watch"
45
45
  },
46
46
  "dependencies": {
47
- "@cedarjs/api": "8.0.0-canary.3541",
48
- "@cedarjs/context": "8.0.0-canary.3541",
49
- "@cedarjs/graphql-server": "8.0.0-canary.3541"
47
+ "@cedarjs/api": "8.0.0-canary.3542",
48
+ "@cedarjs/context": "8.0.0-canary.3542",
49
+ "@cedarjs/graphql-server": "8.0.0-canary.3542"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@apollo/client": "4.3.5",
53
53
  "@arethetypeswrong/cli": "0.18.5",
54
- "@cedarjs/auth": "8.0.0-canary.3541",
55
- "@cedarjs/framework-tools": "8.0.0-canary.3541",
56
- "@cedarjs/router": "8.0.0-canary.3541",
57
- "@cedarjs/web": "8.0.0-canary.3541",
54
+ "@cedarjs/auth": "8.0.0-canary.3542",
55
+ "@cedarjs/framework-tools": "8.0.0-canary.3542",
56
+ "@cedarjs/router": "8.0.0-canary.3542",
57
+ "@cedarjs/web": "8.0.0-canary.3542",
58
58
  "@prisma/adapter-better-sqlite3": "7.10.1",
59
59
  "@prisma/client": "7.10.1",
60
60
  "@testing-library/dom": "10.4.2",
@@ -80,9 +80,9 @@
80
80
  },
81
81
  "peerDependencies": {
82
82
  "@apollo/client": "4.3.5",
83
- "@cedarjs/auth": "8.0.0-canary.3541",
84
- "@cedarjs/router": "8.0.0-canary.3541",
85
- "@cedarjs/web": "8.0.0-canary.3541",
83
+ "@cedarjs/auth": "8.0.0-canary.3542",
84
+ "@cedarjs/router": "8.0.0-canary.3542",
85
+ "@cedarjs/web": "8.0.0-canary.3542",
86
86
  "@prisma/client": "7.10.1",
87
87
  "react": "19.3.1"
88
88
  },