@close-by/clay 0.2.22 → 0.2.24

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/docs/api.md CHANGED
@@ -99,6 +99,7 @@ ui.run({
99
99
  | `sessionAbsoluteMs` | `number` | | Optional: clear auth + redirect after this time since hello |
100
100
  | `sessionExpiredPath` | `string` | `'/'` | SPA path after idle/absolute expiry |
101
101
  | `resolveUserId` | `(ctx) => …` | cookie helper when `authSecret` set | Trusted identity on WebSocket hello |
102
+ | `getRoles` | `() => string \| string[] \| null` | | Current user's roles. Enables `ui.forRoles`, `ui.hasRole`, `.roles()`, and default filtering in `ui.navFromPages()` / `requireRole` — see [Production — role-based content](./production.md#role-based-content) |
102
103
  | `routes` | `Record<path, (req, ctx) => Response>` | | Extra HTTP routes, exact path, any method. `ctx.signIn(userId, location)` / `ctx.signOut(location)` redirect and set/clear the auth cookie. Used for OAuth callbacks — see [Production — single sign-on](./production.md#single-sign-on-oidc) |
103
104
 
104
105
  #### Auth cookie recipe
@@ -1816,6 +1817,8 @@ onClick: async () => {
1816
1817
  | `ui.updateUrlSearch(patch, opts?)` | `void` | Merge query keys; `null`/`''` deletes |
1817
1818
  | `ui.urlState(defaults, opts?)` | reactive object | `ui.state` + hydrate/sync query string (prefer this) |
1818
1819
  | `ui.openExternal(url)` | `void` | `window.open` in a new tab (`noopener`) |
1820
+ | `ui.forRoles(roles, build, fallback?)` | `void` | Run `build` only for users with one of `roles`, else `fallback`; skipped content never reaches the browser |
1821
+ | `ui.hasRole(roles)` | `boolean` | Current user has one of `roles` |
1819
1822
  | `ui.redirect(url)` | `void` | Full-page navigation in the current tab (`location.assign`) — for sign-in routes / external pages; use `ui.navigate` for app pages |
1820
1823
  | `ui.theme.set(mode)` | `void` | Push `light` \| `dark` \| `system` to this client |
1821
1824
  | `ui.theme.get()` | `ThemeMode \| null` | Last value set on this session |
@@ -2057,6 +2060,7 @@ Returned by every `ui.*` factory.
2057
2060
  | Method | Description |
2058
2061
  |--------|-------------|
2059
2062
  | `.classes(...names)` | Append Tailwind/utility classes; patches `className` |
2063
+ | `.roles(list)` | Keep only for users with one of the roles (`ui.run({ getRoles })`); otherwise removed before it reaches the client. Call while building |
2060
2064
  | `.style(string \| Record)` | Set inline style string / object |
2061
2065
  | `.on(event, handler)` | Register additional event handler |
2062
2066
  | `.setText(text)` | Set `props.text` and patch |
@@ -65,6 +65,35 @@ ui.page('/admin', () => {
65
65
 
66
66
  Nav filtering is not authorization. Always guard pages that must not leak data.
67
67
 
68
+ ### Role-based content
69
+
70
+ Tell Clay how to read the current user's roles once, then show or hide parts of a page by role:
71
+
72
+ ```ts
73
+ ui.run({
74
+ authSecret,
75
+ getRoles: () => getSessionUser()?.roles ?? [], // string, string[] or null
76
+ });
77
+
78
+ ui.page('/projects', () => {
79
+ if (!requireAuth()) return;
80
+
81
+ ui.forRoles(['admin', 'manager'], () => {
82
+ ui.button('Delete project', { onClick: deleteProject });
83
+ ui.dataTable(auditRows);
84
+ }, () => ui.label('Ask an admin for access')); // optional fallback
85
+
86
+ ui.button('Approve', { onClick: approve }).roles(['admin']);
87
+ if (ui.hasRole('admin')) { /* plain check */ }
88
+ });
89
+ ```
90
+
91
+ - Checks run on the server while the page builds. Hidden content is never sent to the browser, and its handlers don't exist, so they can't be triggered.
92
+ - With `getRoles` set, `ui.navFromPages()` (no arguments) and `requireRole(...)` use the same roles — no need to pass them separately.
93
+ - Roles are read when the page builds. Signing in or out reconnects and rebuilds, so the UI follows. A role changed while someone is signed in applies on their next page load.
94
+ - Without `getRoles`, role checks treat the user as having no roles (fail closed) and log a warning once.
95
+ - Still check the role inside handlers that change data — cheap protection against mistakes elsewhere.
96
+
68
97
  ## Single sign-on (OIDC)
69
98
 
70
99
  `@close-by/clay-auth` signs users in with any OpenID Connect provider (authorization code + PKCE). The provider is only used to sign in: once its ID token is verified, Clay sets its usual signed auth cookie, so everything above (`resolveUserId`, guards, idle expiry) works unchanged and there are no provider tokens to refresh.
@@ -95,6 +124,8 @@ ui.button('Sign in', { onClick: () => ui.redirect('/auth/login?returnTo=/reports
95
124
  ui.button('Sign out', { onClick: () => ui.redirect('/auth/logout') });
96
125
  ```
97
126
 
127
+ All options are plain values — `process.env` above is just one source. Pass strings from a config file, a secrets manager, or literals; the library reads no environment variables itself. Keep `clientSecret` and `authSecret` out of source control.
128
+
98
129
  Routes: `GET /auth/login` (accepts `?returnTo=/path`, same-origin paths only) → provider → `GET /auth/callback` → cookie set → `returnTo`. `GET /auth/logout` clears the cookie and goes to the provider's sign-out. Failures land on `errorPath?auth_error=<state|expired|invalid_token|denied|provider|…>`.
99
130
 
100
131
  What is checked: `state` (one-shot HttpOnly cookie scoped to the callback path, 10 min), PKCE `S256`, ID token RS256 signature against the provider's JWKS (re-fetched on unknown `kid`), `iss`, `aud`, `exp`/`iat` (60 s skew), and `nonce`.
@@ -105,7 +136,7 @@ What is checked: `state` (one-shot HttpOnly cookie scoped to the callback path,
105
136
 
106
137
  **Behind an authenticating proxy:** if a reverse proxy or load balancer already signs users in and forwards a signed identity header, skip the routes and read (and verify) that header in `resolveUserId`.
107
138
 
108
- The demo enables a "Sign in with SSO" button on `/examples/auth/login` when `OIDC_ISSUER` and `OIDC_CLIENT_ID` are set (see `apps/demo/src/examples/_oidc.ts` for the optional variables).
139
+ The demo (which, unlike the library, reads env variables for convenience) enables a "Sign in with SSO" button on `/examples/auth/login` when `OIDC_ISSUER` and `OIDC_CLIENT_ID` are set (see `apps/demo/src/examples/_oidc.ts` for the optional variables).
109
140
 
110
141
  ## Rate limits & abuse
111
142
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@close-by/clay",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "description": "NiceGUI-like ui.* facade for Clay apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,10 +37,10 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@close-by/clay-compiler": "0.2.22",
41
- "@close-by/clay-core": "0.2.22",
42
- "@close-by/clay-components": "0.2.22",
43
- "@close-by/clay-server": "0.2.22",
40
+ "@close-by/clay-compiler": "0.2.24",
41
+ "@close-by/clay-core": "0.2.24",
42
+ "@close-by/clay-components": "0.2.24",
43
+ "@close-by/clay-server": "0.2.24",
44
44
  "@tailwindcss/cli": "^4.3.3",
45
45
  "tailwindcss": "^4.3.3"
46
46
  }
package/src/index.ts CHANGED
@@ -465,6 +465,10 @@ import {
465
465
  updateUrlSearch as updateUrlSearchCore,
466
466
  openExternal as openExternalCore,
467
467
  redirect as redirectCore,
468
+ setRoleResolver,
469
+ hasRole as hasRoleCore,
470
+ forRoles as forRolesCore,
471
+ type RoleResolver,
468
472
  scroll as scrollCore,
469
473
  timer as timerCore,
470
474
  storage as storageCore,
@@ -1690,6 +1694,30 @@ export function redirect(url: string): void {
1690
1694
  redirectCore(url);
1691
1695
  }
1692
1696
 
1697
+ /** True when the current user has at least one of `roles` (needs `ui.run({ getRoles })`). */
1698
+ export function hasRole(roles: string | readonly string[]): boolean {
1699
+ return hasRoleCore(roles);
1700
+ }
1701
+
1702
+ /**
1703
+ * Build `content` only for users with one of `roles`, else `fallback`.
1704
+ * Skipped content is never sent to the browser. Needs `ui.run({ getRoles })`.
1705
+ *
1706
+ * @example
1707
+ * ```ts
1708
+ * ui.forRoles(['admin', 'manager'], () => {
1709
+ * ui.button('Delete project', { onClick: remove });
1710
+ * }, () => ui.label('Ask an admin for access'));
1711
+ * ```
1712
+ */
1713
+ export function forRoles(
1714
+ roles: string | readonly string[],
1715
+ content: () => void,
1716
+ fallback?: () => void,
1717
+ ): void {
1718
+ forRolesCore(roles, content, fallback);
1719
+ }
1720
+
1693
1721
  /** Open a URL in a new tab. Prefer over `runJavaScript('window.open…')`. */
1694
1722
  export function openExternal(url: string): void {
1695
1723
  openExternalCore(url);
@@ -1789,6 +1817,12 @@ export function page(path: string, fn: () => void, options?: PageOptions): void
1789
1817
  * Extends {@link ClayServerConfig} (port, auth, storage, uploads, session timeouts).
1790
1818
  */
1791
1819
  export type RunConfig = ClayServerConfig & {
1820
+ /**
1821
+ * Current user's role(s), read while pages build. Enables `ui.forRoles`,
1822
+ * `element.roles([...])`, `ui.hasRole`, and default role filtering in
1823
+ * `ui.navFromPages()` / `requireRole`. Return `null` / `[]` when signed out.
1824
+ */
1825
+ getRoles?: RoleResolver;
1792
1826
  /** Global dashboard shell; wraps every page unless `ui.page(..., { shell: false })`. */
1793
1827
  app?: AppProps;
1794
1828
  /**
@@ -1887,7 +1921,8 @@ export function run(rootOrConfig: PageFn | RunConfig = {}, config: RunConfig = {
1887
1921
 
1888
1922
  cfg = applyClayTailwind(cfg);
1889
1923
 
1890
- const { app: appProps, tailwind: _tailwind, ...serverConfig } = cfg;
1924
+ const { app: appProps, tailwind: _tailwind, getRoles, ...serverConfig } = cfg;
1925
+ setRoleResolver(getRoles ?? null);
1891
1926
  if (appProps) {
1892
1927
  setPageWrapper((pageFn) => app(appProps, pageFn));
1893
1928
  } else {
@@ -2035,6 +2070,8 @@ export const ui = {
2035
2070
  updateUrlSearch,
2036
2071
  openExternal,
2037
2072
  redirect,
2073
+ forRoles,
2074
+ hasRole,
2038
2075
  runJavaScript,
2039
2076
  scroll,
2040
2077
  timer,
package/src/pages.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { currentRoles, hasRoleResolver } from '@close-by/clay-core';
1
2
  import { fileURLToPath, pathToFileURL } from 'url';
2
3
  import { join } from 'path';
3
4
  import { readFile } from 'fs/promises';
@@ -249,6 +250,10 @@ export function navFromPages(opts?: NavFromPagesOptions): AppNavItem[] {
249
250
  if (opts?.roles) {
250
251
  for (const r of opts.roles) viewer.add(r);
251
252
  }
253
+ // No explicit role given → use ui.run({ getRoles }) when configured.
254
+ if (opts?.role === undefined && opts?.roles === undefined && hasRoleResolver()) {
255
+ for (const r of currentRoles()) viewer.add(r);
256
+ }
252
257
 
253
258
  const paths = getRegisteredPaths();
254
259
  const ranked: RankedNavPage[] = paths