@seliseblocks/cli-os 0.2.4 → 0.2.6

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.
@@ -7,7 +7,7 @@ import { requestContext } from "../../../lib/request-context.js";
7
7
  import { parseCommand, selectedProject } from "../../../lib/workspace.js";
8
8
  export async function authConfigSave(argv) {
9
9
  const { flags } = parseCommand(argv);
10
- const body = {
10
+ const overrides = {
11
11
  ...(await jsonBodyFlag(flags)),
12
12
  ...compact({
13
13
  absoluteRefreshTokenValidForNumberMinutes: optionalIntegerFlag(flags, "absolute-refresh-token-minutes"),
@@ -21,12 +21,22 @@ export async function authConfigSave(argv) {
21
21
  rememberMeRefreshTokenValidForNumberMinutes: optionalIntegerFlag(flags, "remember-me-refresh-token-minutes")
22
22
  })
23
23
  };
24
+ const projectKey = await selectedProject(flags);
25
+ // POST /auth/config replaces the whole config document rather than merging
26
+ // (confirmed against the portal's own save call, which always resends every
27
+ // field it read on load) -- fetch the current config first so fields the
28
+ // caller didn't mention here survive the round trip instead of resetting.
29
+ const current = await blocksRequest("/iam/v4/auth/config", {
30
+ impersonatedProjectAuth: true,
31
+ ...requestContext(flags),
32
+ projectTenantId: projectKey
33
+ });
34
+ const body = { ...current, ...overrides };
24
35
  if (booleanFlag(flags, "dry-run")) {
25
36
  writeOutput({ dryRun: true, endpoint: "/iam/v4/auth/config", request: body }, flags);
26
37
  return;
27
38
  }
28
39
  await confirmMutation(flags, "Save AuthController configuration for the selected project.");
29
- const projectKey = await selectedProject(flags);
30
40
  const result = await blocksRequest("/iam/v4/auth/config", {
31
41
  body,
32
42
  impersonatedProjectAuth: true,
@@ -8,7 +8,7 @@ import { parseCommand, selectedProject } from "../../../lib/workspace.js";
8
8
  /** Upsert: omit --item-id to register a new OIDC client, pass it to update an existing one. */
9
9
  export async function authOidcClientsSave(argv) {
10
10
  const { flags } = parseCommand(argv);
11
- const body = {
11
+ const overrides = {
12
12
  ...(await jsonBodyFlag(flags)),
13
13
  ...compact({
14
14
  allowedMfaMethods: listFlag(flags, "allowed-mfa-methods")?.map(Number),
@@ -36,12 +36,27 @@ export async function authOidcClientsSave(argv) {
36
36
  useTokensCookie: optionalBooleanFlag(flags, "use-tokens-cookie")
37
37
  })
38
38
  };
39
+ const projectKey = await selectedProject(flags);
40
+ const itemId = typeof overrides.itemId === "string" ? overrides.itemId : undefined;
41
+ // Saving an existing client (itemId set) replaces the whole client document
42
+ // rather than merging -- the portal's own Edit dialog always resubmits every
43
+ // field, including ones this command wasn't asked to change. Fetch the
44
+ // current client first so unmentioned fields (redirectUris, scope, PKCE, ...)
45
+ // survive instead of being reset to defaults. A new client (no itemId) has
46
+ // no prior state to merge.
47
+ const current = itemId
48
+ ? await blocksRequest(`/iam/v4/oidc-clients/${encodeURIComponent(itemId)}`, {
49
+ impersonatedProjectAuth: true,
50
+ ...requestContext(flags),
51
+ projectTenantId: projectKey
52
+ })
53
+ : {};
54
+ const body = { ...current, ...overrides };
39
55
  if (booleanFlag(flags, "dry-run")) {
40
56
  writeOutput({ dryRun: true, endpoint: "/iam/v4/oidc-clients", request: redactSecret(body) }, flags);
41
57
  return;
42
58
  }
43
59
  await confirmMutation(flags, `Save OIDC client '${body.clientDisplayName ?? body.itemId ?? "(new)"}'. The response's client secret is shown once.`);
44
- const projectKey = await selectedProject(flags);
45
60
  const result = await blocksRequest("/iam/v4/oidc-clients", {
46
61
  body,
47
62
  impersonatedProjectAuth: true,
@@ -21,6 +21,15 @@ export async function newWeb(argv) {
21
21
  const appDomain = await resolveAppDomain(project, flags);
22
22
  const apiUrl = stringFlag(flags, "blocks-api-url") || apiUrlFromAppDomain(appDomain);
23
23
  const oidcClientId = await resolveOidcClientId(tenantId, appDomain, name, flags);
24
+ if (oidcClientId) {
25
+ try {
26
+ await ensureOidcLoginEnabled(tenantId, flags);
27
+ }
28
+ catch (error) {
29
+ console.warn(`Warning: could not confirm/enable OIDC login on this project's AuthController config: ${error.message}`);
30
+ console.warn("Enable it manually: 'blocks auth:config:save --oidc-enabled --project " + tenantId + "', or in the Blocks portal under IAM > Auth Config.");
31
+ }
32
+ }
24
33
  await scaffoldWebProject({
25
34
  apiUrl,
26
35
  appDomain,
@@ -116,6 +125,32 @@ async function listOidcClientSummaries(tenantId, flags) {
116
125
  }
117
126
  return summaries;
118
127
  }
128
+ // Creating/selecting an OIDC client is not enough for the hosted login flow to
129
+ // work: AuthController separately gates the whole OIDC/IdP path behind
130
+ // isOidcEnabled on the tenant's auth config, off by default. Without this,
131
+ // scaffolded apps 404/error on login until someone flips it manually in the
132
+ // portal (IAM > Auth Config), so check-and-enable it here as part of setup.
133
+ // POST /auth/config replaces the whole config document rather than merging
134
+ // (confirmed against the portal's own save call, which always resends every
135
+ // field) -- sending just `{ isOidcEnabled: true }` would reset every other
136
+ // AuthController setting for this tenant, so the fetched config is spread
137
+ // back in full with only that one field overridden.
138
+ async function ensureOidcLoginEnabled(tenantId, flags) {
139
+ const config = await blocksRequest("/iam/v4/auth/config", {
140
+ impersonatedProjectAuth: true,
141
+ projectTenantId: tenantId,
142
+ ...requestContext(flags)
143
+ });
144
+ if (config.isOidcEnabled)
145
+ return;
146
+ await confirmMutation(flags, "Enable OIDC login on this project's AuthController configuration.");
147
+ await blocksRequest("/iam/v4/auth/config", {
148
+ body: { ...config, isOidcEnabled: true },
149
+ impersonatedProjectAuth: true,
150
+ projectTenantId: tenantId,
151
+ ...requestContext(flags)
152
+ });
153
+ }
119
154
  function normalizeList(raw) {
120
155
  if (Array.isArray(raw))
121
156
  return raw;
@@ -135,10 +170,15 @@ async function createOidcClientInteractively(tenantId, appDomain, appName, flags
135
170
  // clientType drives IAM's tokenEndpointAuthMethod: omitting it stores this browser
136
171
  // app as confidential ("client_secret_post") and lets it request client_credentials.
137
172
  // The scaffold only ever produces a PKCE SPA, so it is always "public".
173
+ // isAutoRedirect: the scaffolded login page's startLogin() already navigates
174
+ // straight to the provider via window.location.assign -- without this flag IAM
175
+ // shows an interstitial "continue" click on the hosted login page instead of
176
+ // redirecting immediately, which is dead weight for a flow the SPA already drives.
138
177
  const body = {
139
178
  clientDisplayName: displayName,
140
179
  clientType: "public",
141
180
  isActive: true,
181
+ isAutoRedirect: true,
142
182
  redirectUris: [redirectUri],
143
183
  registerAsIdentityProvider: true,
144
184
  requirePkce: true,
@@ -80,7 +80,7 @@ export async function writeAppCore(root) {
80
80
  await write(root, "src/app/providers/AuthProvider.tsx", [
81
81
  "import { createContext, useCallback, useContext, useEffect, useMemo, useState } from \"react\";",
82
82
  "import type { ReactNode } from \"react\";",
83
- "import { fetchSessionClaims, logout as endSession, startLogin } from \"../../lib/blocks/auth\";",
83
+ "import { fetchSessionClaims, logout as endSession, onSessionExpired, startLogin } from \"../../lib/blocks/auth\";",
84
84
  "",
85
85
  "type AuthStatus = \"authenticated\" | \"loading\" | \"unauthenticated\";",
86
86
  "",
@@ -129,6 +129,11 @@ export async function writeAppCore(root) {
129
129
  " };",
130
130
  " }, [refresh]);",
131
131
  "",
132
+ " // Fires when a reactive 401 forced a refresh and IAM rejected the refresh",
133
+ " // token outright (invalid_grant) -- blocks/auth.ts already ended the",
134
+ " // session server-side, this just gets the UI to notice and redirect.",
135
+ " useEffect(() => onSessionExpired(() => void refresh()), [refresh]);",
136
+ "",
132
137
  " const login = useCallback(async (returnTo?: string) => {",
133
138
  " await startLogin(returnTo);",
134
139
  " }, []);",
@@ -54,17 +54,22 @@ export async function writeBlocksLib(root) {
54
54
  await write(root, "src/lib/blocks/client.ts", [
55
55
  "import { createBlocksClient } from \"@seliseblocks/client\";",
56
56
  "import { blocksConfig } from \"./config\";",
57
- "import { getValidAccessToken } from \"./auth\";",
57
+ "import { forceRefreshAccessToken, getValidAccessToken } from \"./auth\";",
58
58
  "",
59
59
  "// Single Blocks API entry point for this app -- every Auth, IAM, Data, and",
60
60
  "// Localization call goes through this client, never a hand-written fetch().",
61
61
  "// `accessToken` is a caller-owned resolver: the SDK reads it before each",
62
62
  "// protected call but never stores/refreshes/clears it itself, so the actual",
63
63
  "// session lifecycle (storage, refresh-before-expiry) lives in ./auth.ts.",
64
+ "// `onUnauthorized` is the reactive counterpart: it only fires when a call",
65
+ "// comes back 401 despite a locally-valid-looking token (server-side",
66
+ "// revocation, clock skew), and shares the same in-flight refresh as the",
67
+ "// proactive path so concurrent 401s resolve one refresh, not one each.",
64
68
  "export const blocksClient = createBlocksClient({",
65
69
  " accessToken: () => getValidAccessToken(),",
66
70
  " apiUrl: blocksConfig.apiUrl,",
67
71
  " appDomain: blocksConfig.appDomain,",
72
+ " onUnauthorized: () => forceRefreshAccessToken(),",
68
73
  " oidc: {",
69
74
  " clientId: blocksConfig.oidcClientId,",
70
75
  " scope: blocksConfig.oidcScope,",
@@ -94,6 +99,21 @@ export async function writeBlocksLib(root) {
94
99
  "let cachedRefreshToken: string | undefined;",
95
100
  "let refreshInFlight: Promise<string | undefined> | undefined;",
96
101
  "",
102
+ "// AuthProvider subscribes to this to learn the session died out-of-band (a",
103
+ "// refresh came back invalid_grant) so it can flip status to unauthenticated",
104
+ "// and let RequireAuth redirect to /login -- this module has no router access",
105
+ "// of its own to do that navigation directly.",
106
+ "const sessionExpiredListeners = new Set<() => void>();",
107
+ "",
108
+ "export function onSessionExpired(listener: () => void): () => void {",
109
+ " sessionExpiredListeners.add(listener);",
110
+ " return () => sessionExpiredListeners.delete(listener);",
111
+ "}",
112
+ "",
113
+ "function notifySessionExpired(): void {",
114
+ " for (const listener of sessionExpiredListeners) listener();",
115
+ "}",
116
+ "",
97
117
  "function getAccessToken(): string | undefined {",
98
118
  " if (cachedAccessToken && !isJwtExpired(cachedAccessToken)) return cachedAccessToken;",
99
119
  "",
@@ -137,7 +157,17 @@ export async function writeBlocksLib(root) {
137
157
  "export async function getValidAccessToken(): Promise<string | undefined> {",
138
158
  " const current = getAccessToken();",
139
159
  " if (current) return current;",
160
+ " return forceRefreshAccessToken();",
161
+ "}",
140
162
  "",
163
+ "// Passed to createBlocksClient as `onUnauthorized`: unlike getValidAccessToken,",
164
+ "// this skips the \"is the cached token still fresh\" check and always goes",
165
+ "// straight to refreshAccessToken() -- a 401 means the server already",
166
+ "// disagreed with our local judgment of freshness, so re-checking it would",
167
+ "// just resend the same rejected token. Still funnels through the same",
168
+ "// refreshInFlight guard, so a burst of concurrent 401s (and any proactive",
169
+ "// caller racing them) share one refresh call instead of firing one each.",
170
+ "export async function forceRefreshAccessToken(): Promise<string | undefined> {",
141
171
  " const refreshToken = getRefreshToken();",
142
172
  " if (!refreshToken) return undefined;",
143
173
  "",
@@ -166,8 +196,12 @@ export async function writeBlocksLib(root) {
166
196
  " if (!accessToken) {",
167
197
  " // IAM answered but explicitly rejected the grant (e.g. invalid_grant --",
168
198
  " // the refresh token expired or was already rotated away) -- now it",
169
- " // really is dead, so there is nothing left to retry with.",
199
+ " // really is dead, so this is a full sign-out, not just a cache clear.",
200
+ " // Clear local state before the logout call so its own accessToken",
201
+ " // lookup finds nothing to refresh and doesn't loop back into us.",
170
202
  " clearLocalTokens();",
203
+ " await blocksClient.auth.logout({ refreshToken }).catch(() => undefined);",
204
+ " notifySessionExpired();",
171
205
  " return undefined;",
172
206
  " }",
173
207
  "",
@@ -1,49 +1,49 @@
1
- ---
2
- name: blocks-iam-access-control
3
- description: "Work with SELISE Blocks RBAC (roles & permissions) via `blocks iam roles/permissions *` (CLI, project-scoped) or `blocksClient.iam.*` (SDK), never raw fetch/curl. Two facets: read-only feature-gating by the current user's own roles/permissions (common, safe) vs. creating/editing role and permission definitions (sensitive, human-confirmed only — CLI `--dry-run`→`--yes` or an in-app admin screen). OIDC/identity-provider provisioning stays portal-only, a different concern. Use for permission-gated UI, role/permission pickers, or building/scripting role & permission admin ('gate this button by permission', 'create a role and grant permissions', 'list permissions by severity')."
4
- ---
5
-
6
- # Blocks IAM — Access Control (Permissions & Roles)
7
-
8
- This skill covers **permission and role definitions** in SELISE Blocks — the RBAC model itself, not who has which role (that's the blocks-iam-users skill). Everything goes through either `blocks iam roles/permissions *` (CLI) or `blocksClient.iam.*` from **`@seliseblocks/client`**, the single SDK instance every `blocks new web` scaffold wires up at `src/lib/blocks/client.ts` and exports as `blocksClient`. No raw `fetch`/`curl` for either surface.
9
-
10
- ```ts
11
- import { blocksClient } from "../../lib/blocks/client";
12
- ```
13
-
14
- ## The platform boundary — read this before writing any code
15
-
16
- Role and permission administration is **not** portal-only or app-UI-only — `blocks` has a full, working CLI surface for it too. There are two equally real surfaces for the same operations, and the choice is about *where the human is*, not which one is "allowed" — see [flows/manage-roles-permissions.md](flows/manage-roles-permissions.md) for the full command reference and the CLI-vs-SDK decision.
17
-
18
- Identity-provider/OIDC client provisioning is the one piece that really is **portal-only, human-driven**, at `https://os.seliseblocks.com` — unrelated to roles/permissions, don't bolt it onto this skill.
19
-
20
- Keep the two facets below (read-only feature-gating vs. sensitive admin mutations) separate in your head (and in your code) — they have very different risk profiles regardless of which surface (CLI or SDK) you're using.
21
-
22
- ## Facet 1 — Feature-gating a frontend by the user's own permissions (common, low risk)
23
-
24
- Read-only, scoped to whoever is signed in, needs no special confirmation. `useCurrentUser()` + `iam.resources.features()` + `iam.roles.assignable()`.
25
-
26
- → Full walkthrough: [flows/feature-gating.md](flows/feature-gating.md)
27
-
28
- ## Facet 2 — Creating/editing roles & permissions (sensitive)
29
-
30
- Legitimate only in direct response to a human's explicit, in-the-moment instruction — CLI (`--dry-run` reviewed, then `--yes`) or a signed-in admin's own in-app screen. Never something an agent decides to invoke on its own initiative.
31
-
32
- → Full command reference, SDK methods, and confirm-before-mutating pattern: [flows/manage-roles-permissions.md](flows/manage-roles-permissions.md)
33
-
34
- ## Gotchas
35
-
36
- - **CLI mutations are project-scoped, not account-scoped** — they run against the impersonated-project token; `blocks iam me` is the one IAM command that uses the account token instead.
37
- - **Role hierarchy and permission assignment key off `slug`**, not `itemId`.
38
- - **Never fire a create/update/assign-permissions call — CLI or SDK — without a human confirming that specific change first.** See [flows/manage-roles-permissions.md](flows/manage-roles-permissions.md) for the full discipline.
39
- - **OIDC/identity-provider client provisioning is always portal-only**, independent of everything above.
40
-
41
- ## Example trigger prompts
42
-
43
- - "Only show the delete button to users who have the `order::delete` permission." → Facet 1
44
- - "Hide this whole admin section unless the signed-in user has an admin role." → Facet 1
45
- - "What roles am I allowed to assign to other users?" → Facet 1
46
- - "Show me permissions grouped by severity in a settings panel." → Facet 1
47
- - "Build an admin page where I can create a role and pick which permissions it gets." → Facet 2
48
- - "Create a `content-editor` role from the CLI with these permissions." → Facet 2
49
- - "Can you just set up a few default roles for my project?" → confirm the exact list with the human first (in chat, or via a reviewed `--dry-run`), then run each `blocks iam roles create`/`assign-permissions` with `--yes` only after they say go — don't auto-provision without that per-change confirmation.
1
+ ---
2
+ name: blocks-iam-access-control
3
+ description: "Work with SELISE Blocks RBAC (roles & permissions) via `blocks iam roles/permissions *` (CLI, project-scoped) or `blocksClient.iam.*` (SDK), never raw fetch/curl. Two facets: read-only feature-gating by the current user's own roles/permissions (common, safe) vs. creating/editing role and permission definitions (sensitive, human-confirmed only — CLI `--dry-run`→`--yes` or an in-app admin screen). OIDC/identity-provider provisioning stays portal-only, a different concern. Use for permission-gated UI, role/permission pickers, or building/scripting role & permission admin ('gate this button by permission', 'create a role and grant permissions', 'list permissions by severity')."
4
+ ---
5
+
6
+ # Blocks IAM — Access Control (Permissions & Roles)
7
+
8
+ This skill covers **permission and role definitions** in SELISE Blocks — the RBAC model itself, not who has which role (that's the blocks-iam-users skill). Everything goes through either `blocks iam roles/permissions *` (CLI) or `blocksClient.iam.*` from **`@seliseblocks/client`**, the single SDK instance every `blocks new web` scaffold wires up at `src/lib/blocks/client.ts` and exports as `blocksClient`. No raw `fetch`/`curl` for either surface.
9
+
10
+ ```ts
11
+ import { blocksClient } from "../../lib/blocks/client";
12
+ ```
13
+
14
+ ## The platform boundary — read this before writing any code
15
+
16
+ Role and permission administration is **not** portal-only or app-UI-only — `blocks` has a full, working CLI surface for it too. There are two equally real surfaces for the same operations, and the choice is about *where the human is*, not which one is "allowed" — see [flows/manage-roles-permissions.md](flows/manage-roles-permissions.md) for the full command reference and the CLI-vs-SDK decision.
17
+
18
+ Identity-provider/OIDC client provisioning is the one piece that really is **portal-only, human-driven**, at `https://os.seliseblocks.com` — unrelated to roles/permissions, don't bolt it onto this skill.
19
+
20
+ Keep the two facets below (read-only feature-gating vs. sensitive admin mutations) separate in your head (and in your code) — they have very different risk profiles regardless of which surface (CLI or SDK) you're using.
21
+
22
+ ## Facet 1 — Feature-gating a frontend by the user's own permissions (common, low risk)
23
+
24
+ Read-only, scoped to whoever is signed in, needs no special confirmation. `useCurrentUser()` + `iam.resources.features()` + `iam.roles.assignable()`.
25
+
26
+ → Full walkthrough: [flows/feature-gating.md](flows/feature-gating.md)
27
+
28
+ ## Facet 2 — Creating/editing roles & permissions (sensitive)
29
+
30
+ Legitimate only in direct response to a human's explicit, in-the-moment instruction — CLI (`--dry-run` reviewed, then `--yes`) or a signed-in admin's own in-app screen. Never something an agent decides to invoke on its own initiative.
31
+
32
+ → Full command reference, SDK methods, and confirm-before-mutating pattern: [flows/manage-roles-permissions.md](flows/manage-roles-permissions.md)
33
+
34
+ ## Gotchas
35
+
36
+ - **CLI mutations are project-scoped, not account-scoped** — they run against the impersonated-project token; `blocks iam me` is the one IAM command that uses the account token instead.
37
+ - **Role hierarchy and permission assignment key off `slug`**, not `itemId`.
38
+ - **Never fire a create/update/assign-permissions call — CLI or SDK — without a human confirming that specific change first.** See [flows/manage-roles-permissions.md](flows/manage-roles-permissions.md) for the full discipline.
39
+ - **OIDC/identity-provider client provisioning is always portal-only**, independent of everything above.
40
+
41
+ ## Example trigger prompts
42
+
43
+ - "Only show the delete button to users who have the `order::delete` permission." → Facet 1
44
+ - "Hide this whole admin section unless the signed-in user has an admin role." → Facet 1
45
+ - "What roles am I allowed to assign to other users?" → Facet 1
46
+ - "Show me permissions grouped by severity in a settings panel." → Facet 1
47
+ - "Build an admin page where I can create a role and pick which permissions it gets." → Facet 2
48
+ - "Create a `content-editor` role from the CLI with these permissions." → Facet 2
49
+ - "Can you just set up a few default roles for my project?" → confirm the exact list with the human first (in chat, or via a reviewed `--dry-run`), then run each `blocks iam roles create`/`assign-permissions` with `--yes` only after they say go — don't auto-provision without that per-change confirmation.
@@ -1,38 +1,38 @@
1
- # Flow: Feature-gating a frontend by the user's own permissions (common, low risk)
2
-
3
- This is read-only against IAM and scoped to whoever is signed in, so it needs no special confirmation — build it the same way you'd build any other data-fetching feature.
4
-
5
- The scaffold already gives you a `useCurrentUser()` hook (`src/features/profile/useCurrentUser.ts`) wrapping `blocksClient.iam.me()` with TanStack Query; `me()` returns `{ data: { itemId, email, firstName, lastName, roles: string[], permissions: string[], ... } }`. Reuse it instead of re-fetching:
6
-
7
- ```ts
8
- // src/features/access/usePermission.ts
9
- import { useCurrentUser } from "../profile/useCurrentUser";
10
-
11
- export function useHasPermission(permission: string): boolean {
12
- const me = useCurrentUser();
13
- return me.data?.data?.permissions?.includes(permission) ?? false;
14
- }
15
-
16
- export function useHasRole(role: string): boolean {
17
- const me = useCurrentUser();
18
- return me.data?.data?.roles?.includes(role) ?? false;
19
- }
20
- ```
21
-
22
- ```tsx
23
- // src/shared/ui/RequirePermission.tsx
24
- import type { ReactNode } from "react";
25
- import { useHasPermission } from "../../features/access/usePermission";
26
-
27
- export function RequirePermission({ permission, children }: { permission: string; children: ReactNode }) {
28
- if (!useHasPermission(permission)) return null;
29
- return <>{children}</>;
30
- }
31
- ```
32
-
33
- Two more read methods round this out:
34
-
35
- - `blocksClient.iam.resources.features(query?)` — feature/resource flags for the active user context; use this to drive nav items or feature flags that are more granular than a flat permission string.
36
- - `blocksClient.iam.roles.assignable()` — lists roles the **current caller** is allowed to assign. If you're building a "grant this user a role" picker, populate it from `assignable()`, not from `roles.list()` — don't assume every role in the system is one this particular admin may hand out.
37
-
38
- There is also a CLI read path for the same data, useful outside an app (scripting/inspection): `blocks iam roles list/get/assignable` and `blocks iam permissions list/get/by-severity` — see [manage-roles-permissions.md](manage-roles-permissions.md) for the full CLI command reference (it covers both reads and mutations).
1
+ # Flow: Feature-gating a frontend by the user's own permissions (common, low risk)
2
+
3
+ This is read-only against IAM and scoped to whoever is signed in, so it needs no special confirmation — build it the same way you'd build any other data-fetching feature.
4
+
5
+ The scaffold already gives you a `useCurrentUser()` hook (`src/features/profile/useCurrentUser.ts`) wrapping `blocksClient.iam.me()` with TanStack Query; `me()` returns `{ data: { itemId, email, firstName, lastName, roles: string[], permissions: string[], ... } }`. Reuse it instead of re-fetching:
6
+
7
+ ```ts
8
+ // src/features/access/usePermission.ts
9
+ import { useCurrentUser } from "../profile/useCurrentUser";
10
+
11
+ export function useHasPermission(permission: string): boolean {
12
+ const me = useCurrentUser();
13
+ return me.data?.data?.permissions?.includes(permission) ?? false;
14
+ }
15
+
16
+ export function useHasRole(role: string): boolean {
17
+ const me = useCurrentUser();
18
+ return me.data?.data?.roles?.includes(role) ?? false;
19
+ }
20
+ ```
21
+
22
+ ```tsx
23
+ // src/shared/ui/RequirePermission.tsx
24
+ import type { ReactNode } from "react";
25
+ import { useHasPermission } from "../../features/access/usePermission";
26
+
27
+ export function RequirePermission({ permission, children }: { permission: string; children: ReactNode }) {
28
+ if (!useHasPermission(permission)) return null;
29
+ return <>{children}</>;
30
+ }
31
+ ```
32
+
33
+ Two more read methods round this out:
34
+
35
+ - `blocksClient.iam.resources.features(query?)` — feature/resource flags for the active user context; use this to drive nav items or feature flags that are more granular than a flat permission string.
36
+ - `blocksClient.iam.roles.assignable()` — lists roles the **current caller** is allowed to assign. If you're building a "grant this user a role" picker, populate it from `assignable()`, not from `roles.list()` — don't assume every role in the system is one this particular admin may hand out.
37
+
38
+ There is also a CLI read path for the same data, useful outside an app (scripting/inspection): `blocks iam roles list/get/assignable` and `blocks iam permissions list/get/by-severity` — see [manage-roles-permissions.md](manage-roles-permissions.md) for the full CLI command reference (it covers both reads and mutations).