@seliseblocks/cli-os 0.2.2 → 0.2.3

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.
Files changed (37) hide show
  1. package/AI_USAGE_GUIDE.md +551 -546
  2. package/LICENSE +21 -21
  3. package/README.md +171 -171
  4. package/bin/run.js +2 -2
  5. package/dist/commands/data/files/delete.js +5 -4
  6. package/dist/commands/data/files/get-many.js +1 -1
  7. package/dist/commands/data/files/get.js +1 -1
  8. package/dist/commands/data/files/info.js +1 -1
  9. package/dist/commands/data/files/object-tree.d.ts +23 -0
  10. package/dist/commands/data/files/object-tree.js +238 -0
  11. package/dist/commands/data/files/presigned-upload-url.js +9 -2
  12. package/dist/commands/data/files/update-additional-info.js +2 -2
  13. package/dist/commands/data/files/upload-to-local-storage.js +2 -2
  14. package/dist/commands/data/files/upload.d.ts +2 -4
  15. package/dist/commands/data/files/upload.js +14 -28
  16. package/dist/index.js +685 -647
  17. package/dist/skills/blocks-data-gateway-configuration/SKILL.md +204 -204
  18. package/dist/skills/blocks-data-gateway-crud/SKILL.md +223 -223
  19. package/dist/skills/blocks-data-storage/SKILL.md +253 -161
  20. package/dist/skills/blocks-data-storage/flows/object-management.md +124 -0
  21. package/dist/skills/blocks-frontend-local-https/SKILL.md +100 -100
  22. package/dist/skills/blocks-iam-account/SKILL.md +169 -169
  23. package/dist/skills/blocks-iam-sso-oidc-implementation/SKILL.md +80 -80
  24. package/dist/skills/blocks-iam-users/SKILL.md +131 -131
  25. package/dist/skills/blocks-localization-configuration/SKILL.md +149 -149
  26. package/dist/skills/blocks-localization-implementation/SKILL.md +63 -63
  27. package/dist/skills/blocks-onboarding/SKILL.md +77 -77
  28. package/dist/skills/blocks-storage-configuration/SKILL.md +4 -4
  29. package/package.json +47 -47
  30. package/dist/commands/data/files/create-folder.d.ts +0 -1
  31. package/dist/commands/data/files/create-folder.js +0 -34
  32. package/dist/commands/data/files/delete-folder.d.ts +0 -1
  33. package/dist/commands/data/files/delete-folder.js +0 -25
  34. package/dist/commands/data/files/dms-list.d.ts +0 -1
  35. package/dist/commands/data/files/dms-list.js +0 -27
  36. package/dist/commands/data/files/dms-upload.d.ts +0 -6
  37. package/dist/commands/data/files/dms-upload.js +0 -41
@@ -1,80 +1,80 @@
1
- ---
2
- name: blocks-iam-sso-oidc-implementation
3
- description: "Extend or debug the hosted SSO/OIDC login flow `blocks new web` scaffolds into every Blocks app: redirectToProvider → `/login/callback` → session, via the single `blocksClient`. Covers `AuthProvider` status/claims, `RequireAuth`/`RedirectIfAuthenticated` guards, and token refresh. Use for a login button, the OIDC callback, protected routes, a disabled login button, redirect loops, or a session that doesn't stick — on an app `blocks new web` already created. Requires a registered OIDC client (`blocks-iam-sso-oidc-configuration`) and HTTPS on the real domain for testing (`blocks-frontend-local-https`)."
4
- ---
5
-
6
- # Blocks IAM — SSO / OIDC Implementation (scaffolded frontend)
7
-
8
- `blocks new web <name>` already generates a complete, working hosted-login flow. Don't reinvent it — read what's there, extend it, or fix it. Every Blocks call in this flow goes through the single `blocksClient` instance (`src/lib/blocks/client.ts`, `@seliseblocks/client`); there is no raw `fetch`/`curl` anywhere in this stack.
9
-
10
- ## The files, and what each one actually does
11
-
12
- | File | Role |
13
- |---|---|
14
- | `src/lib/blocks/config.ts` | Reads `VITE_BLOCKS_*` env vars; `isLoginConfigured()` = `apiUrl && oidcUrl && oidcClientId` all present |
15
- | `src/lib/blocks/client.ts` | The one `blocksClient = createBlocksClient({...})` instance, with `oidc: { clientId, scope, url: oidcUrl }` |
16
- | `src/lib/blocks/auth.ts` | `startLogin`, `completeLogin`, `fetchSessionClaims`, `logout`, `getValidAccessToken` — the session/token logic |
17
- | `src/lib/blocks/jwt.ts` | `decodeJwtPayload`/`isJwtExpired` — only relevant if a tenant's OIDC config returns bearer tokens in the body |
18
- | `src/app/providers/AuthProvider.tsx` | React context: `status`/`claims`/`login`/`logout`/`refresh`, polling + visibility-driven refresh |
19
- | `src/app/router/guards.tsx` | `RequireAuth`, `RedirectIfAuthenticated` |
20
- | `src/app/router/routes.tsx` | Wires `/login`, `/login/callback`, and the protected route table (`/`, `/assets`, `/profile`, `/error`) |
21
- | `src/features/auth/LoginPage.tsx` | The login button |
22
- | `src/features/auth/CallbackPage.tsx` | The `/login/callback` handler |
23
-
24
- ## The flow, traced through the generated code
25
-
26
- 1. **Login button.** `LoginPage`'s button calls `useAuth().login(returnTo)`, which is `AuthProvider`'s `login` calling `startLogin(returnTo)` in `lib/blocks/auth.ts`. `startLogin` throws a clear error if `oidcClientId` isn't set (`"Login is not configured. Set VITE_BLOCKS_OIDC_CLIENT_ID in .env."`), stashes `returnTo` (default `"/"`) in `sessionStorage`, then calls `blocksClient.auth.idp.redirectToProvider()` with no arguments — it relies entirely on the client's configured `oidc` defaults.
27
- - The button itself is `disabled={!configured || pending}` — if `isLoginConfigured()` is false, `LoginPage` renders a warning `Alert` with the exact callback URL (`{origin}/login/callback`) to register, instead of letting the click fail. **"Login button does nothing" is almost always an empty `VITE_BLOCKS_OIDC_CLIENT_ID`.**
28
- 2. **`redirectToProvider()`** (SDK, `auth-client.ts`) calls `auth.idp.initiate()`, then `window.location.assign(response.redirect_uri)`. `initiate` itself is also directly callable (e.g. to get the URL without immediately navigating, such as opening it in a new tab) but the scaffold never calls it directly; only `redirectToProvider` is wired to the button.
29
- 3. The user authenticates on Blocks-hosted IAM.
30
- 4. IAM redirects back to `<origin>/login/callback?code=...&state=...`. That path is the SDK's *default* `redirectUri` — the scaffold's `client.ts` never passes an explicit `redirectUri`, so `createBlocksClient` derives `${window.location.origin}/login/callback` at runtime (see `browserRedirectUri()` in the SDK's `client.ts`). This is exactly the route `routes.tsx` handles, so it lines up with zero config — **but** it means the OIDC client's registered `redirect_uris` must include `/login/callback` under **every origin** this app runs on (dev HTTPS origin and prod origin both — see the scaffold's own README and `blocks-iam-sso-oidc-configuration`).
31
- 5. `routes.tsx` matches `path === "/login/callback"` and renders `CallbackPage` directly — **not** wrapped in `RequireAuth` or `RedirectIfAuthenticated`, since the user is by definition not yet authenticated when they land here.
32
- 6. `CallbackPage`'s one-shot effect (guarded with a `useRef` so React 18 Strict Mode's double-invoke doesn't run it twice) calls `completeLogin(window.location.href)`. `completeLogin` reads and clears the stashed `returnTo`, then calls `blocksClient.auth.idp.callback(callbackUrl)`, passing the full URL so the SDK parses `code`/`state`/`error` itself.
33
- - On the default cookie flow, IAM sets the session as a **Secure, httpOnly cookie** via `Set-Cookie` on this response and returns no token in the body — `completeLogin` only caches a bearer token if the response body actually contains one (a non-default, explicit-token OIDC config). The SDK never stores tokens itself either way; every call sets `credentials: "include"` so the cookie rides along automatically once IAM has set it.
34
- - If `data.error` is present, `completeLogin` returns `{ ok: false, message }` and `CallbackPage` shows an inline error `Alert` plus a button back to `/login` — it never silently strands the user on a blank screen.
35
- 7. On success, `CallbackPage` calls `refresh()` (from `AuthProvider`) and then `onNavigate(result.returnTo)`. `refresh()` calls `fetchSessionClaims()` → `blocksClient.auth.userInfo()` to confirm the cookie actually landed and to populate `claims`/`status` before the app navigates away from the callback screen.
36
-
37
- ## Session state and route guards
38
-
39
- - **`AuthProvider`** is the single source of truth for `status` (`"loading" | "authenticated" | "unauthenticated"`) and `claims`. It calls `refresh()` on mount, every 5 minutes (`STATUS_POLL_MS`, a backup interval — not the primary signal), and immediately whenever the tab regains visibility (catches sign-out in another tab or session expiry while backgrounded). It never inspects local storage to decide auth state — asking IAM directly (`userInfo()`) is the only source of truth, because the default flow holds no locally readable token by design.
40
- - **`RequireAuth`** wraps every protected route in `routes.tsx` (`/`, `/assets`, `/profile`, `/error`). While `status !== "authenticated"` it renders `LoadingScreen`; once `status` resolves to `"unauthenticated"` it navigates to `/login?returnTo=<currentPath>` from a `useEffect` (not render-time — reading `window.location` live at render would double-nest the `returnTo` param under Strict Mode's double-invoked effects).
41
- - **`RedirectIfAuthenticated`** wraps `/login` itself so an already-signed-in user hitting `/login` bounces straight to `/` instead of seeing the login button again.
42
- - Adding a new protected page: add it to the `protectedRoutes` map in `routes.tsx` — it's automatically wrapped in `RequireAuth` and `AppShell` by the existing router code, nothing else to wire.
43
-
44
- ## The `@seliseblocks/client` methods behind all of this
45
-
46
- All under `blocksClient.auth`:
47
-
48
- - **`idp.initiate(request?)`** — starts the flow, returns `{ redirect_uri }`. Uses the client's configured `oidc` defaults (`clientId`, `redirectUri`) unless you pass overrides per call.
49
- - **`idp.redirectToProvider(request?)`** — calls `initiate` then `window.location.assign(...)`. This is what `startLogin` (and therefore the login button) actually calls; reach for this directly in any new login entry point rather than re-implementing initiate+navigate.
50
- - **`idp.callback(callbackUrlOrObject)`** — completes the flow. Pass `window.location.href` directly (what `completeLogin` does), or `{ code, state, error?, error_description? }` if you've parsed the URL yourself. Returns IAM's auth response as-is; the SDK never stores tokens — your app decides what, if anything, to keep (the scaffold keeps nothing in the default cookie flow).
51
- - **`idp.uiConfig()`** — public UI config (e.g. captcha settings). **Not currently called anywhere in the scaffold** — if you're extending `LoginPage` with captcha or tenant-specific login UI, call this before rendering that UI, not before.
52
- - **`oidc.refreshToken(request?)`** — a separate call from the IdP-controller hosted flow, using a refresh-token grant. `getValidAccessToken()` in `lib/blocks/auth.ts` is already wired as the 401-retry/expiry path: it returns a cached, unexpired token if present, otherwise calls this (de-duplicating concurrent callers via `refreshInFlight`) if a refresh token happens to be cached. In the default cookie-only flow there's usually nothing cached to refresh, so this mostly matters for tenants whose OIDC config explicitly returns tokens in the response body.
53
- - **`isAuthenticated()`** — returns a plain boolean. The scaffold's own `fetchSessionClaims()` calls the lower-level `userInfo()` instead (same underlying check) because `AuthProvider` needs the claims payload, not just a boolean — reach for `isAuthenticated()` yourself for a one-off check that doesn't need claims, rather than hand-rolling another call.
54
-
55
- ## Config
56
-
57
- `createBlocksClient` needs an `oidc` block: `clientId` (required), `url` (required — kept for app metadata, not used to build the authorize URL), `redirectUri`/`scope` (optional, default to `${origin}/login/callback` / `openid profile`). The scaffold populates this from `VITE_BLOCKS_OIDC_CLIENT_ID` / `VITE_BLOCKS_OIDC_URL` / `VITE_BLOCKS_OIDC_SCOPE` in `.env`.
58
-
59
- **This `clientId` is the public OIDC client registered for *this app*** — see the sibling **`blocks-iam-sso-oidc-configuration`** skill for how to resolve or create one (`blocks auth oidc-clients list` / `save`, no portal needed). Don't confuse it with `blocks login` itself, which authenticates the CLI with no setup and needs no registration at all (see **blocks-onboarding**) — the two are unrelated and neither can substitute for the other.
60
-
61
- **`--client-id` and `--app-domain` are non-interactive-unsafe when omitted.** `blocks new web`'s client-id and domain resolution both fall back to an interactive selection prompt ("Choose an OIDC client... or create/skip" / "Multiple domains found... choose one") when the flag is missing and there's more than one candidate (or, for the client id, always — even zero candidates offers "Create"/"Skip"). There is no stdin in a non-interactive/agent-driven run, so this hangs waiting for a selection instead of quietly scaffolding with a blank/default value. A blank `oidcClientId` only happens if a human sitting at the terminal interactively picks "Skip". An agent running `blocks new web` should always resolve and pass both `--client-id` and `--app-domain` explicitly up front — see **`blocks-iam-sso-oidc-configuration`** for resolving/creating an OIDC client via `auth oidc-clients list`/`save`, and `project.applications[].domain` (from the project record) for the app domain — rather than omitting either and hoping for a graceful non-interactive default.
62
-
63
- ## Gotchas
64
-
65
- - **Disabled login button, no error** → `isLoginConfigured()` is false, almost always because `VITE_BLOCKS_OIDC_CLIENT_ID` is empty in `.env`. Don't assume `blocks new web` was just run without `--client-id` and "left this blank on purpose" — omitting `--client-id` (or `--app-domain`, when a project has multiple domains) drops into an interactive `selectFromList()` prompt with no graceful non-interactive fallback; in an agent-driven run with no stdin, that hangs rather than scaffolding a blank value. A blank client id only results from a human interactively choosing "Skip." Always pass `--client-id` explicitly (see the Config section above).
66
- - **Login redirects back but the app still shows logged out** → this is an HTTPS/cookie problem, not an app-logic bug — the session cookie is Secure and won't be stored/sent on `http://localhost`. Cross-reference **`blocks-frontend-local-https`** rather than debugging `AuthProvider`.
67
- - **Redirect URI mismatch** → the SDK derives `redirectUri` from `window.location.origin` at runtime; if the app runs under more than one origin (dev HTTPS host, prod domain), the registered OIDC client's `redirect_uris` must list `/login/callback` under **each** of them, or IAM rejects the authorize request for the ones missing.
68
- - **Activation is a separate concern.** Already-activated users go straight through this flow. Only users invited/created inactive via the portal or API need a one-time `/activate` step first — out of scope here, see **`blocks-iam-account`**.
69
- - **Don't add a `RequireAuth`/`RedirectIfAuthenticated` guard around `/login/callback`** — it must stay reachable while the user is still unauthenticated, by design.
70
- - **Don't hand-roll a "check if logged in" fetch** — call `blocksClient.auth.isAuthenticated()` or reuse `AuthProvider`'s `status`/`refresh()`, never infer auth state from `sessionStorage`/`localStorage` (the default flow keeps no readable token there at all).
71
- - **Custom app domain, session never sticks (cookie calls silently fail)** → on a custom (non-`*.seliseblocks.com`) app domain, the hosted-login session cookie is only stored/sent if `VITE_BLOCKS_API_URL` shares the app's registrable domain. The default `https://api.seliseblocks.com` does not share a registrable domain with e.g. `abc.slsblx.com`, so the browser never stores the cross-site cookie and cookie-based calls (`userInfo()`/`/iam/me`, `logout`, the OIDC callback flow this skill documents) silently fail. For a custom domain, `VITE_BLOCKS_API_URL` must be `https://blocksapi.<registrable-domain>` (e.g. `abc.slsblx.com` → `https://blocksapi.slsblx.com`), not the default.
72
-
73
- ## Example trigger prompts
74
-
75
- - "Add a login button and handle the OIDC callback"
76
- - "Why is my login button disabled?"
77
- - "Add a new protected page that requires the user to be signed in"
78
- - "The user gets redirected back from IAM but the app still shows them as logged out"
79
- - "Wire up token refresh for when the session expires"
80
- - "How does this scaffolded app know if someone is logged in?"
1
+ ---
2
+ name: blocks-iam-sso-oidc-implementation
3
+ description: "Extend or debug the hosted SSO/OIDC login flow `blocks new web` scaffolds into every Blocks app: redirectToProvider → `/login/callback` → session, via the single `blocksClient`. Covers `AuthProvider` status/claims, `RequireAuth`/`RedirectIfAuthenticated` guards, and token refresh. Use for a login button, the OIDC callback, protected routes, a disabled login button, redirect loops, or a session that doesn't stick — on an app `blocks new web` already created. Requires a registered OIDC client (`blocks-iam-sso-oidc-configuration`) and HTTPS on the real domain for testing (`blocks-frontend-local-https`)."
4
+ ---
5
+
6
+ # Blocks IAM — SSO / OIDC Implementation (scaffolded frontend)
7
+
8
+ `blocks new web <name>` already generates a complete, working hosted-login flow. Don't reinvent it — read what's there, extend it, or fix it. Every Blocks call in this flow goes through the single `blocksClient` instance (`src/lib/blocks/client.ts`, `@seliseblocks/client`); there is no raw `fetch`/`curl` anywhere in this stack.
9
+
10
+ ## The files, and what each one actually does
11
+
12
+ | File | Role |
13
+ |---|---|
14
+ | `src/lib/blocks/config.ts` | Reads `VITE_BLOCKS_*` env vars; `isLoginConfigured()` = `apiUrl && oidcUrl && oidcClientId` all present |
15
+ | `src/lib/blocks/client.ts` | The one `blocksClient = createBlocksClient({...})` instance, with `oidc: { clientId, scope, url: oidcUrl }` |
16
+ | `src/lib/blocks/auth.ts` | `startLogin`, `completeLogin`, `fetchSessionClaims`, `logout`, `getValidAccessToken` — the session/token logic |
17
+ | `src/lib/blocks/jwt.ts` | `decodeJwtPayload`/`isJwtExpired` — only relevant if a tenant's OIDC config returns bearer tokens in the body |
18
+ | `src/app/providers/AuthProvider.tsx` | React context: `status`/`claims`/`login`/`logout`/`refresh`, polling + visibility-driven refresh |
19
+ | `src/app/router/guards.tsx` | `RequireAuth`, `RedirectIfAuthenticated` |
20
+ | `src/app/router/routes.tsx` | Wires `/login`, `/login/callback`, and the protected route table (`/`, `/assets`, `/profile`, `/error`) |
21
+ | `src/features/auth/LoginPage.tsx` | The login button |
22
+ | `src/features/auth/CallbackPage.tsx` | The `/login/callback` handler |
23
+
24
+ ## The flow, traced through the generated code
25
+
26
+ 1. **Login button.** `LoginPage`'s button calls `useAuth().login(returnTo)`, which is `AuthProvider`'s `login` calling `startLogin(returnTo)` in `lib/blocks/auth.ts`. `startLogin` throws a clear error if `oidcClientId` isn't set (`"Login is not configured. Set VITE_BLOCKS_OIDC_CLIENT_ID in .env."`), stashes `returnTo` (default `"/"`) in `sessionStorage`, then calls `blocksClient.auth.idp.redirectToProvider()` with no arguments — it relies entirely on the client's configured `oidc` defaults.
27
+ - The button itself is `disabled={!configured || pending}` — if `isLoginConfigured()` is false, `LoginPage` renders a warning `Alert` with the exact callback URL (`{origin}/login/callback`) to register, instead of letting the click fail. **"Login button does nothing" is almost always an empty `VITE_BLOCKS_OIDC_CLIENT_ID`.**
28
+ 2. **`redirectToProvider()`** (SDK, `auth-client.ts`) calls `auth.idp.initiate()`, then `window.location.assign(response.redirect_uri)`. `initiate` itself is also directly callable (e.g. to get the URL without immediately navigating, such as opening it in a new tab) but the scaffold never calls it directly; only `redirectToProvider` is wired to the button.
29
+ 3. The user authenticates on Blocks-hosted IAM.
30
+ 4. IAM redirects back to `<origin>/login/callback?code=...&state=...`. That path is the SDK's *default* `redirectUri` — the scaffold's `client.ts` never passes an explicit `redirectUri`, so `createBlocksClient` derives `${window.location.origin}/login/callback` at runtime (see `browserRedirectUri()` in the SDK's `client.ts`). This is exactly the route `routes.tsx` handles, so it lines up with zero config — **but** it means the OIDC client's registered `redirect_uris` must include `/login/callback` under **every origin** this app runs on (dev HTTPS origin and prod origin both — see the scaffold's own README and `blocks-iam-sso-oidc-configuration`).
31
+ 5. `routes.tsx` matches `path === "/login/callback"` and renders `CallbackPage` directly — **not** wrapped in `RequireAuth` or `RedirectIfAuthenticated`, since the user is by definition not yet authenticated when they land here.
32
+ 6. `CallbackPage`'s one-shot effect (guarded with a `useRef` so React 18 Strict Mode's double-invoke doesn't run it twice) calls `completeLogin(window.location.href)`. `completeLogin` reads and clears the stashed `returnTo`, then calls `blocksClient.auth.idp.callback(callbackUrl)`, passing the full URL so the SDK parses `code`/`state`/`error` itself.
33
+ - On the default cookie flow, IAM sets the session as a **Secure, httpOnly cookie** via `Set-Cookie` on this response and returns no token in the body — `completeLogin` only caches a bearer token if the response body actually contains one (a non-default, explicit-token OIDC config). The SDK never stores tokens itself either way; every call sets `credentials: "include"` so the cookie rides along automatically once IAM has set it.
34
+ - If `data.error` is present, `completeLogin` returns `{ ok: false, message }` and `CallbackPage` shows an inline error `Alert` plus a button back to `/login` — it never silently strands the user on a blank screen.
35
+ 7. On success, `CallbackPage` calls `refresh()` (from `AuthProvider`) and then `onNavigate(result.returnTo)`. `refresh()` calls `fetchSessionClaims()` → `blocksClient.auth.userInfo()` to confirm the cookie actually landed and to populate `claims`/`status` before the app navigates away from the callback screen.
36
+
37
+ ## Session state and route guards
38
+
39
+ - **`AuthProvider`** is the single source of truth for `status` (`"loading" | "authenticated" | "unauthenticated"`) and `claims`. It calls `refresh()` on mount, every 5 minutes (`STATUS_POLL_MS`, a backup interval — not the primary signal), and immediately whenever the tab regains visibility (catches sign-out in another tab or session expiry while backgrounded). It never inspects local storage to decide auth state — asking IAM directly (`userInfo()`) is the only source of truth, because the default flow holds no locally readable token by design.
40
+ - **`RequireAuth`** wraps every protected route in `routes.tsx` (`/`, `/assets`, `/profile`, `/error`). While `status !== "authenticated"` it renders `LoadingScreen`; once `status` resolves to `"unauthenticated"` it navigates to `/login?returnTo=<currentPath>` from a `useEffect` (not render-time — reading `window.location` live at render would double-nest the `returnTo` param under Strict Mode's double-invoked effects).
41
+ - **`RedirectIfAuthenticated`** wraps `/login` itself so an already-signed-in user hitting `/login` bounces straight to `/` instead of seeing the login button again.
42
+ - Adding a new protected page: add it to the `protectedRoutes` map in `routes.tsx` — it's automatically wrapped in `RequireAuth` and `AppShell` by the existing router code, nothing else to wire.
43
+
44
+ ## The `@seliseblocks/client` methods behind all of this
45
+
46
+ All under `blocksClient.auth`:
47
+
48
+ - **`idp.initiate(request?)`** — starts the flow, returns `{ redirect_uri }`. Uses the client's configured `oidc` defaults (`clientId`, `redirectUri`) unless you pass overrides per call.
49
+ - **`idp.redirectToProvider(request?)`** — calls `initiate` then `window.location.assign(...)`. This is what `startLogin` (and therefore the login button) actually calls; reach for this directly in any new login entry point rather than re-implementing initiate+navigate.
50
+ - **`idp.callback(callbackUrlOrObject)`** — completes the flow. Pass `window.location.href` directly (what `completeLogin` does), or `{ code, state, error?, error_description? }` if you've parsed the URL yourself. Returns IAM's auth response as-is; the SDK never stores tokens — your app decides what, if anything, to keep (the scaffold keeps nothing in the default cookie flow).
51
+ - **`idp.uiConfig()`** — public UI config (e.g. captcha settings). **Not currently called anywhere in the scaffold** — if you're extending `LoginPage` with captcha or tenant-specific login UI, call this before rendering that UI, not before.
52
+ - **`oidc.refreshToken(request?)`** — a separate call from the IdP-controller hosted flow, using a refresh-token grant. `getValidAccessToken()` in `lib/blocks/auth.ts` is already wired as the 401-retry/expiry path: it returns a cached, unexpired token if present, otherwise calls this (de-duplicating concurrent callers via `refreshInFlight`) if a refresh token happens to be cached. In the default cookie-only flow there's usually nothing cached to refresh, so this mostly matters for tenants whose OIDC config explicitly returns tokens in the response body.
53
+ - **`isAuthenticated()`** — returns a plain boolean. The scaffold's own `fetchSessionClaims()` calls the lower-level `userInfo()` instead (same underlying check) because `AuthProvider` needs the claims payload, not just a boolean — reach for `isAuthenticated()` yourself for a one-off check that doesn't need claims, rather than hand-rolling another call.
54
+
55
+ ## Config
56
+
57
+ `createBlocksClient` needs an `oidc` block: `clientId` (required), `url` (required — kept for app metadata, not used to build the authorize URL), `redirectUri`/`scope` (optional, default to `${origin}/login/callback` / `openid profile`). The scaffold populates this from `VITE_BLOCKS_OIDC_CLIENT_ID` / `VITE_BLOCKS_OIDC_URL` / `VITE_BLOCKS_OIDC_SCOPE` in `.env`.
58
+
59
+ **This `clientId` is the public OIDC client registered for *this app*** — see the sibling **`blocks-iam-sso-oidc-configuration`** skill for how to resolve or create one (`blocks auth oidc-clients list` / `save`, no portal needed). Don't confuse it with `blocks login` itself, which authenticates the CLI with no setup and needs no registration at all (see **blocks-onboarding**) — the two are unrelated and neither can substitute for the other.
60
+
61
+ **`--client-id` and `--app-domain` are non-interactive-unsafe when omitted.** `blocks new web`'s client-id and domain resolution both fall back to an interactive selection prompt ("Choose an OIDC client... or create/skip" / "Multiple domains found... choose one") when the flag is missing and there's more than one candidate (or, for the client id, always — even zero candidates offers "Create"/"Skip"). There is no stdin in a non-interactive/agent-driven run, so this hangs waiting for a selection instead of quietly scaffolding with a blank/default value. A blank `oidcClientId` only happens if a human sitting at the terminal interactively picks "Skip". An agent running `blocks new web` should always resolve and pass both `--client-id` and `--app-domain` explicitly up front — see **`blocks-iam-sso-oidc-configuration`** for resolving/creating an OIDC client via `auth oidc-clients list`/`save`, and `project.applications[].domain` (from the project record) for the app domain — rather than omitting either and hoping for a graceful non-interactive default.
62
+
63
+ ## Gotchas
64
+
65
+ - **Disabled login button, no error** → `isLoginConfigured()` is false, almost always because `VITE_BLOCKS_OIDC_CLIENT_ID` is empty in `.env`. Don't assume `blocks new web` was just run without `--client-id` and "left this blank on purpose" — omitting `--client-id` (or `--app-domain`, when a project has multiple domains) drops into an interactive `selectFromList()` prompt with no graceful non-interactive fallback; in an agent-driven run with no stdin, that hangs rather than scaffolding a blank value. A blank client id only results from a human interactively choosing "Skip." Always pass `--client-id` explicitly (see the Config section above).
66
+ - **Login redirects back but the app still shows logged out** → this is an HTTPS/cookie problem, not an app-logic bug — the session cookie is Secure and won't be stored/sent on `http://localhost`. Cross-reference **`blocks-frontend-local-https`** rather than debugging `AuthProvider`.
67
+ - **Redirect URI mismatch** → the SDK derives `redirectUri` from `window.location.origin` at runtime; if the app runs under more than one origin (dev HTTPS host, prod domain), the registered OIDC client's `redirect_uris` must list `/login/callback` under **each** of them, or IAM rejects the authorize request for the ones missing.
68
+ - **Activation is a separate concern.** Already-activated users go straight through this flow. Only users invited/created inactive via the portal or API need a one-time `/activate` step first — out of scope here, see **`blocks-iam-account`**.
69
+ - **Don't add a `RequireAuth`/`RedirectIfAuthenticated` guard around `/login/callback`** — it must stay reachable while the user is still unauthenticated, by design.
70
+ - **Don't hand-roll a "check if logged in" fetch** — call `blocksClient.auth.isAuthenticated()` or reuse `AuthProvider`'s `status`/`refresh()`, never infer auth state from `sessionStorage`/`localStorage` (the default flow keeps no readable token there at all).
71
+ - **Custom app domain, session never sticks (cookie calls silently fail)** → on a custom (non-`*.seliseblocks.com`) app domain, the hosted-login session cookie is only stored/sent if `VITE_BLOCKS_API_URL` shares the app's registrable domain. The default `https://api.seliseblocks.com` does not share a registrable domain with e.g. `abc.slsblx.com`, so the browser never stores the cross-site cookie and cookie-based calls (`userInfo()`/`/iam/me`, `logout`, the OIDC callback flow this skill documents) silently fail. For a custom domain, `VITE_BLOCKS_API_URL` must be `https://blocksapi.<registrable-domain>` (e.g. `abc.slsblx.com` → `https://blocksapi.slsblx.com`), not the default.
72
+
73
+ ## Example trigger prompts
74
+
75
+ - "Add a login button and handle the OIDC callback"
76
+ - "Why is my login button disabled?"
77
+ - "Add a new protected page that requires the user to be signed in"
78
+ - "The user gets redirected back from IAM but the app still shows them as logged out"
79
+ - "Wire up token refresh for when the session expires"
80
+ - "How does this scaffolded app know if someone is logged in?"
@@ -1,131 +1,131 @@
1
- ---
2
- name: blocks-iam-users
3
- description: "Manage OTHER users' IAM records via `blocksClient.iam.users.*` (never raw fetch/curl), or the equivalent project-scoped `blocks iam users *` / `blocks iam email available` CLI. Covers reads (`get`, `list`, `emailAvailable`, `exists`) and admin mutations (`create`, `update`, `activate`, `deactivate`, `updateAccess`, `revokeAccess`) — CLI mutations require `--dry-run`/`--yes`. Use to invite, edit, deactivate/reactivate, list/search users, or grant/revoke roles/org access. Not for the current user's own profile (blocks-iam-account) or role/permission definitions (blocks-iam-access-control)."
4
- ---
5
-
6
- # Blocks IAM — Managing Other Users
7
-
8
- This skill is about an **admin managing other people's IAM accounts** from inside a Blocks app — inviting them, editing their profile, changing their access, deactivating them. It is not about the signed-in user managing their own account (that's the **blocks-iam-account** skill) and not about defining the roles/permissions being assigned (that's **blocks-iam-access-control**).
9
-
10
- Everything here goes through the SDK: `blocksClient.iam.users.*` on the app's single `@seliseblocks/client` instance (created once, typically at `src/lib/blocks/client.ts` by `blocks new web`). **Never raw `fetch`/`curl` against `api.seliseblocks.com`.**
11
-
12
- ```ts
13
- import { blocksClient } from "../../lib/blocks/client";
14
-
15
- const { data } = await blocksClient.iam.users.get(userId);
16
- ```
17
-
18
- ## Two surfaces, same operations: SDK (in-app) and CLI (`blocks iam users *`)
19
-
20
- There are two legitimate ways to drive full user administration (create, update, deactivate, activate, access grant/revoke) — both are covered by this skill:
21
-
22
- - **SDK — `blocksClient.iam.users.*`** — build the capability **as a feature inside a signed-in admin's own app**: the admin is looking at a screen, clicking "Deactivate" on a specific user row, and their own IAM permissions gate whether the call succeeds.
23
- - **CLI — `blocks iam users *` / `blocks iam email available`** — the same operations, invoked directly from a terminal or an agent's shell tool. These are fully wired, project-scoped commands (see "CLI surface" below), not a read-only stub — `iam me` is a separate, account-scoped command for the CLI operator's own identity and is not the only IAM command the CLI has.
24
-
25
- What is **not** legitimate on either surface: an agent deciding on its own, without the human explicitly directing that specific action in the moment, to call `create`/`update`/`deactivate`/`activate`/`updateAccess`/`revokeAccess` (SDK) or `users create`/`update`/`activate`/`deactivate`/`access grant`/`access revoke` (CLI). State the exact change in plain language and get the user's explicit go-ahead first, every time, even if they asked for something adjacent a moment ago. The CLI enforces this mechanically — every mutating command requires `--dry-run` (preview only, no call) or `--yes`/an interactive "yes" before it executes — but that built-in gate doesn't replace stating the change and getting a real go-ahead when an agent is the one typing the command.
26
-
27
- ## Safe surface — reads and checks, no confirmation needed
28
-
29
- These don't change anything, so there's no caveat to apply:
30
-
31
- | Method | What it does |
32
- |---|---|
33
- | `iam.users.get(id, { organizationId? })` | One user record, optionally scoped to an org. |
34
- | `iam.users.list(request)` | Paged/filtered user query. **This is a POST-read contract** — `list` sends `{ pageNo, pageSize, filter, search, ... }` as a POST body, it is not a GET. |
35
- | `iam.users.emailAvailable(query)` | Public duplicate-email check for invite/signup forms. No auth needed. |
36
- | `iam.users.exists(email)` | Existence check by email. |
37
-
38
- ```ts
39
- const page = await blocksClient.iam.users.list({ pageNo: 1, pageSize: 20, search: "jane" });
40
- const check = await blocksClient.iam.users.emailAvailable({ email: "new.hire@example.com" });
41
- ```
42
-
43
- ## Sensitive surface — confirm the exact change before calling
44
-
45
- Every method below mutates a real account. Before calling any of them, restate to the user in plain language exactly what will change (which user, which field, which effect) and wait for an explicit yes — do not infer consent from an earlier, more general request.
46
-
47
- | Method | What it does |
48
- |---|---|
49
- | `iam.users.create(request)` | Invites/provisions a user in the active tenant/organization. |
50
- | `iam.users.update(id, request)` | Edits an IAM profile's fields. |
51
- | `iam.users.deactivate(request)` | Removes access without deleting the record. |
52
- | `iam.users.activate(request)` | Restores access for a previously deactivated account. |
53
- | `iam.users.updateAccess(request)` | Grants or changes roles/permissions/org access for a user. |
54
- | `iam.users.revokeAccess(request)` | Removes roles/permissions/org access from a user. |
55
-
56
- Example — deactivating a user:
57
-
58
- > Agent: "This will deactivate **jane.doe@example.com** (user id `usr_8a2f`) — she'll immediately lose access but her record and history stay intact. Confirm?"
59
- > User: "Yes, deactivate her."
60
- > *(only then)* `await blocksClient.iam.users.deactivate({ userId: "usr_8a2f" });`
61
-
62
- Never chain a mutation straight off a read (e.g. don't look a user up and deactivate them in the same breath just because the user asked to "find inactive-looking accounts") — surface what you found, then get a decision on each mutation separately.
63
-
64
- ```ts
65
- // After the user explicitly confirms creating this exact invite:
66
- await blocksClient.iam.users.create({
67
- email: "new.hire@example.com",
68
- firstName: "New",
69
- lastName: "Hire",
70
- roles: ["member"]
71
- });
72
-
73
- // After the user explicitly confirms this exact access change:
74
- await blocksClient.iam.users.updateAccess({ userId: "usr_8a2f", roles: ["editor"] });
75
- ```
76
-
77
- ## CLI surface — `blocks iam users *`, `blocks iam email available`
78
-
79
- These are real, fully-wired commands — not a stub and not limited to `iam me`. `iam me` is a separate, account-scoped command (current CLI operator's own identity via the account token); every command below is **project-scoped**: it requires a project already selected (`blocks use <project-tenant-id>`) and calls IAM with an impersonated project token, same as the rest of the project-scoped CLI surface.
80
-
81
- Reads — no confirmation needed:
82
-
83
- | Command | What it does |
84
- |---|---|
85
- | `blocks iam users list [--page 1] [--page-size 20] [--email <e>] [--name <n>] [--organization-id <id>] [--sort-by <field>] [--sort-desc] [--filter '<json>'] [--json]` | Paged/filtered user query. `--filter` merges a raw JSON object over the convenience flags. |
86
- | `blocks iam users get <id> [--organization-id <id>] [--json]` | One user record, optionally scoped to an org. |
87
- | `blocks iam users exists <email> [--json]` | Existence check by email. |
88
- | `blocks iam email available <email> [--json]` | Duplicate-email check. |
89
-
90
- Mutations — every one supports `--dry-run` (print the request body and exit, no call) and requires either `--yes` or a typed `yes` at an interactive prompt before it executes:
91
-
92
- | Command | What it does |
93
- |---|---|
94
- | `blocks iam users create --email <e>\|--user-name <n> [--first-name] [--last-name] [--password] [--phone-number] [--organization-id] [--roles a,b] [--permissions a,b] [--body '<json>'\|--file <path>] [--dry-run] [--yes] [--json]` | Invites/provisions a user. |
95
- | `blocks iam users update <id> [--first-name] [--last-name] [--phone-number] [--organization-id] [--roles a,b] [--permissions a,b] [--body '<json>'\|--file <path>] [--dry-run] [--yes] [--json]` | Edits an IAM profile's fields. |
96
- | `blocks iam users activate <userId> [--reason <text>] [--dry-run] [--yes] [--json]` | Restores access for a previously deactivated account. |
97
- | `blocks iam users deactivate <userId> [--dry-run] [--yes] [--json]` | Removes access without deleting the record. |
98
- | `blocks iam users access grant <userId> [--roles a,b] [--permissions a,b] [--organization-id] [--dry-run] [--yes] [--json]` | Grants roles/permissions/org access (requires at least one of `--roles`/`--permissions`). |
99
- | `blocks iam users access revoke <userId> [--organization-id] [--dry-run] [--yes] [--json]` | Revokes org access for a user. |
100
-
101
- Command segments joined by a space also accept a colon (`iam:users:access:grant` etc.) — both forms resolve to the same handler; `blocks iam users --help`-style docs in the CLI's own `--help` output use the space form shown above.
102
-
103
- Example — deactivating a user from the CLI, dry-run first:
104
-
105
- ```sh
106
- blocks iam users deactivate usr_8a2f --dry-run # preview the request body, no call made
107
- blocks iam users deactivate usr_8a2f --yes # after the user explicitly confirms
108
- ```
109
-
110
- Apply the same confirm-before-mutating discipline here as with the SDK: state which user and which effect, wait for an explicit yes, don't chain a mutating command straight off a `list`/`get` just because the user asked to "find" something.
111
-
112
- ## Gotchas
113
-
114
- - **`list` is a POST**, not a GET — don't assume query-string filtering.
115
- - **Roles are referenced by slug**, as defined in blocks-iam-access-control — not by their internal item ids.
116
- - **`organizationId`** matters in multi-org projects — pass it to `get` when you need a user's record in a specific org context.
117
- - **Every request/response type in the SDK is a loosely-typed `Record<string, unknown>`** (`BlocksUser`, `BlocksBaseResponse`, etc. only guarantee a few common fields) — treat fields defensively and confirm shape against a live response for the project rather than assuming a fixed schema.
118
- - **The CLI is project-scoped, not account-scoped** — `blocks iam users *`/`blocks iam email available` need a selected project (`blocks use <project-tenant-id>`) and use an impersonated project token; `iam me` is the one exception that runs on the account token instead.
119
- - **Don't duplicate blocks-iam-account** — if the ask is "let me update my own profile" or "let me reset my password," that's the current user acting on themselves, not this skill.
120
-
121
- ## Example triggers
122
-
123
- - "Invite a user and set their roles"
124
- - "Deactivate this user's account"
125
- - "List all users in the org, filtered by status"
126
- - "Check if this email is already registered before I show the invite form"
127
- - "Grant this user the editor role"
128
- - "Revoke this user's access to the finance org"
129
- - "Update this user's phone number"
130
- - "Reactivate this account"
131
- - "From the terminal, deactivate user usr_8a2f in the current project" → use `blocks iam users deactivate usr_8a2f`, `--dry-run` first, then `--yes` after explicit confirmation
1
+ ---
2
+ name: blocks-iam-users
3
+ description: "Manage OTHER users' IAM records via `blocksClient.iam.users.*` (never raw fetch/curl), or the equivalent project-scoped `blocks iam users *` / `blocks iam email available` CLI. Covers reads (`get`, `list`, `emailAvailable`, `exists`) and admin mutations (`create`, `update`, `activate`, `deactivate`, `updateAccess`, `revokeAccess`) — CLI mutations require `--dry-run`/`--yes`. Use to invite, edit, deactivate/reactivate, list/search users, or grant/revoke roles/org access. Not for the current user's own profile (blocks-iam-account) or role/permission definitions (blocks-iam-access-control)."
4
+ ---
5
+
6
+ # Blocks IAM — Managing Other Users
7
+
8
+ This skill is about an **admin managing other people's IAM accounts** from inside a Blocks app — inviting them, editing their profile, changing their access, deactivating them. It is not about the signed-in user managing their own account (that's the **blocks-iam-account** skill) and not about defining the roles/permissions being assigned (that's **blocks-iam-access-control**).
9
+
10
+ Everything here goes through the SDK: `blocksClient.iam.users.*` on the app's single `@seliseblocks/client` instance (created once, typically at `src/lib/blocks/client.ts` by `blocks new web`). **Never raw `fetch`/`curl` against `api.seliseblocks.com`.**
11
+
12
+ ```ts
13
+ import { blocksClient } from "../../lib/blocks/client";
14
+
15
+ const { data } = await blocksClient.iam.users.get(userId);
16
+ ```
17
+
18
+ ## Two surfaces, same operations: SDK (in-app) and CLI (`blocks iam users *`)
19
+
20
+ There are two legitimate ways to drive full user administration (create, update, deactivate, activate, access grant/revoke) — both are covered by this skill:
21
+
22
+ - **SDK — `blocksClient.iam.users.*`** — build the capability **as a feature inside a signed-in admin's own app**: the admin is looking at a screen, clicking "Deactivate" on a specific user row, and their own IAM permissions gate whether the call succeeds.
23
+ - **CLI — `blocks iam users *` / `blocks iam email available`** — the same operations, invoked directly from a terminal or an agent's shell tool. These are fully wired, project-scoped commands (see "CLI surface" below), not a read-only stub — `iam me` is a separate, account-scoped command for the CLI operator's own identity and is not the only IAM command the CLI has.
24
+
25
+ What is **not** legitimate on either surface: an agent deciding on its own, without the human explicitly directing that specific action in the moment, to call `create`/`update`/`deactivate`/`activate`/`updateAccess`/`revokeAccess` (SDK) or `users create`/`update`/`activate`/`deactivate`/`access grant`/`access revoke` (CLI). State the exact change in plain language and get the user's explicit go-ahead first, every time, even if they asked for something adjacent a moment ago. The CLI enforces this mechanically — every mutating command requires `--dry-run` (preview only, no call) or `--yes`/an interactive "yes" before it executes — but that built-in gate doesn't replace stating the change and getting a real go-ahead when an agent is the one typing the command.
26
+
27
+ ## Safe surface — reads and checks, no confirmation needed
28
+
29
+ These don't change anything, so there's no caveat to apply:
30
+
31
+ | Method | What it does |
32
+ |---|---|
33
+ | `iam.users.get(id, { organizationId? })` | One user record, optionally scoped to an org. |
34
+ | `iam.users.list(request)` | Paged/filtered user query. **This is a POST-read contract** — `list` sends `{ pageNo, pageSize, filter, search, ... }` as a POST body, it is not a GET. |
35
+ | `iam.users.emailAvailable(query)` | Public duplicate-email check for invite/signup forms. No auth needed. |
36
+ | `iam.users.exists(email)` | Existence check by email. |
37
+
38
+ ```ts
39
+ const page = await blocksClient.iam.users.list({ pageNo: 1, pageSize: 20, search: "jane" });
40
+ const check = await blocksClient.iam.users.emailAvailable({ email: "new.hire@example.com" });
41
+ ```
42
+
43
+ ## Sensitive surface — confirm the exact change before calling
44
+
45
+ Every method below mutates a real account. Before calling any of them, restate to the user in plain language exactly what will change (which user, which field, which effect) and wait for an explicit yes — do not infer consent from an earlier, more general request.
46
+
47
+ | Method | What it does |
48
+ |---|---|
49
+ | `iam.users.create(request)` | Invites/provisions a user in the active tenant/organization. |
50
+ | `iam.users.update(id, request)` | Edits an IAM profile's fields. |
51
+ | `iam.users.deactivate(request)` | Removes access without deleting the record. |
52
+ | `iam.users.activate(request)` | Restores access for a previously deactivated account. |
53
+ | `iam.users.updateAccess(request)` | Grants or changes roles/permissions/org access for a user. |
54
+ | `iam.users.revokeAccess(request)` | Removes roles/permissions/org access from a user. |
55
+
56
+ Example — deactivating a user:
57
+
58
+ > Agent: "This will deactivate **jane.doe@example.com** (user id `usr_8a2f`) — she'll immediately lose access but her record and history stay intact. Confirm?"
59
+ > User: "Yes, deactivate her."
60
+ > *(only then)* `await blocksClient.iam.users.deactivate({ userId: "usr_8a2f" });`
61
+
62
+ Never chain a mutation straight off a read (e.g. don't look a user up and deactivate them in the same breath just because the user asked to "find inactive-looking accounts") — surface what you found, then get a decision on each mutation separately.
63
+
64
+ ```ts
65
+ // After the user explicitly confirms creating this exact invite:
66
+ await blocksClient.iam.users.create({
67
+ email: "new.hire@example.com",
68
+ firstName: "New",
69
+ lastName: "Hire",
70
+ roles: ["member"]
71
+ });
72
+
73
+ // After the user explicitly confirms this exact access change:
74
+ await blocksClient.iam.users.updateAccess({ userId: "usr_8a2f", roles: ["editor"] });
75
+ ```
76
+
77
+ ## CLI surface — `blocks iam users *`, `blocks iam email available`
78
+
79
+ These are real, fully-wired commands — not a stub and not limited to `iam me`. `iam me` is a separate, account-scoped command (current CLI operator's own identity via the account token); every command below is **project-scoped**: it requires a project already selected (`blocks use <project-tenant-id>`) and calls IAM with an impersonated project token, same as the rest of the project-scoped CLI surface.
80
+
81
+ Reads — no confirmation needed:
82
+
83
+ | Command | What it does |
84
+ |---|---|
85
+ | `blocks iam users list [--page 1] [--page-size 20] [--email <e>] [--name <n>] [--organization-id <id>] [--sort-by <field>] [--sort-desc] [--filter '<json>'] [--json]` | Paged/filtered user query. `--filter` merges a raw JSON object over the convenience flags. |
86
+ | `blocks iam users get <id> [--organization-id <id>] [--json]` | One user record, optionally scoped to an org. |
87
+ | `blocks iam users exists <email> [--json]` | Existence check by email. |
88
+ | `blocks iam email available <email> [--json]` | Duplicate-email check. |
89
+
90
+ Mutations — every one supports `--dry-run` (print the request body and exit, no call) and requires either `--yes` or a typed `yes` at an interactive prompt before it executes:
91
+
92
+ | Command | What it does |
93
+ |---|---|
94
+ | `blocks iam users create --email <e>\|--user-name <n> [--first-name] [--last-name] [--password] [--phone-number] [--organization-id] [--roles a,b] [--permissions a,b] [--body '<json>'\|--file <path>] [--dry-run] [--yes] [--json]` | Invites/provisions a user. |
95
+ | `blocks iam users update <id> [--first-name] [--last-name] [--phone-number] [--organization-id] [--roles a,b] [--permissions a,b] [--body '<json>'\|--file <path>] [--dry-run] [--yes] [--json]` | Edits an IAM profile's fields. |
96
+ | `blocks iam users activate <userId> [--reason <text>] [--dry-run] [--yes] [--json]` | Restores access for a previously deactivated account. |
97
+ | `blocks iam users deactivate <userId> [--dry-run] [--yes] [--json]` | Removes access without deleting the record. |
98
+ | `blocks iam users access grant <userId> [--roles a,b] [--permissions a,b] [--organization-id] [--dry-run] [--yes] [--json]` | Grants roles/permissions/org access (requires at least one of `--roles`/`--permissions`). |
99
+ | `blocks iam users access revoke <userId> [--organization-id] [--dry-run] [--yes] [--json]` | Revokes org access for a user. |
100
+
101
+ Command segments joined by a space also accept a colon (`iam:users:access:grant` etc.) — both forms resolve to the same handler; `blocks iam users --help`-style docs in the CLI's own `--help` output use the space form shown above.
102
+
103
+ Example — deactivating a user from the CLI, dry-run first:
104
+
105
+ ```sh
106
+ blocks iam users deactivate usr_8a2f --dry-run # preview the request body, no call made
107
+ blocks iam users deactivate usr_8a2f --yes # after the user explicitly confirms
108
+ ```
109
+
110
+ Apply the same confirm-before-mutating discipline here as with the SDK: state which user and which effect, wait for an explicit yes, don't chain a mutating command straight off a `list`/`get` just because the user asked to "find" something.
111
+
112
+ ## Gotchas
113
+
114
+ - **`list` is a POST**, not a GET — don't assume query-string filtering.
115
+ - **Roles are referenced by slug**, as defined in blocks-iam-access-control — not by their internal item ids.
116
+ - **`organizationId`** matters in multi-org projects — pass it to `get` when you need a user's record in a specific org context.
117
+ - **Every request/response type in the SDK is a loosely-typed `Record<string, unknown>`** (`BlocksUser`, `BlocksBaseResponse`, etc. only guarantee a few common fields) — treat fields defensively and confirm shape against a live response for the project rather than assuming a fixed schema.
118
+ - **The CLI is project-scoped, not account-scoped** — `blocks iam users *`/`blocks iam email available` need a selected project (`blocks use <project-tenant-id>`) and use an impersonated project token; `iam me` is the one exception that runs on the account token instead.
119
+ - **Don't duplicate blocks-iam-account** — if the ask is "let me update my own profile" or "let me reset my password," that's the current user acting on themselves, not this skill.
120
+
121
+ ## Example triggers
122
+
123
+ - "Invite a user and set their roles"
124
+ - "Deactivate this user's account"
125
+ - "List all users in the org, filtered by status"
126
+ - "Check if this email is already registered before I show the invite form"
127
+ - "Grant this user the editor role"
128
+ - "Revoke this user's access to the finance org"
129
+ - "Update this user's phone number"
130
+ - "Reactivate this account"
131
+ - "From the terminal, deactivate user usr_8a2f in the current project" → use `blocks iam users deactivate usr_8a2f`, `--dry-run` first, then `--yes` after explicit confirmation