@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 +4 -0
- package/docs/production.md +32 -1
- package/package.json +5 -5
- package/src/index.ts +38 -1
- package/src/pages.ts +5 -0
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 |
|
package/docs/production.md
CHANGED
|
@@ -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.
|
|
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.
|
|
41
|
-
"@close-by/clay-core": "0.2.
|
|
42
|
-
"@close-by/clay-components": "0.2.
|
|
43
|
-
"@close-by/clay-server": "0.2.
|
|
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
|