@fikar-ai/design 1.5.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +349 -2
- package/brand/apps/pii.svg +4 -0
- package/components.css +9 -6
- package/esm/app-icons.js +12 -0
- package/esm/auth.js +240 -0
- package/esm/dom.js +39 -0
- package/esm/launcher.js +44 -0
- package/esm/oidc.js +163 -0
- package/esm/theme.js +47 -0
- package/package.json +3 -1
- package/recipes/shell.html +1 -1
- package/templates/jinja/launcher.html +1 -0
- package/templates/jinja/shell.html +1 -1
package/README.md
CHANGED
|
@@ -149,7 +149,9 @@ Rules, in force on every surface:
|
|
|
149
149
|
- From 1024px the sidebar shows open at 248px. It can collapse to an icon rail at 72px.
|
|
150
150
|
- The account cluster (launcher, theme control, avatar) sits at the top right of the top bar on every surface.
|
|
151
151
|
- The collapse control (`.shell-collapse`) is the last child of the sidebar, so it sits at the bottom in
|
|
152
|
-
the same position open or collapsed. It is
|
|
152
|
+
the same position open or collapsed. It is an icon-only button with a static panel icon: left-aligned in
|
|
153
|
+
line with the nav icons when open, centred in the rail when collapsed. Its `<span class="nav-label">Collapse</span>`
|
|
154
|
+
is clipped (not removed) in both states and stays as the accessible name. It is hidden below 1024px.
|
|
153
155
|
- The choice is stored in `localStorage` under `fikar.shell.collapsed`: the value `"1"` means collapsed,
|
|
154
156
|
`"0"` means open, and an absent key is read as open. Since 0.8.0 both `shell.js` and `@fikar-ai/design-react` write `"0"` on
|
|
155
157
|
expand; an older surface that removes the key is still read correctly. Storage is per origin, so the choice does not follow a person from
|
|
@@ -435,7 +437,7 @@ Since 1.2.0 the launcher can also use the shared behaviour, the same way as the
|
|
|
435
437
|
- Plain HTML and Jinja: add `data-fk-menu` to the root, or call `launcher(apps, data_fk_menu=true)`. `shell.js` then opens it on a click or Enter, closes it on Escape (focus returns to the button), on a press outside and on choosing a link, and moves between the links with the arrow keys, Home and End. The recipe file itself has no attribute, because it is the markup a page that wires the launcher itself already copies. The shell recipes carry it.
|
|
436
438
|
- The current app has `.current` and `aria-current="page"`, and its tile is the primary colour. The apps come from the page, and nothing is fetched.
|
|
437
439
|
- Each tile is a 36px `.launcher-tile` with a 20px glyph over the app name, and the name may wrap to two lines. The hint is not shown. The panel carries `.launcher-apps`, which is what makes it a grid; every grid rule is scoped to that class, so the search results, the user menu and the theme menu, which reuse `.launcher-panel` and `.launcher-item`, keep their look, and a host whose copy of the markup has no `.launcher-apps` keeps the old list.
|
|
438
|
-
- The package owns the glyphs, keyed by app id: `chat`, `platform`, `crs`, `dictionary`, `cost-calculator`, `super-admin` and `account`. They are lucide 0.469 SVG files in `brand/apps/<id>.svg` (24px frame, 1.5 stroke, `currentColor`). An id the package does not know shows the first letter of the app name in the same tile. The Jinja `launcher` macro and the React component need the app `id` for this; an item without one shows the letter.
|
|
440
|
+
- The package owns the glyphs, keyed by app id: `chat`, `platform`, `crs`, `dictionary`, `pii`, `cost-calculator`, `super-admin` and `account`. They are lucide 0.469 SVG files in `brand/apps/<id>.svg` (24px frame, 1.5 stroke, `currentColor`). An id the package does not know shows the first letter of the app name in the same tile. The Jinja `launcher` macro and the React component need the app `id` for this; an item without one shows the letter.
|
|
439
441
|
- `react/src/app-icons.ts` and the icon map in `templates/jinja/launcher.html` are generated from those files by `node scripts/build-app-icons.mjs`. Change an SVG, run the script and commit the result; a test fails when they differ.
|
|
440
442
|
- React: `AppLauncher` renders the recipe and owns its behaviour, so it does not render `data-fk-menu`.
|
|
441
443
|
|
|
@@ -1309,6 +1311,351 @@ Docs imports only the raw ramp and aliases Infima to it. Do **not** delete the d
|
|
|
1309
1311
|
|
|
1310
1312
|
Docusaurus toggles `[data-theme='dark']`, not `.dark`, so docs keeps its own dark block (it already references `--fk-*`).
|
|
1311
1313
|
|
|
1314
|
+
## Plain-JS apps (`esm/`)
|
|
1315
|
+
|
|
1316
|
+
Apps with no bundler (the dictionary, PII and crs services) sign in, renew the session, theme the page and
|
|
1317
|
+
build the app launcher with the same five scripts. They used to each carry a copy; the package now ships
|
|
1318
|
+
one, as plain ES modules under `esm/` with no build step. Every module takes its collaborators as options
|
|
1319
|
+
(storage, fetch, crypto, location, timers), so `node --test` runs them with no browser.
|
|
1320
|
+
|
|
1321
|
+
```
|
|
1322
|
+
@fikar-ai/design/esm/oidc.js auth.js theme.js launcher.js dom.js app-icons.js
|
|
1323
|
+
```
|
|
1324
|
+
|
|
1325
|
+
| Module | Exports |
|
|
1326
|
+
|---|---|
|
|
1327
|
+
| `oidc.js` | `createOidc({ storagePrefix, authUrl, clientId, redirectUri, postLogoutRedirectUri })` returning `{ beginSignIn(returnTo), completeSignIn(callbackUrl), signOutUrl(), accountUrl() }`. Also `base64url`, `codeChallenge`, `decodeJwtPayload`. Authorization code flow with PKCE (S256); the three endpoints are built from `authUrl`. |
|
|
1328
|
+
| `auth.js` | `createAuth({ storagePrefix, getAuthUrl, onSessionCleared })` returning the session object (`token`, `setSession`, `clearSession`, `trySso`, `authedFetch`, `armRenewal`, `clearRenewal`, `refreshInFlight`, `generation`). Also `parseJwtClaims`, `decodeJwtExpMs`, `decodeJwtEmail`, `computeDelayMs`. Single-flight cookie refresh and proactive renewal. |
|
|
1329
|
+
| `theme.js` | `createTheme({ storagePrefix, storage, root, matchMedia, onChange })` returning `{ get, set, apply, resolved }`. Also `themeKey(prefix)`, `normalizeTheme`, `resolveTheme`, `THEMES`. |
|
|
1330
|
+
| `launcher.js` | `launcherItems(apps, currentId)` returns the launcher panel's markup (one `<a class="launcher-item">` per app, the current one marked), or `''` for an empty list. Names and URLs are escaped. |
|
|
1331
|
+
| `dom.js` | `h(tag, props, ...children)`, `fill(node, ...children)`, `clear(node)`, `renderable(children)`. Text goes in as text nodes, never as markup. |
|
|
1332
|
+
| `app-icons.js` | `APP_ICONS`, the inner markup of each `brand/apps/*.svg` glyph keyed by app id. Generated by `scripts/build-app-icons.mjs`; `launcher.js` imports it as `./app-icons.js`, so the files must sit in one directory. |
|
|
1333
|
+
|
|
1334
|
+
### storagePrefix
|
|
1335
|
+
|
|
1336
|
+
`storagePrefix` is required by `createAuth`, `createOidc` and `createTheme` (each throws without it). It is
|
|
1337
|
+
the only per-app setting in these modules. One app name gives three keys:
|
|
1338
|
+
|
|
1339
|
+
| Key | Holds |
|
|
1340
|
+
|---|---|
|
|
1341
|
+
| `${storagePrefix}.token` | the access token (`auth.js`) |
|
|
1342
|
+
| `${storagePrefix}.oidc.pending` | the PKCE verifier, state and nonce between redirect and callback (`oidc.js`, `sessionStorage`) |
|
|
1343
|
+
| `${storagePrefix}-theme` | the theme choice (`theme.js`, `localStorage`); `themeKey(prefix)` returns it |
|
|
1344
|
+
|
|
1345
|
+
Use the app's client id as the prefix (`dictionary`, `pii`, `crs`), which keeps the existing keys, so nobody
|
|
1346
|
+
is signed out by the move. The page's inline first-paint script reads `localStorage.getItem('<prefix>-theme')`
|
|
1347
|
+
before any module has run, so it repeats the literal key.
|
|
1348
|
+
|
|
1349
|
+
### Recipe: a new plain-JS app
|
|
1350
|
+
|
|
1351
|
+
1. Copy `scripts/fetch-design.sh` below into the app, pin `DESIGN_VERSION`, and run it. It writes
|
|
1352
|
+
`static/design/` and `static/design/design.lock.json`.
|
|
1353
|
+
2. Build `static/index.html` from the recipes. The three menus carry `data-fk-menu`, so `shell.js` opens and
|
|
1354
|
+
closes them and the app wires none by hand. The shell markup is `recipes/shell.html`, the launcher is
|
|
1355
|
+
`recipes/launcher.html`, the theme menu `recipes/theme-menu.html`, the user menu `recipes/user-menu.html`.
|
|
1356
|
+
3. Write `static/js/app.js` with the glue below.
|
|
1357
|
+
4. Register the client with the provider (client id = the prefix, redirect URI `<origin>/auth/callback`,
|
|
1358
|
+
post-logout URI `<origin>/`) and have the service answer `/auth/callback` with the same `index.html`.
|
|
1359
|
+
The page also needs `GET /config` returning `{ "auth_url": "..." }`.
|
|
1360
|
+
|
|
1361
|
+
`static/index.html`, reduced to what the modules need:
|
|
1362
|
+
|
|
1363
|
+
```html
|
|
1364
|
+
<!doctype html>
|
|
1365
|
+
<html lang="en">
|
|
1366
|
+
<head>
|
|
1367
|
+
<meta charset="utf-8">
|
|
1368
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1369
|
+
<title>My app</title>
|
|
1370
|
+
<link rel="stylesheet" href="/static/design/tokens.css">
|
|
1371
|
+
<link rel="stylesheet" href="/static/design/components.css">
|
|
1372
|
+
<script>
|
|
1373
|
+
// First paint: same key as createTheme({ storagePrefix: 'myapp' }) writes.
|
|
1374
|
+
(function () {
|
|
1375
|
+
try {
|
|
1376
|
+
var stored = localStorage.getItem('myapp-theme');
|
|
1377
|
+
var theme = stored === 'light' || stored === 'dark' ? stored : 'system';
|
|
1378
|
+
var dark = theme === 'dark' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
|
1379
|
+
document.documentElement.classList.toggle('dark', dark);
|
|
1380
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
1381
|
+
} catch (_) {}
|
|
1382
|
+
})();
|
|
1383
|
+
</script>
|
|
1384
|
+
</head>
|
|
1385
|
+
<body>
|
|
1386
|
+
<div id="authError" class="overlay-screen" hidden>
|
|
1387
|
+
<p id="authError-message"></p>
|
|
1388
|
+
<button id="authError-retry" type="button">Try again</button>
|
|
1389
|
+
<button id="authError-signout" type="button">Sign out</button>
|
|
1390
|
+
</div>
|
|
1391
|
+
<div id="noAccess" class="overlay-screen" hidden>
|
|
1392
|
+
<p id="noAccess-signed-in" hidden></p>
|
|
1393
|
+
<a id="noAccess-account" href="/">Account settings</a>
|
|
1394
|
+
<button id="noAccess-switch" type="button">Switch account</button>
|
|
1395
|
+
</div>
|
|
1396
|
+
|
|
1397
|
+
<!-- recipes/shell.html goes here. In its topbar cluster:
|
|
1398
|
+
recipes/launcher.html with id="launcher" (hidden) and its panel id="launcher-panel"
|
|
1399
|
+
recipes/theme-menu.html with id="thememenu"
|
|
1400
|
+
recipes/user-menu.html with ids usermenu-btn, usermenu-avatar, usermenu-email, account, logout
|
|
1401
|
+
each wrapper keeps its data-fk-menu attribute. -->
|
|
1402
|
+
|
|
1403
|
+
<script src="/static/design/shell.js"></script>
|
|
1404
|
+
<script type="module" src="/static/js/app.js"></script>
|
|
1405
|
+
</body>
|
|
1406
|
+
</html>
|
|
1407
|
+
```
|
|
1408
|
+
|
|
1409
|
+
`static/js/app.js`, the glue the dictionary and PII apps share. Everything app-specific is the prefix, the
|
|
1410
|
+
client id, the launcher id and the screens at the end.
|
|
1411
|
+
|
|
1412
|
+
```js
|
|
1413
|
+
import { createAuth, decodeJwtEmail } from '/static/design/auth.js';
|
|
1414
|
+
import { createOidc } from '/static/design/oidc.js';
|
|
1415
|
+
import { launcherItems } from '/static/design/launcher.js';
|
|
1416
|
+
import { createTheme } from '/static/design/theme.js';
|
|
1417
|
+
|
|
1418
|
+
const APP = 'myapp'; // storagePrefix, client id and launcher id
|
|
1419
|
+
const $ = (id) => document.getElementById(id);
|
|
1420
|
+
|
|
1421
|
+
let config = null;
|
|
1422
|
+
async function getAuthUrl() {
|
|
1423
|
+
if (!config) config = await (await fetch('/config')).json();
|
|
1424
|
+
return config.auth_url;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
let oidc = null;
|
|
1428
|
+
// Two races to guard: onSessionCleared firing mid-redirect, and a session the
|
|
1429
|
+
// service rejects bouncing through the provider (which still has its own
|
|
1430
|
+
// session) into an unbroken redirect loop.
|
|
1431
|
+
let redirecting = false;
|
|
1432
|
+
let justSignedIn = false;
|
|
1433
|
+
|
|
1434
|
+
function showAuthError(message) {
|
|
1435
|
+
$('authError-message').textContent = message;
|
|
1436
|
+
$('authError').hidden = false;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function startSignIn(returnTo) {
|
|
1440
|
+
if (redirecting || !oidc) return;
|
|
1441
|
+
if (justSignedIn) {
|
|
1442
|
+
showAuthError('Signed in, but the session was rejected. Sign out and try again.');
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
redirecting = true;
|
|
1446
|
+
oidc.beginSignIn(returnTo);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const auth = createAuth({
|
|
1450
|
+
storagePrefix: APP,
|
|
1451
|
+
getAuthUrl,
|
|
1452
|
+
onSessionCleared: () => startSignIn(location.pathname + location.search),
|
|
1453
|
+
});
|
|
1454
|
+
const token = auth.token;
|
|
1455
|
+
|
|
1456
|
+
function switchAccount() {
|
|
1457
|
+
// Set before clearSession() so its sign-in callback no-ops instead of racing
|
|
1458
|
+
// the redirect to the provider's end-session page.
|
|
1459
|
+
redirecting = true;
|
|
1460
|
+
auth.clearSession();
|
|
1461
|
+
location.assign(oidc.signOutUrl());
|
|
1462
|
+
}
|
|
1463
|
+
$('authError-retry').addEventListener('click', () => { $('authError').hidden = true; oidc.beginSignIn('/'); });
|
|
1464
|
+
$('authError-signout').addEventListener('click', () => location.assign(oidc.signOutUrl()));
|
|
1465
|
+
$('logout').addEventListener('click', switchAccount);
|
|
1466
|
+
$('noAccess-switch').addEventListener('click', switchAccount);
|
|
1467
|
+
|
|
1468
|
+
// Theme: the local choice paints at once; the account's choice arrives with /me.
|
|
1469
|
+
const thememenuEl = $('thememenu');
|
|
1470
|
+
const theme = createTheme({
|
|
1471
|
+
storagePrefix: APP,
|
|
1472
|
+
storage: localStorage,
|
|
1473
|
+
root: document.documentElement,
|
|
1474
|
+
matchMedia: window.matchMedia.bind(window),
|
|
1475
|
+
onChange: () => {
|
|
1476
|
+
for (const b of thememenuEl.querySelectorAll('[data-value]')) {
|
|
1477
|
+
const on = b.dataset.value === theme.get();
|
|
1478
|
+
b.classList.toggle('current', on);
|
|
1479
|
+
b.setAttribute('aria-checked', String(on));
|
|
1480
|
+
}
|
|
1481
|
+
},
|
|
1482
|
+
});
|
|
1483
|
+
thememenuEl.addEventListener('fk-theme-change', async (e) => {
|
|
1484
|
+
theme.set(e.detail.value);
|
|
1485
|
+
if (!token()) return;
|
|
1486
|
+
try {
|
|
1487
|
+
await auth.authedFetch(`${await getAuthUrl()}/api/auth/me`, {
|
|
1488
|
+
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ theme: theme.get() }),
|
|
1489
|
+
});
|
|
1490
|
+
} catch { /* the local choice stands */ }
|
|
1491
|
+
});
|
|
1492
|
+
theme.apply();
|
|
1493
|
+
|
|
1494
|
+
// Identity and launcher: the provider returns only the apps this account may open.
|
|
1495
|
+
async function syncLauncher() {
|
|
1496
|
+
if (!token()) return;
|
|
1497
|
+
const email = decodeJwtEmail(token());
|
|
1498
|
+
$('usermenu-email').textContent = email || '';
|
|
1499
|
+
const initial = email ? email[0].toUpperCase() : '?';
|
|
1500
|
+
$('usermenu-avatar').textContent = initial;
|
|
1501
|
+
$('usermenu-btn').dataset.initials = initial;
|
|
1502
|
+
try {
|
|
1503
|
+
const authUrl = await getAuthUrl();
|
|
1504
|
+
const [appsRes, meRes] = await Promise.all([
|
|
1505
|
+
auth.authedFetch(`${authUrl}/api/auth/me/apps`),
|
|
1506
|
+
auth.authedFetch(`${authUrl}/api/auth/me`),
|
|
1507
|
+
]);
|
|
1508
|
+
if (appsRes.ok) {
|
|
1509
|
+
const apps = await appsRes.json();
|
|
1510
|
+
$('launcher-panel').innerHTML = launcherItems(apps, APP);
|
|
1511
|
+
$('launcher').hidden = apps.length === 0;
|
|
1512
|
+
}
|
|
1513
|
+
if (meRes.ok) {
|
|
1514
|
+
const me = await meRes.json();
|
|
1515
|
+
if (me.preferences && me.preferences.theme) theme.set(me.preferences.theme);
|
|
1516
|
+
}
|
|
1517
|
+
} catch { /* the launcher is not worth an error state */ }
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function startApp() {
|
|
1521
|
+
// The first accepted response proves the service took the token, which is
|
|
1522
|
+
// when the redirect-loop guard can stand down.
|
|
1523
|
+
const authedFetch = async (...args) => {
|
|
1524
|
+
const res = await auth.authedFetch(...args);
|
|
1525
|
+
if (res.ok) justSignedIn = false;
|
|
1526
|
+
return res;
|
|
1527
|
+
};
|
|
1528
|
+
// ... build the app's screens with `authedFetch` ...
|
|
1529
|
+
syncLauncher();
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
async function init() {
|
|
1533
|
+
try {
|
|
1534
|
+
await getAuthUrl();
|
|
1535
|
+
} catch {
|
|
1536
|
+
showAuthError('Cannot reach the service. Try reloading the page.');
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
oidc = createOidc({
|
|
1540
|
+
storagePrefix: APP,
|
|
1541
|
+
authUrl: config.auth_url,
|
|
1542
|
+
clientId: APP,
|
|
1543
|
+
redirectUri: `${location.origin}/auth/callback`,
|
|
1544
|
+
postLogoutRedirectUri: `${location.origin}/`,
|
|
1545
|
+
});
|
|
1546
|
+
$('account').href = oidc.accountUrl();
|
|
1547
|
+
|
|
1548
|
+
if (location.pathname === '/auth/callback') {
|
|
1549
|
+
try {
|
|
1550
|
+
const { accessToken, returnTo } = await oidc.completeSignIn(location.href);
|
|
1551
|
+
justSignedIn = true;
|
|
1552
|
+
auth.setSession(accessToken);
|
|
1553
|
+
history.replaceState(null, '', returnTo);
|
|
1554
|
+
startApp();
|
|
1555
|
+
} catch (e) {
|
|
1556
|
+
showAuthError(e.message || 'Sign-in failed');
|
|
1557
|
+
}
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (token()) {
|
|
1561
|
+
auth.armRenewal();
|
|
1562
|
+
startApp();
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
// No refresh-cookie auto-login: the provider session behind /authorize gives
|
|
1566
|
+
// the silent re-entry and keeps each app's refresh family attached to a
|
|
1567
|
+
// session the account settings can list.
|
|
1568
|
+
startSignIn(location.pathname + location.search + location.hash);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
init();
|
|
1572
|
+
```
|
|
1573
|
+
|
|
1574
|
+
The dictionary and PII apps also revalidate the session when the tab regains focus (a sign-out elsewhere
|
|
1575
|
+
clears the shared cookie) and show the `noAccess` overlay when the token lacks what the screen needs
|
|
1576
|
+
(`showNoAccess` sets `noAccess-signed-in` and points `noAccess-account` at `oidc.accountUrl()`). Both are
|
|
1577
|
+
about 30 lines and identical in the two apps, so copy them from `services/dictionary/static/js/app.js`
|
|
1578
|
+
(`revalidateSession`, `showNoAccess`) when the app wants them. They are left out of `esm/` because they
|
|
1579
|
+
reach into the page's DOM and the crs explorer wires them inline in `ui.html`.
|
|
1580
|
+
|
|
1581
|
+
`scripts/fetch-design.sh`:
|
|
1582
|
+
|
|
1583
|
+
```bash
|
|
1584
|
+
#!/usr/bin/env bash
|
|
1585
|
+
# Pull the pinned @fikar-ai/design release into static/design.
|
|
1586
|
+
#
|
|
1587
|
+
# The page links the design system's CSS and runs its shell.js and esm/ modules, so the files
|
|
1588
|
+
# live in this repo. This script is the only sanctioned way to update them, and a test fails when
|
|
1589
|
+
# a committed file drifts from design.lock.json. Bump DESIGN_VERSION, run this, commit the result.
|
|
1590
|
+
set -euo pipefail
|
|
1591
|
+
|
|
1592
|
+
# The release must ship esm/ (the first one after 1.5.0).
|
|
1593
|
+
DESIGN_VERSION="${DESIGN_VERSION:?set DESIGN_VERSION to an @fikar-ai/design release that ships esm/}"
|
|
1594
|
+
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
|
1595
|
+
DEST="${DEST:-$HERE/static/design}"
|
|
1596
|
+
TMP="$(mktemp -d)"
|
|
1597
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
1598
|
+
|
|
1599
|
+
(cd "$TMP" && npm pack "@fikar-ai/design@${DESIGN_VERSION}" --silent >/dev/null && tar xzf fikar-ai-design-*.tgz)
|
|
1600
|
+
|
|
1601
|
+
FILES="tokens.css components.css shell.js brand/fikar-logo.svg brand/fikar-logo-reversed.svg"
|
|
1602
|
+
ESM_FILES="esm/oidc.js esm/auth.js esm/theme.js esm/dom.js esm/launcher.js esm/app-icons.js"
|
|
1603
|
+
NOTE="Vendored verbatim from @fikar-ai/design@${DESIGN_VERSION} by scripts/fetch-design.sh; do not edit."
|
|
1604
|
+
|
|
1605
|
+
mkdir -p "$DEST"
|
|
1606
|
+
for f in $FILES $ESM_FILES; do
|
|
1607
|
+
out="$DEST/$(basename "$f")"
|
|
1608
|
+
case "$f" in
|
|
1609
|
+
*.svg)
|
|
1610
|
+
# An XML comment must come after the XML declaration, when there is one.
|
|
1611
|
+
python3 - "$TMP/package/$f" "$out" "$NOTE" <<'PY'
|
|
1612
|
+
import pathlib, re, sys
|
|
1613
|
+
src, out, note = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), sys.argv[3]
|
|
1614
|
+
text = src.read_text()
|
|
1615
|
+
decl = re.match(r"\s*<\?xml[^>]*\?>\s*", text)
|
|
1616
|
+
head = decl.group(0) if decl else ""
|
|
1617
|
+
out.write_text(f"{head}<!-- {note} -->\n{text[len(head):]}")
|
|
1618
|
+
PY
|
|
1619
|
+
;;
|
|
1620
|
+
*)
|
|
1621
|
+
{ echo "/* $NOTE */"; cat "$TMP/package/$f"; } > "$out"
|
|
1622
|
+
;;
|
|
1623
|
+
esac
|
|
1624
|
+
done
|
|
1625
|
+
|
|
1626
|
+
# The favicon set is copied byte for byte: the .ico and .png files are binary and a manifest
|
|
1627
|
+
# cannot carry a comment. Serve it at the site root.
|
|
1628
|
+
rm -rf "$DEST/favicon"
|
|
1629
|
+
mkdir -p "$DEST/favicon"
|
|
1630
|
+
cp "$TMP"/package/brand/favicon/* "$DEST/favicon/"
|
|
1631
|
+
|
|
1632
|
+
python3 - "$DEST" "$DESIGN_VERSION" $(for f in $FILES $ESM_FILES; do basename "$f"; done) <<'PY'
|
|
1633
|
+
import hashlib, json, pathlib, sys
|
|
1634
|
+
dest, version, names = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3:]
|
|
1635
|
+
digest = lambda p: hashlib.sha256(p.read_bytes()).hexdigest()
|
|
1636
|
+
lock = {
|
|
1637
|
+
"package": "@fikar-ai/design",
|
|
1638
|
+
"version": version,
|
|
1639
|
+
"sha256": {n: digest(dest / n) for n in names},
|
|
1640
|
+
"favicon_sha256": {p.name: digest(p) for p in sorted((dest / "favicon").iterdir())},
|
|
1641
|
+
}
|
|
1642
|
+
(dest / "design.lock.json").write_text(json.dumps(lock, indent=2) + "\n")
|
|
1643
|
+
print(f"vendored @fikar-ai/design@{version}: {', '.join(names)}")
|
|
1644
|
+
PY
|
|
1645
|
+
```
|
|
1646
|
+
|
|
1647
|
+
The `brand/apps/` glyphs are not vendored any more. `app-icons.js` carries them, already generated.
|
|
1648
|
+
|
|
1649
|
+
A guard test in the app repo keeps the vendored files honest. It reads `design.lock.json`, hashes each file
|
|
1650
|
+
under `static/design/` (and each file in `favicon/`) and fails when one differs, when a file on disk is not
|
|
1651
|
+
in the lock, or when the `@fikar-ai/design@<version>` named in a file's first line is not the lock's version.
|
|
1652
|
+
The dictionary's `tests/test_design_vendoring.py` is the working example (pytest); a Node version is a
|
|
1653
|
+
dozen lines with `node:crypto`.
|
|
1654
|
+
|
|
1655
|
+
Vendored modules run unchanged under `node --test`, so the app's own tests import them from
|
|
1656
|
+
`static/design/` and inject fakes. Keep the app's tests for its own code only; the modules' tests live in
|
|
1657
|
+
this package (`test/esm-*.test.mjs`).
|
|
1658
|
+
|
|
1312
1659
|
## Tokens
|
|
1313
1660
|
|
|
1314
1661
|
- Ramp is `--fk-*` (e.g. `--fk-blue600`, `--fk-n200`). Compose with `hsl()`: `hsl(var(--fk-blue600) / 0.5)`.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>
|
|
3
|
+
<path d="m9 12 2 2 4-4"/>
|
|
4
|
+
</svg>
|
package/components.css
CHANGED
|
@@ -187,20 +187,23 @@ nav.side { display: flex; flex-direction: column; gap: 2px; }
|
|
|
187
187
|
renders as before and the phone drawer never sees the state. Labels are
|
|
188
188
|
clipped, not display:none, so they stay in the accessibility tree and give
|
|
189
189
|
the host's tooltip its text. The collapse control is the last child of the
|
|
190
|
-
sidebar, so it holds the same place open or collapsed.
|
|
190
|
+
sidebar, so it holds the same place open or collapsed. It is an icon-only 38px
|
|
191
|
+
square (panel icon, static): its "Collapse" label is clipped in both states and
|
|
192
|
+
stays as the accessible name. Open, it sits left so its icon lines up with the nav
|
|
193
|
+
icons (14px sidebar padding + 10px item padding); collapsed, it is centred in the rail. ── */
|
|
191
194
|
.shell-collapse { display: none; }
|
|
192
195
|
@media (min-width: 1024px) {
|
|
193
|
-
.shell-collapse { display: flex; align-items: center;
|
|
194
|
-
background: none; color: hsl(var(--muted-foreground)); font-family: inherit; font-size: 14px; font-weight: 500;
|
|
196
|
+
.shell-collapse { position: relative; display: flex; align-items: center; justify-content: center; align-self: flex-start; width: 38px; height: 38px; margin-top: 8px; padding: 0; border: 0; border-radius: var(--r-sm);
|
|
197
|
+
background: none; color: hsl(var(--muted-foreground)); font-family: inherit; font-size: 14px; font-weight: 500; cursor: pointer;
|
|
195
198
|
transition: background-color var(--fast) var(--ease), color var(--fast) var(--ease); }
|
|
196
199
|
.shell-collapse:hover { background: hsl(var(--muted)); color: hsl(var(--foreground)); }
|
|
197
200
|
.shell-collapse svg { width: 18px; height: 18px; flex: 0 0 auto; }
|
|
198
201
|
.shell.is-collapsed { grid-template-columns: 72px 1fr; }
|
|
199
|
-
.shell.is-collapsed .nav-item
|
|
200
|
-
.shell.is-collapsed .
|
|
202
|
+
.shell.is-collapsed .nav-item { position: relative; justify-content: center; padding-left: 0; padding-right: 0; }
|
|
203
|
+
.shell.is-collapsed .shell-collapse { align-self: center; }
|
|
204
|
+
.shell-collapse .nav-label, .shell.is-collapsed .nav-label, .shell.is-collapsed .brand .word, .shell.is-collapsed .side-foot .meta {
|
|
201
205
|
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
202
206
|
.shell.is-collapsed .sidebar .brand, .shell.is-collapsed .side-foot { justify-content: center; padding-left: 0; padding-right: 0; }
|
|
203
|
-
.shell.is-collapsed .shell-collapse svg { transform: rotate(180deg); }
|
|
204
207
|
}
|
|
205
208
|
|
|
206
209
|
/* ── app shell: theme-aware logo (KAN-322) ──
|
package/esm/app-icons.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Generated by scripts/build-app-icons.mjs from brand/apps/*.svg. Do not edit; change the SVG and run the script.
|
|
2
|
+
// The inner markup of each app's launcher glyph, keyed by app id. Each is drawn in the shared 24px, 1.5 stroke frame.
|
|
3
|
+
export const APP_ICONS = {
|
|
4
|
+
"account": "<circle cx=\"12\" cy=\"8\" r=\"5\"/><path d=\"M20 21a8 8 0 0 0-16 0\"/>",
|
|
5
|
+
"chat": "<path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\"/>",
|
|
6
|
+
"cost-calculator": "<rect width=\"16\" height=\"20\" x=\"4\" y=\"2\" rx=\"2\"/><line x1=\"8\" x2=\"16\" y1=\"6\" y2=\"6\"/><line x1=\"16\" x2=\"16\" y1=\"14\" y2=\"18\"/><path d=\"M16 10h.01\"/><path d=\"M12 10h.01\"/><path d=\"M8 10h.01\"/><path d=\"M12 14h.01\"/><path d=\"M8 14h.01\"/><path d=\"M12 18h.01\"/><path d=\"M8 18h.01\"/>",
|
|
7
|
+
"crs": "<circle cx=\"12\" cy=\"4.5\" r=\"2.5\"/><path d=\"m10.2 6.3-3.9 3.9\"/><circle cx=\"4.5\" cy=\"12\" r=\"2.5\"/><path d=\"M7 12h10\"/><circle cx=\"19.5\" cy=\"12\" r=\"2.5\"/><path d=\"m13.8 17.7 3.9-3.9\"/><circle cx=\"12\" cy=\"19.5\" r=\"2.5\"/>",
|
|
8
|
+
"dictionary": "<path d=\"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20\"/><path d=\"m8 13 4-7 4 7\"/><path d=\"M9.1 11h5.7\"/>",
|
|
9
|
+
"pii": "<path d=\"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z\"/><path d=\"m9 12 2 2 4-4\"/>",
|
|
10
|
+
"platform": "<line x1=\"21\" x2=\"14\" y1=\"4\" y2=\"4\"/><line x1=\"10\" x2=\"3\" y1=\"4\" y2=\"4\"/><line x1=\"21\" x2=\"12\" y1=\"12\" y2=\"12\"/><line x1=\"8\" x2=\"3\" y1=\"12\" y2=\"12\"/><line x1=\"21\" x2=\"16\" y1=\"20\" y2=\"20\"/><line x1=\"12\" x2=\"3\" y1=\"20\" y2=\"20\"/><line x1=\"14\" x2=\"14\" y1=\"2\" y2=\"6\"/><line x1=\"8\" x2=\"8\" y1=\"10\" y2=\"14\"/><line x1=\"16\" x2=\"16\" y1=\"18\" y2=\"22\"/>",
|
|
11
|
+
"super-admin": "<path d=\"M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z\"/><path d=\"M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2\"/><path d=\"M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2\"/><path d=\"M10 6h4\"/><path d=\"M10 10h4\"/><path d=\"M10 14h4\"/><path d=\"M10 18h4\"/>",
|
|
12
|
+
};
|
package/esm/auth.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Auth state machine for a plain-JS FIKAR app: the shared single-flight cookie
|
|
2
|
+
// refresh, the authGeneration guard, and the proactive access-token renewal
|
|
3
|
+
// timer. Shared by the dictionary, PII and crs apps (KAN-385).
|
|
4
|
+
//
|
|
5
|
+
// Kept free of the DOM, with every dependency injected, so node --test can run
|
|
6
|
+
// it (test/esm-auth.test.mjs in @fikar-ai/design). The page wires the login
|
|
7
|
+
// redirect and sign-out into the object createAuth() returns. The access token
|
|
8
|
+
// is stored under `${storagePrefix}.token`.
|
|
9
|
+
|
|
10
|
+
// 90s margin absorbs clock skew and the refresh call's own latency; the
|
|
11
|
+
// jitter (redrawn on every arm) staggers tabs that share the same stored
|
|
12
|
+
// token so several tabs don't rotate the shared .fikar.ai cookie at once.
|
|
13
|
+
const MARGIN_MS = 90_000;
|
|
14
|
+
const JITTER_MAX_MS = 45_000;
|
|
15
|
+
|
|
16
|
+
/** Dependency-free JWT payload decode, no signature check: every caller here
|
|
17
|
+
* already holds a token the server accepted on some prior request, so this
|
|
18
|
+
* is just reading claims back out of it, not trusting it as a credential.
|
|
19
|
+
* Returns null for anything that isn't a three-segment JWT with a JSON
|
|
20
|
+
* payload, rather than throwing, since both callers below treat "can't read
|
|
21
|
+
* this claim" as a normal case (an unrecognized token, a claim the server
|
|
22
|
+
* didn't set) and not an error. */
|
|
23
|
+
export function parseJwtClaims(token) {
|
|
24
|
+
if (typeof token !== 'string') return null;
|
|
25
|
+
const parts = token.split('.');
|
|
26
|
+
if (parts.length !== 3) return null;
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(base64UrlDecode(parts[1]));
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The `exp` claim (seconds since epoch), in milliseconds. Needed to arm the
|
|
35
|
+
* renewal timer, and the token lifetime differs per environment (15 or 60
|
|
36
|
+
* minutes), so it must come from the token itself rather than a hardcoded
|
|
37
|
+
* constant. Exported for direct testing. */
|
|
38
|
+
export function decodeJwtExpMs(token) {
|
|
39
|
+
const payload = parseJwtClaims(token);
|
|
40
|
+
return typeof payload?.exp === 'number' ? payload.exp * 1000 : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The `email` claim the user service puts on every access token (KAN-255):
|
|
44
|
+
* used only for the "Signed in as ..." line on the no-access screen, so a
|
|
45
|
+
* token that doesn't decode or carries no email claim just means that line
|
|
46
|
+
* is omitted, not an error. Exported for direct testing. */
|
|
47
|
+
export function decodeJwtEmail(token) {
|
|
48
|
+
const payload = parseJwtClaims(token);
|
|
49
|
+
return typeof payload?.email === 'string' ? payload.email : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function base64UrlDecode(segment) {
|
|
53
|
+
const base64 = segment.replace(/-/g, '+').replace(/_/g, '/');
|
|
54
|
+
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
|
|
55
|
+
const binary = atob(padded);
|
|
56
|
+
// atob yields a binary string; re-decode as UTF-8 so multi-byte claim
|
|
57
|
+
// values don't get mangled.
|
|
58
|
+
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
|
59
|
+
return new TextDecoder().decode(bytes);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Pure so jitter/margin math can be asserted directly. Exported for tests. */
|
|
63
|
+
export function computeDelayMs(expiresAtMs, now, jitterMs) {
|
|
64
|
+
return Math.max(0, expiresAtMs - MARGIN_MS - jitterMs - now);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds the auth object ui.html holds for its lifetime. Dependencies are
|
|
69
|
+
* injected (storage, fetch, timers, randomness, the clock, the document) so
|
|
70
|
+
* tests can run this with no DOM, no real network and no real delays.
|
|
71
|
+
*
|
|
72
|
+
* `documentImpl` defaults to the real `document` when one exists (the
|
|
73
|
+
* `typeof` guard keeps that default from throwing in Node, where no global
|
|
74
|
+
* `document` is declared) and is only used to listen for `visibilitychange`
|
|
75
|
+
* (KAN-238); pass an object with `addEventListener`/`visibilityState` to
|
|
76
|
+
* drive that from a test, or `undefined` to skip wiring it up at all.
|
|
77
|
+
*/
|
|
78
|
+
export function createAuth({
|
|
79
|
+
storagePrefix,
|
|
80
|
+
storage = localStorage,
|
|
81
|
+
fetchImpl = fetch,
|
|
82
|
+
getAuthUrl,
|
|
83
|
+
onSessionCleared,
|
|
84
|
+
now = Date.now,
|
|
85
|
+
random = Math.random,
|
|
86
|
+
setTimeoutImpl = setTimeout,
|
|
87
|
+
clearTimeoutImpl = clearTimeout,
|
|
88
|
+
documentImpl = typeof document === 'undefined' ? undefined : document,
|
|
89
|
+
} = {}) {
|
|
90
|
+
if (typeof storagePrefix !== 'string' || !storagePrefix) throw new Error('storagePrefix is required');
|
|
91
|
+
// Shares one cookie rotation across startup, the proactive timer, and
|
|
92
|
+
// concurrent expired requests.
|
|
93
|
+
let refreshPromise = null;
|
|
94
|
+
let authGeneration = 0;
|
|
95
|
+
let timerId = null;
|
|
96
|
+
|
|
97
|
+
const TOKEN_KEY = `${storagePrefix}.token`;
|
|
98
|
+
const token = () => storage.getItem(TOKEN_KEY);
|
|
99
|
+
|
|
100
|
+
function clearRenewal() {
|
|
101
|
+
if (timerId !== null) {
|
|
102
|
+
clearTimeoutImpl(timerId);
|
|
103
|
+
timerId = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** (Re)arms the proactive renewal timer off the stored token's own `exp`.
|
|
108
|
+
* A no-op (no stored token, or one with no decodable exp) just clears any
|
|
109
|
+
* timer already scheduled. */
|
|
110
|
+
function armRenewal() {
|
|
111
|
+
clearRenewal();
|
|
112
|
+
const current = token();
|
|
113
|
+
if (!current) return;
|
|
114
|
+
const expiresAtMs = decodeJwtExpMs(current);
|
|
115
|
+
if (expiresAtMs === null) return;
|
|
116
|
+
const jitterMs = random() * JITTER_MAX_MS;
|
|
117
|
+
const delay = computeDelayMs(expiresAtMs, now(), jitterMs);
|
|
118
|
+
const generation = authGeneration;
|
|
119
|
+
timerId = setTimeoutImpl(() => {
|
|
120
|
+
timerId = null;
|
|
121
|
+
// A logout (or another generation-bumping event) between arming and
|
|
122
|
+
// firing must not let a stale timer touch the new session.
|
|
123
|
+
if (generation !== authGeneration) return;
|
|
124
|
+
// Shares trySso()'s single-flight guard with the reactive 401 path in
|
|
125
|
+
// authedFetch(), so a timer firing mid-refresh just waits on it. A
|
|
126
|
+
// proactive failure is silent by design: no login form, token left
|
|
127
|
+
// alone, the reactive path in authedFetch() is the fallback.
|
|
128
|
+
void trySso();
|
|
129
|
+
}, delay);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function clearSession() {
|
|
133
|
+
authGeneration++;
|
|
134
|
+
clearRenewal();
|
|
135
|
+
storage.removeItem(TOKEN_KEY);
|
|
136
|
+
onSessionCleared?.();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Stores a freshly issued access token (login) and arms the timer for it. */
|
|
140
|
+
function setSession(accessToken) {
|
|
141
|
+
authGeneration++;
|
|
142
|
+
storage.setItem(TOKEN_KEY, accessToken);
|
|
143
|
+
armRenewal();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function trySso() {
|
|
147
|
+
if (refreshPromise) return refreshPromise;
|
|
148
|
+
const generation = authGeneration;
|
|
149
|
+
refreshPromise = (async () => {
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetchImpl(`${await getAuthUrl()}/api/auth/refresh`, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
credentials: 'include',
|
|
154
|
+
});
|
|
155
|
+
if (!res.ok) return false;
|
|
156
|
+
const data = await res.json();
|
|
157
|
+
if (generation !== authGeneration || !data.access_token) return false;
|
|
158
|
+
storage.setItem(TOKEN_KEY, data.access_token);
|
|
159
|
+
armRenewal();
|
|
160
|
+
return true;
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
})().finally(() => { refreshPromise = null; });
|
|
165
|
+
return refreshPromise;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** True when the stored token cannot carry a request without a refresh
|
|
169
|
+
* first: no token at all, or one that has already passed `exp` or sits
|
|
170
|
+
* inside the same MARGIN_MS the renewal timer uses. A token that decodes
|
|
171
|
+
* to no `exp` claim (never issued by the real user-service, but a stand-in
|
|
172
|
+
* some tests use) is left alone here and handled by the existing 401 path
|
|
173
|
+
* instead, since there is nothing to preflight-check about it. */
|
|
174
|
+
function needsPreflightRefresh() {
|
|
175
|
+
const current = token();
|
|
176
|
+
if (!current) return true;
|
|
177
|
+
const expiresAtMs = decodeJwtExpMs(current);
|
|
178
|
+
if (expiresAtMs === null) return false;
|
|
179
|
+
return expiresAtMs - MARGIN_MS <= now();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function authedFetch(url, init = {}) {
|
|
183
|
+
// `init` lets a caller PATCH (the theme, KAN-294); the bearer header is
|
|
184
|
+
// always ours, whatever the caller passed.
|
|
185
|
+
const withAuth = (bearer) => ({ ...init, headers: { ...(init.headers || {}), Authorization: `Bearer ${bearer}` } });
|
|
186
|
+
const generation = authGeneration;
|
|
187
|
+
// Pre-flight (KAN-238): a token that is already expired, or due to
|
|
188
|
+
// expire within the renewal margin, is refreshed silently before the
|
|
189
|
+
// request goes out, instead of sending it, getting a 401, and refreshing
|
|
190
|
+
// reactively. This is what makes a laptop wake silent: the overdue
|
|
191
|
+
// renewal timer and the app's first request both want a refresh, and
|
|
192
|
+
// trySso()'s single-flight guard collapses them into one. A failed
|
|
193
|
+
// refresh here (dead cookie) falls straight through to the unchanged
|
|
194
|
+
// 401 handling below.
|
|
195
|
+
if (needsPreflightRefresh()) {
|
|
196
|
+
await trySso();
|
|
197
|
+
if (generation !== authGeneration) throw new Error('session changed');
|
|
198
|
+
}
|
|
199
|
+
const sentToken = token();
|
|
200
|
+
let res = await fetchImpl(url, withAuth(sentToken));
|
|
201
|
+
if (generation !== authGeneration) throw new Error('session changed');
|
|
202
|
+
if (res.status === 401) {
|
|
203
|
+
// A late 401 may arrive after another request already refreshed. Reuse
|
|
204
|
+
// that token instead of rotating the shared cookie for a second time.
|
|
205
|
+
if (token() && (token() !== sentToken || await trySso())) {
|
|
206
|
+
if (generation !== authGeneration) throw new Error('session changed');
|
|
207
|
+
res = await fetchImpl(url, withAuth(token()));
|
|
208
|
+
if (generation !== authGeneration) throw new Error('session changed');
|
|
209
|
+
if (res.status !== 401) return res;
|
|
210
|
+
}
|
|
211
|
+
if (generation === authGeneration) clearSession();
|
|
212
|
+
throw new Error('unauthorized');
|
|
213
|
+
}
|
|
214
|
+
return res;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Re-arm on wake (KAN-238): a setTimeout cannot fire while the machine is
|
|
218
|
+
// asleep, so the armed timer is stale the moment the tab becomes visible
|
|
219
|
+
// again. Re-arming off the stored token here (an expired token arms with
|
|
220
|
+
// delay 0, see computeDelayMs) puts the next renewal on a correct footing
|
|
221
|
+
// even if this races the pre-flight check in authedFetch(); both paths
|
|
222
|
+
// share trySso()'s single-flight guard.
|
|
223
|
+
if (documentImpl?.addEventListener) {
|
|
224
|
+
documentImpl.addEventListener('visibilitychange', () => {
|
|
225
|
+
if (documentImpl.visibilityState === 'visible') armRenewal();
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
token,
|
|
231
|
+
setSession,
|
|
232
|
+
clearSession,
|
|
233
|
+
trySso,
|
|
234
|
+
authedFetch,
|
|
235
|
+
armRenewal,
|
|
236
|
+
clearRenewal,
|
|
237
|
+
refreshInFlight: () => refreshPromise,
|
|
238
|
+
generation: () => authGeneration,
|
|
239
|
+
};
|
|
240
|
+
}
|
package/esm/dom.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A tiny element builder. Shared by the plain-JS apps (KAN-385). Text goes in as text nodes, never as markup, so a
|
|
2
|
+
// name or definition typed by a person cannot inject HTML into the page.
|
|
3
|
+
|
|
4
|
+
/** Children worth rendering: nested lists flattened, and the false, null and
|
|
5
|
+
* undefined that `cond && el` leaves behind dropped. Native append() would
|
|
6
|
+
* write those as the text "false" or "null". */
|
|
7
|
+
export function renderable(children) {
|
|
8
|
+
return children.flat(Infinity).filter((c) => c !== undefined && c !== null && c !== false);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** h('button', { class: 'btn', onclick: fn, disabled: true }, 'Save', child) */
|
|
12
|
+
export function h(tag, props = {}, ...children) {
|
|
13
|
+
const node = document.createElement(tag);
|
|
14
|
+
for (const [key, value] of Object.entries(props || {})) {
|
|
15
|
+
if (value === undefined || value === null || value === false) continue;
|
|
16
|
+
if (key.startsWith('on') && typeof value === 'function') node.addEventListener(key.slice(2), value);
|
|
17
|
+
else if (key === 'class') node.className = value;
|
|
18
|
+
else if (key === 'value') node.value = value;
|
|
19
|
+
else if (value === true) node.setAttribute(key, '');
|
|
20
|
+
else node.setAttribute(key, String(value));
|
|
21
|
+
}
|
|
22
|
+
for (const child of renderable(children)) {
|
|
23
|
+
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
|
24
|
+
}
|
|
25
|
+
return node;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clear(node) {
|
|
29
|
+
node.replaceChildren();
|
|
30
|
+
return node;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Replace a node's children; use this instead of clear(node).append(...) for
|
|
34
|
+
* any list that can hold `cond && el`. */
|
|
35
|
+
export function fill(node, ...children) {
|
|
36
|
+
node.replaceChildren();
|
|
37
|
+
for (const child of renderable(children)) node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
|
38
|
+
return node;
|
|
39
|
+
}
|
package/esm/launcher.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The app launcher's panel markup from the provider's list, kept out of the
|
|
2
|
+
// page so it can be tested with node --test. Shared by the dictionary, PII and
|
|
3
|
+
// crs apps (KAN-385). The open/close behaviour is
|
|
4
|
+
// shell.js's, through data-fk-menu.
|
|
5
|
+
|
|
6
|
+
import { APP_ICONS } from './app-icons.js';
|
|
7
|
+
|
|
8
|
+
function escapeHtml(value) {
|
|
9
|
+
return String(value)
|
|
10
|
+
.replace(/&/g, '&')
|
|
11
|
+
.replace(/</g, '<')
|
|
12
|
+
.replace(/>/g, '>')
|
|
13
|
+
.replace(/"/g, '"');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The glyph for a known app id (the body comes from the vendored package, so it
|
|
17
|
+
// is markup we trust); an unknown id shows the first letter of its name.
|
|
18
|
+
function tile(app) {
|
|
19
|
+
const body = APP_ICONS[app.id];
|
|
20
|
+
if (body) {
|
|
21
|
+
return (
|
|
22
|
+
'<svg class="app-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"' +
|
|
23
|
+
` stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return escapeHtml(String(app.name || '').trim().charAt(0).toUpperCase());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One <a class="launcher-item"> per app, the current one marked. The list
|
|
30
|
+
* arrives already filtered and ordered by the provider; nothing is reordered
|
|
31
|
+
* or dropped here. Returns '' for an empty list, which the caller treats as
|
|
32
|
+
* "no launcher". */
|
|
33
|
+
export function launcherItems(apps, currentId) {
|
|
34
|
+
return (apps || [])
|
|
35
|
+
.map((app) => {
|
|
36
|
+
const current = app.id === currentId;
|
|
37
|
+
return (
|
|
38
|
+
`<a class="launcher-item${current ? ' current' : ''}" role="menuitem"` +
|
|
39
|
+
` href="${escapeHtml(app.url)}"${current ? ' aria-current="page"' : ''}>` +
|
|
40
|
+
`<span class="launcher-tile" aria-hidden="true">${tile(app)}</span>${escapeHtml(app.name)}</a>`
|
|
41
|
+
);
|
|
42
|
+
})
|
|
43
|
+
.join('');
|
|
44
|
+
}
|
package/esm/oidc.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// OIDC authorization-code + PKCE flow for a plain-JS FIKAR app, against the
|
|
2
|
+
// user service acting as the provider. Shared by the dictionary, PII and crs
|
|
3
|
+
// apps (KAN-385).
|
|
4
|
+
//
|
|
5
|
+
// The three endpoints this app needs never move independently of the provider,
|
|
6
|
+
// so they are built from authUrl rather than fetched from discovery, saving a
|
|
7
|
+
// round trip before the redirect. Dependencies are injected so node --test
|
|
8
|
+
// runs it with no browser (test/esm-oidc.test.mjs in @fikar-ai/design). The
|
|
9
|
+
// pending sign-in is stored under `${storagePrefix}.oidc.pending`.
|
|
10
|
+
|
|
11
|
+
const VERIFIER_BYTES = 64;
|
|
12
|
+
|
|
13
|
+
/** Bytes -> base64url, no padding. Used for both the PKCE verifier and the
|
|
14
|
+
* S256 challenge. Exported for direct testing. */
|
|
15
|
+
export function base64url(bytes) {
|
|
16
|
+
let binary = '';
|
|
17
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
18
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** RFC 7636 S256: base64url(SHA-256(ascii(verifier))). Exported so the RFC
|
|
22
|
+
* appendix B test vector can be checked directly against this function. */
|
|
23
|
+
export async function codeChallenge(verifier, cryptoImpl) {
|
|
24
|
+
const bytes = new TextEncoder().encode(verifier);
|
|
25
|
+
const digest = await cryptoImpl.subtle.digest('SHA-256', bytes);
|
|
26
|
+
return base64url(new Uint8Array(digest));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Decodes a JWT's payload segment with no signature check. Safe here only
|
|
30
|
+
* because the id_token was read straight off the token endpoint's response
|
|
31
|
+
* body over TLS, not handed to us by a third party the way a redirect
|
|
32
|
+
* parameter would be; nothing forwards this token or trusts it beyond the
|
|
33
|
+
* nonce/aud/iss checks completeSignIn() makes right after decoding it.
|
|
34
|
+
* Exported for direct testing. */
|
|
35
|
+
export function decodeJwtPayload(idToken) {
|
|
36
|
+
const parts = idToken.split('.');
|
|
37
|
+
if (parts.length !== 3) throw new Error('malformed id_token');
|
|
38
|
+
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
39
|
+
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
|
|
40
|
+
const binary = atob(padded);
|
|
41
|
+
const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
|
42
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function randomString(cryptoImpl, byteLength) {
|
|
46
|
+
const bytes = new Uint8Array(byteLength);
|
|
47
|
+
cryptoImpl.getRandomValues(bytes);
|
|
48
|
+
return base64url(bytes);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A same-origin path only: '/x' is fine, '//evil.example/x' and an absolute
|
|
52
|
+
* URL are not. Guards against an open redirect via a crafted returnTo. */
|
|
53
|
+
function isSameOriginPath(value) {
|
|
54
|
+
return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Builds the OIDC client ui.html holds for its lifetime. Dependencies are
|
|
59
|
+
* injected (fetch, storage, crypto, location) so tests can drive this with
|
|
60
|
+
* no browser and no real network.
|
|
61
|
+
*/
|
|
62
|
+
export function createOidc({
|
|
63
|
+
storagePrefix,
|
|
64
|
+
authUrl,
|
|
65
|
+
clientId,
|
|
66
|
+
redirectUri,
|
|
67
|
+
postLogoutRedirectUri,
|
|
68
|
+
fetchImpl = fetch,
|
|
69
|
+
storage = typeof sessionStorage === 'undefined' ? undefined : sessionStorage,
|
|
70
|
+
cryptoImpl = globalThis.crypto,
|
|
71
|
+
locationImpl = typeof window === 'undefined' ? undefined : window.location,
|
|
72
|
+
} = {}) {
|
|
73
|
+
if (typeof storagePrefix !== 'string' || !storagePrefix) throw new Error('storagePrefix is required');
|
|
74
|
+
const PENDING_KEY = `${storagePrefix}.oidc.pending`;
|
|
75
|
+
const issuer = authUrl.replace(/\/$/, '');
|
|
76
|
+
const authorizeEndpoint = `${issuer}/authorize`;
|
|
77
|
+
const tokenEndpoint = `${issuer}/token`;
|
|
78
|
+
const endSessionEndpoint = `${issuer}/end-session`;
|
|
79
|
+
|
|
80
|
+
async function beginSignIn(returnTo) {
|
|
81
|
+
const verifier = randomString(cryptoImpl, VERIFIER_BYTES);
|
|
82
|
+
const challenge = await codeChallenge(verifier, cryptoImpl);
|
|
83
|
+
const state = randomString(cryptoImpl, 32);
|
|
84
|
+
const nonce = randomString(cryptoImpl, 32);
|
|
85
|
+
storage.setItem(PENDING_KEY, JSON.stringify({ verifier, state, nonce, returnTo }));
|
|
86
|
+
|
|
87
|
+
const params = new URLSearchParams({
|
|
88
|
+
client_id: clientId,
|
|
89
|
+
redirect_uri: redirectUri,
|
|
90
|
+
response_type: 'code',
|
|
91
|
+
scope: 'openid email profile',
|
|
92
|
+
state,
|
|
93
|
+
nonce,
|
|
94
|
+
code_challenge: challenge,
|
|
95
|
+
code_challenge_method: 'S256',
|
|
96
|
+
});
|
|
97
|
+
locationImpl.assign(`${authorizeEndpoint}?${params}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function completeSignIn(callbackUrl) {
|
|
101
|
+
const url = new URL(callbackUrl);
|
|
102
|
+
const error = url.searchParams.get('error');
|
|
103
|
+
if (error) {
|
|
104
|
+
const description = url.searchParams.get('error_description') || error;
|
|
105
|
+
throw new Error(description);
|
|
106
|
+
}
|
|
107
|
+
const code = url.searchParams.get('code');
|
|
108
|
+
const state = url.searchParams.get('state');
|
|
109
|
+
if (!code) throw new Error('callback URL has no code');
|
|
110
|
+
|
|
111
|
+
const raw = storage.getItem(PENDING_KEY);
|
|
112
|
+
const pending = raw ? JSON.parse(raw) : null;
|
|
113
|
+
if (!pending || !state || pending.state !== state) {
|
|
114
|
+
throw new Error('sign-in state mismatch');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const body = new URLSearchParams({
|
|
118
|
+
grant_type: 'authorization_code',
|
|
119
|
+
code,
|
|
120
|
+
redirect_uri: redirectUri,
|
|
121
|
+
client_id: clientId,
|
|
122
|
+
code_verifier: pending.verifier,
|
|
123
|
+
});
|
|
124
|
+
const res = await fetchImpl(tokenEndpoint, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
// The provider sets the refresh cookie on this response; without
|
|
127
|
+
// credentials: 'include' the browser drops that Set-Cookie header.
|
|
128
|
+
credentials: 'include',
|
|
129
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
130
|
+
body,
|
|
131
|
+
});
|
|
132
|
+
if (!res.ok) throw new Error(`token exchange failed (${res.status})`);
|
|
133
|
+
const data = await res.json();
|
|
134
|
+
|
|
135
|
+
const payload = decodeJwtPayload(data.id_token);
|
|
136
|
+
if (payload.nonce !== pending.nonce) throw new Error('id_token nonce mismatch');
|
|
137
|
+
if (payload.aud !== clientId) throw new Error('id_token aud mismatch');
|
|
138
|
+
if (String(payload.iss).replace(/\/$/, '') !== issuer) throw new Error('id_token iss mismatch');
|
|
139
|
+
|
|
140
|
+
storage.removeItem(PENDING_KEY);
|
|
141
|
+
const returnTo = isSameOriginPath(pending.returnTo) ? pending.returnTo : '/';
|
|
142
|
+
return { accessToken: data.access_token, returnTo };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function signOutUrl() {
|
|
146
|
+
const params = new URLSearchParams({
|
|
147
|
+
client_id: clientId,
|
|
148
|
+
post_logout_redirect_uri: postLogoutRedirectUri,
|
|
149
|
+
});
|
|
150
|
+
return `${endSessionEndpoint}?${params}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The hosted account console (KAN-241), told which app to link back to. */
|
|
154
|
+
function accountUrl() {
|
|
155
|
+
const params = new URLSearchParams({
|
|
156
|
+
client_id: clientId,
|
|
157
|
+
return_to: postLogoutRedirectUri,
|
|
158
|
+
});
|
|
159
|
+
return `${issuer}/account?${params}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { beginSignIn, completeSignIn, signOutUrl, accountUrl };
|
|
163
|
+
}
|
package/esm/theme.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Theme plumbing: the same contract as the other FIKAR apps. The choice
|
|
2
|
+
// (light, dark, system) is the account's; this module mirrors it locally so
|
|
3
|
+
// the first paint is right, resolves system against the OS, and paints by
|
|
4
|
+
// toggling `dark` on <html> plus data-theme, which the vendored @fikar-ai/design
|
|
5
|
+
// tokens key on. The choice is stored under `${storagePrefix}-theme`.
|
|
6
|
+
|
|
7
|
+
/** The localStorage key for an app's theme choice. The page's inline
|
|
8
|
+
* first-paint script reads the same key before any module has run. */
|
|
9
|
+
export const themeKey = (prefix) => `${prefix}-theme`;
|
|
10
|
+
export const THEMES = ['light', 'dark', 'system'];
|
|
11
|
+
|
|
12
|
+
export function normalizeTheme(value) {
|
|
13
|
+
return THEMES.includes(value) ? value : 'system';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveTheme(theme, prefersDark) {
|
|
17
|
+
return theme === 'dark' || (theme === 'system' && prefersDark) ? 'dark' : 'light';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Wires storage, the root element and the OS query together. `onChange`
|
|
21
|
+
* receives the resolved theme after every paint, so canvases can repaint. */
|
|
22
|
+
export function createTheme({ storagePrefix, storage, root, matchMedia, onChange = () => {} }) {
|
|
23
|
+
if (typeof storagePrefix !== 'string' || !storagePrefix) throw new Error('storagePrefix is required');
|
|
24
|
+
const THEME_KEY = themeKey(storagePrefix);
|
|
25
|
+
let theme = normalizeTheme(storage.getItem(THEME_KEY));
|
|
26
|
+
const query = matchMedia('(prefers-color-scheme: dark)');
|
|
27
|
+
|
|
28
|
+
function apply() {
|
|
29
|
+
const resolved = resolveTheme(theme, query.matches);
|
|
30
|
+
root.classList.toggle('dark', resolved === 'dark');
|
|
31
|
+
root.setAttribute('data-theme', theme);
|
|
32
|
+
onChange(resolved);
|
|
33
|
+
return resolved;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function set(next) {
|
|
37
|
+
theme = normalizeTheme(next);
|
|
38
|
+
storage.setItem(THEME_KEY, theme);
|
|
39
|
+
return apply();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (typeof query.addEventListener === 'function') {
|
|
43
|
+
query.addEventListener('change', () => { if (theme === 'system') apply(); });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { get: () => theme, set, apply, resolved: () => resolveTheme(theme, query.matches) };
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fikar-ai/design",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "Fikar Ink design language — tokens, Tailwind preset + component layer. Single source of truth across all Fikar surfaces.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./fonts/*": "./fonts/*",
|
|
22
22
|
"./recipes/*": "./recipes/*",
|
|
23
23
|
"./shell.js": "./shell.js",
|
|
24
|
+
"./esm/*": "./esm/*",
|
|
24
25
|
"./templates/*": "./templates/*",
|
|
25
26
|
"./preset": {
|
|
26
27
|
"types": "./preset.d.ts",
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
"recipes/status-badge.html",
|
|
52
53
|
"recipes/time.html",
|
|
53
54
|
"shell.js",
|
|
55
|
+
"esm/",
|
|
54
56
|
"templates/",
|
|
55
57
|
"preset.js",
|
|
56
58
|
"preset.d.ts",
|
package/recipes/shell.html
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
<div class="meta"><b>Alex Morgan</b><br><span>Admin</span></div>
|
|
23
23
|
</div>
|
|
24
24
|
<button class="shell-collapse" type="button" aria-controls="sidebar" aria-pressed="false" title="Collapse sidebar">
|
|
25
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><
|
|
25
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/></svg>
|
|
26
26
|
<span class="nav-label">Collapse</span>
|
|
27
27
|
</button>
|
|
28
28
|
</aside>
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
'cost-calculator': '<rect width="16" height="20" x="4" y="2" rx="2"/><line x1="8" x2="16" y1="6" y2="6"/><line x1="16" x2="16" y1="14" y2="18"/><path d="M16 10h.01"/><path d="M12 10h.01"/><path d="M8 10h.01"/><path d="M12 14h.01"/><path d="M8 14h.01"/><path d="M12 18h.01"/><path d="M8 18h.01"/>',
|
|
19
19
|
'crs': '<circle cx="12" cy="4.5" r="2.5"/><path d="m10.2 6.3-3.9 3.9"/><circle cx="4.5" cy="12" r="2.5"/><path d="M7 12h10"/><circle cx="19.5" cy="12" r="2.5"/><path d="m13.8 17.7 3.9-3.9"/><circle cx="12" cy="19.5" r="2.5"/>',
|
|
20
20
|
'dictionary': '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/><path d="m8 13 4-7 4 7"/><path d="M9.1 11h5.7"/>',
|
|
21
|
+
'pii': '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12 2 2 4-4"/>',
|
|
21
22
|
'platform': '<line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/>',
|
|
22
23
|
'super-admin': '<path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/>',
|
|
23
24
|
} %}
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
</div>
|
|
47
47
|
{%- endif %}
|
|
48
48
|
<button class="shell-collapse" type="button" aria-controls="sidebar" aria-pressed="false" title="Collapse sidebar">
|
|
49
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><
|
|
49
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/></svg>
|
|
50
50
|
<span class="nav-label">Collapse</span>
|
|
51
51
|
</button>
|
|
52
52
|
</aside>
|