Compare commits

..

7 Commits

Author SHA1 Message Date
Claude
a11353577d chrome: brandIcons() — derive Next.js favicon metadata from Brand
Every app that imports the kit was shipping a /favicon.ico 404
because none of them wired up Next.js's metadata.icons. This adds
a tiny helper so an app only has to:

  export const metadata: Metadata = {
    title: brand.product,
    icons: brandIcons(brand),
  };

brandIcons() returns icon/shortcut/apple entries pointing at
brand.faviconUrl (new optional field, defaults to brand.logoUrl).
MIME type inferred from the URL extension (svg/png/ico).

Brand gains the optional faviconUrl field. Existing apps that just
pass logoUrl keep working — they'll now render the logo as the
favicon by default. Apps that want a separate icon set
faviconUrl explicitly.

First consumer: gscSounds layout — verified /favicon.ico now
serves the proper icon and /icon.svg works too.
2026-05-23 13:39:49 +02:00
Claude
71bce1bd56 Add i18n factory + AdminShell My Profile + LogoutButton anchor
- src/i18n/server.ts: createI18nConfig factory consolidating the locale
  resolution chain (cookie → access_token preferred_language claim →
  Accept-Language → default). Reusable across apps; previously each
  frontend reimplemented it.
- AdminShell: thread signoutPath + myProfileUrl (default
  https://my.gosec.internal/profile) into the navbar; render My Profile
  link alongside logout.
- LogoutButton: replace two-step fetch+signOut+redirect with a plain
  anchor pointing at signoutPath — the NextAuth POST-only signout
  endpoint plus form-CSRF flow doesn't need client JS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 20:50:37 +02:00
Claude
85a31eb3d6 feat(auth): force re-auth on failed refresh
requireAuth() now redirects on user.error so SSR catches stale sessions
that the cookie-presence middleware can't see. New SessionExpirationGuard
(client) listens on useSession() and calls signIn("keycloak") when the
JWT carries RefreshAccessTokenError or RefreshTokenMissing. Without it,
a tab idle past the Keycloak SSO lifetime sat on a dead accessToken
until a downstream API 401'd, with no UI-level redirect.

Bumps to 0.4.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:44:29 +02:00
Claude
ec33b7bcb8 fix(migrations): nav.apps demo placeholders → Phosphor icons
The four entries pointing at /images/demo/logos/{1-4}.svg (gsc-meet,
gsc-ai-hub, gsc-surveillance, gsc-dam) shipped icon URLs that don't
resolve in any consumer app's public/, producing 404s in every
AdminShell app-switcher render. Replaced with Phosphor classes +
bg-*-lt tinted tiles, matching the existing gsc-voice/gsc-archive
pattern in the same seed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:26:01 +02:00
Claude
08a62d550c feat(auth): refresh-token rotation, per-app role gate, custom error page
- jwt callback stores refresh_token + expires_at on initial sign-in and
  exchanges via Keycloak's token endpoint at expiresAt-30s. On refresh
  failure, marks session.user.error so consumers can force re-auth.
- New `requireClientRole(roles): boolean` option runs in the signIn
  callback against the access_token's realm_access.roles; false bounces
  the user to the configured error page with ?error=AccessDenied.
- New `pages` passthrough so consumers can route AccessDenied to a
  branded full-page message instead of NextAuth's default error UI.
- Realm roles are read by decoding the access_token, NOT from the OIDC
  `profile` (the userinfo endpoint never carries realm_access). Earlier
  code that read profile.realm_access.roles produced empty role arrays
  in both jwt and signIn — both callsites fixed via a shared
  rolesFromAccessToken helper.

Consumers: gscCRM v2.6.11..v2.6.19 (CRM enforces gsccrm_* groups via
requireClientRole + a /access-denied public route).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:25:50 +02:00
Claude
960dfeba7c feat(migrations): canonical nav schema + apps seed + menu-items template
Three SQL files for apps to copy into their own migrations directory:

- nav-schema.up.sql      — schema "nav" + enum menu_type + tables
                            menu_items, menu_role_requirements,
                            menu_permission_requirements,
                            menu_product_requirements, apps. Apply
                            verbatim; the kit owns it.
- nav-apps-seed.up.sql   — canonical cross-app browse-apps list,
                            idempotent INSERT … ON CONFLICT DO UPDATE.
                            Updates flow via kit version bumps; apps
                            re-apply to receive new entries.
- nav-menu-items-template.sql — TEMPLATE only. Each app copies once,
                            renames to its next migration number, and
                            edits the seed rows for its own sidebar/
                            topbar/subbar items.

Adoption pattern documented in the kit README.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:24:46 +02:00
Claude
440f815df7 feat(chrome)!: v0.4.0 — AdminShell + headers as /chrome sub-export
New `./chrome` entrypoint exporting `<AdminShell>` and the header
components (Search, SearchHistory, SearchOptions, Messages, BrowseApps,
HeaderCustomers, HeaderContacts, LogoutButton). Refactored from the
Chronos-style AdminShell that gscCRM was vendoring byte-for-byte —
header/footer/sidebar are now a single shared surface across apps.

Explicit props contract (no site-informations.json, no internal data
sources): `menus`, `apps`, `user`, `brand` are required; `features.*`
flags gate every section (search/browseApps/messages/notifications/
subbar*/pageHeader*/activityPanel/chat/footer); `slots.*` lets apps
inject content; `labels` overrides the next-intl "chrome" namespace.

Locale-aware navigation: chrome calls useLocale() and prepends
/{locale} to internal menu URLs, leaving externals (http(s)://…) and
the "#" sentinel alone. Breadcrumbs and the path-derived page title
strip the leading locale segment so they read "Contacts" not
"En › Contacts". Necessary for `localePrefix: 'always'` consumers like
gscCRM.

Phosphor 2.x icons: `normalizeIconClass` prepends the base `ph` class
(compound selectors `.ph.ph-house:before` require both). All hardcoded
`<i className="ph-…">` sites switched to `ph ph-…`.

`next-intl` and `next-auth` moved to peerDependencies (with devDep
copies for the kit's own typecheck/build). Consumers must symlink their
installed copies into the kit's node_modules at build time — otherwise
useTranslations()/useSession() bind to a separate React context and
next-intl throws Error(void 0) on render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:24:16 +02:00
29 changed files with 2856 additions and 31 deletions

150
README.md
View File

@@ -29,7 +29,10 @@ your app
```ts
import "@gsc/web-kit/css"; // CSS bundle (layout-3 + JetBrains Mono)
// Chrome
// Chrome — unified header/footer/sidebar shell
import { AdminShell } from "@gsc/web-kit/chrome";
// Lower-level layout primitives
import { AppLayout } from "@gsc/web-kit/layout";
import { useShell } from "@gsc/web-kit/shell";
import { fetchShellConfig } from "@gsc/web-kit/shell/server";
@@ -81,8 +84,149 @@ The `/api` sub-export is reserved for a future HTTP client helper; it currently
| 2 | layout · auth · shell — usable end-to-end with shell-api | **done** |
| 3 | data · forms — curated re-exports from limitless + validation | **done (v0.3.0)** |
| 4 | feedback · navigation · utils — curated re-exports from limitless | **done (v0.3.0)** |
| 4a | api · HTTP client helper (Bearer injection, 401 → signInRedirect) | planned |
| 5 | Roll out to gscCRM / gscChronos / gscAdmin / gscPortal | planned |
| 5 | chrome · AdminShell + headers + LogoutButton + nav migrations | **done (v0.4.0)** |
| 5a | Roll out chrome to gscCRM / gscChronos / gscAdmin / gscPortal | in progress |
| 6 | api · HTTP client helper (Bearer injection, 401 → signInRedirect) | planned |
---
## Chrome (`/chrome`) — v0.4.0
Unified `AdminShell` providing navbar, subbar, page-header, sidebar, footer,
optional chat overlay and activity panel. Every app receives the same UI
chrome and toggles features it doesn't use via `features` props.
### Adopting chrome in a new app
1. **Add the dep** (already a `file:` resolve to this kit) and import:
```tsx
// app/[locale]/layout.tsx
import { AdminShell } from "@gsc/web-kit/chrome";
import "@gsc/web-kit/css";
```
2. **Apply nav migrations** to your app's database:
```bash
# In your app's migrations directory, copy the two kit-canonical files verbatim
cp node_modules/@gsc/web-kit/migrations/nav-schema.up.sql apps/<your-app>/migrations/00X_nav_schema.up.sql
cp node_modules/@gsc/web-kit/migrations/nav-apps-seed.up.sql apps/<your-app>/migrations/00Y_nav_apps_seed.up.sql
# Then copy the menu-items template once and adapt to your app's menu
cp node_modules/@gsc/web-kit/migrations/nav-menu-items-template.sql apps/<your-app>/migrations/00Z_nav_menu_items.up.sql
# edit 00Z_… to replace example rows with your app's sidebar/topbar entries
psql "$DATABASE_URL" -f apps/<your-app>/migrations/00X_nav_schema.up.sql
psql "$DATABASE_URL" -f apps/<your-app>/migrations/00Y_nav_apps_seed.up.sql
psql "$DATABASE_URL" -f apps/<your-app>/migrations/00Z_nav_menu_items.up.sql
```
Re-copy `nav-schema` + `nav-apps-seed` on every kit upgrade. The
menu-items file is yours after the first copy — the kit never touches it
again.
3. **Add Prisma** to read the data:
```prisma
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
schemas = ["nav"]
}
generator client {
provider = "prisma-client-js"
previewFeatures = ["multiSchema"]
}
```
Add `"postinstall": "prisma generate"` to package.json. Copy `prisma/`
into the Docker image *before* `npm install` so the generate step sees it.
4. **Wire the layout server component**:
```tsx
const [sidebar, topbar, subbar, apps] = await Promise.all([
getMenuItemsByType("sidebar"),
getMenuItemsByType("topbar"),
getMenuItemsByType("subbar"),
getApps(),
]);
return (
<AdminShell
menus={{ sidebar, topbar, subbar }}
apps={apps}
user={{ displayName, email, avatarUrl }}
brand={{
name: "MyApp",
product: "GoSec MyApp",
logoUrl: "https://assets.gosec.cloud/logos/logo.svg",
websiteUrl: "https://gosec.cloud",
supportUrl: "https://support.gosec.cloud/",
docsUrl: "https://support.gosec.cloud/docs",
copyrightStartYear: 2024,
}}
features={{ chat: false, activityPanel: false }}
>
{children}
</AdminShell>
);
```
### Props reference
`<AdminShell>` is the kit's contract — see `src/chrome/types.ts` for the
authoritative TypeScript definition. Summary:
- **Data**: `menus`, `apps`, `user`, `notificationCount?`, `activity?`
- **Brand** (required): `name`, `product`, `logoUrl`, `websiteUrl`,
`supportUrl`, `docsUrl`, `copyrightStartYear` (+ optional `logoSmallUrl`)
- **`features`** (all optional booleans, sensible defaults):
`search`, `searchHistory`, `searchOptions`, `browseApps`, `messages`,
`notifications`, `subbar`, `subbarSupport`, `subbarSettings`,
`pageHeader`, `pageHeaderCustomers`, `pageHeaderContacts`,
`activityPanel`, `chat`, `footer`
- **`slots`** (ReactNode overrides):
`pageTitle`, `pageHeaderExtras`, `subbarExtras`, `activityPanel`,
`navbarExtras`, `footerExtras`
- **Behavior**: `onSignOut?` (default `next-auth signOut`),
`labels?: Partial<ChromeLabels>` (override individual chrome strings)
### i18n
Chrome's own strings come from next-intl namespace `chrome` with English
fallbacks. Add to your app's `common.json`:
```json
{
"chrome": {
"navigation": "Navigation",
"logout": "Logout",
"support": "Support",
...
}
}
```
Menu item labels (`menu.dashboard`, `menu.accounts`, …) live in your app's
existing translation namespace.
### Semver
Public surface = `<AdminShell>` props + exported types + migration files +
chrome CSS class names.
- **Major** — removed/renamed prop, changed prop shape, removed feature
flag, renamed CSS class, changed migration DDL.
- **Minor** — new optional prop, new feature flag (default off), new
slot, new export, new migration file.
- **Patch** — bugfix, internal refactor.
Deprecations land for one minor release with `console.warn` and a
`CHANGELOG.md` note before removal in the next major. Pin
`"@gsc/web-kit": "^X.Y.Z"` to receive minors automatically.
---
## Notes

View File

@@ -0,0 +1,30 @@
-- @gsc/web-kit nav.apps canonical seed (apply verbatim, never edit)
--
-- Replicated into every app's nav.apps table so the "browse apps" panel
-- shows the same canonical list everywhere. Updates flow via kit version
-- bumps: re-copy this file into each app's migrations directory and re-apply.
--
-- ON CONFLICT (key) DO UPDATE keeps the canonical list in sync — any local
-- edits to these rows will be overwritten on next apply. Add new apps by
-- editing this file in the kit and bumping the kit's minor version.
INSERT INTO nav.apps (key, name, description, url, icon_class, icon_url, icon_bg, sort_order, enabled)
VALUES
('gsc-crm', 'CRM', 'Customer relationship management', 'https://crm.gosec.internal', NULL, 'https://assets.gosec.cloud/logos/crm.svg', NULL, 10, TRUE),
('gsc-chronos', 'Chronos', 'Time tracking and timesheets', 'https://chronos.gosec.internal', NULL, 'https://assets.gosec.cloud/logos/chronos.svg', NULL, 20, TRUE),
('gsc-meet', 'GSC Meet', 'Video conferencing with AI features', '/apps/gsc-meet', 'ph-video-camera', NULL, 'bg-success-lt', 30, TRUE),
('gsc-voice', 'Voice', 'PBX and telephony management', '/apps/gsc-voice', 'ph-phone', NULL, 'bg-primary-lt', 40, TRUE),
('gsc-ai-hub', 'AI Hub', 'AI models and services management', '/apps/gsc-ai-hub', 'ph-brain', NULL, 'bg-warning-lt', 50, TRUE),
('gsc-surveillance', 'Surveillance', 'Video surveillance and security', '/apps/surveillance', 'ph-shield-check', NULL, 'bg-danger-lt', 60, TRUE),
('gsc-archive', 'Archive', 'Email archiving and eDiscovery', '/apps/gsc-archive', 'ph-archive-box', NULL, 'bg-info-lt', 70, TRUE),
('gsc-dam', 'Digital Asset Manager', 'Media and asset management platform', '/apps/gsc-dam', 'ph-images-square', NULL, 'bg-secondary-lt', 80, TRUE)
ON CONFLICT (key) DO UPDATE
SET name = EXCLUDED.name,
description = EXCLUDED.description,
url = EXCLUDED.url,
icon_class = EXCLUDED.icon_class,
icon_url = EXCLUDED.icon_url,
icon_bg = EXCLUDED.icon_bg,
sort_order = EXCLUDED.sort_order,
enabled = EXCLUDED.enabled,
updated_at = NOW();

View File

@@ -0,0 +1,70 @@
-- @gsc/web-kit nav.menu_items TEMPLATE — copy once, then adapt per app
--
-- Each app curates its own sidebar/topbar/subbar items. Copy this file into
-- your app's migrations directory (e.g. apps/gscFoo/migrations/00X_nav_menu_items.up.sql),
-- replace the example rows below with your app's real menu, and apply.
--
-- The kit will NOT update this file in subsequent versions — once you've
-- copied and adapted it, it's yours. The DDL (nav-schema.up.sql) and the
-- canonical apps list (nav-apps-seed.up.sql) are kit-owned and re-copied
-- on every kit upgrade; menu_items rows belong to the app.
--
-- Notes on columns:
-- - key: unique stable identifier (used by ON CONFLICT)
-- - translation_key: dotted i18n key, e.g. "menu.dashboard"
-- (resolved via next-intl namespace "menu")
-- - url: "/dashboard" — pass "#" for parent items with children
-- - icon: Phosphor CSS class, e.g. "ph-house"
-- - menu_type: 'sidebar' (left nav), 'topbar' (user dropdown), 'subbar' (settings dropdown)
-- - parent_id: UUID of parent menu_item for nested items (or NULL)
-- - sort_order: ascending; render order within type/parent
-- - is_system_required: cannot be hidden/removed by user customization
-- ============================================================================
-- SIDEBAR (left navigation)
-- ============================================================================
INSERT INTO nav.menu_items (menu_type, key, translation_key, url, icon, sort_order, is_active, is_system_required)
VALUES
-- Replace these example rows with your app's sidebar items.
('sidebar', 'dashboard', 'menu.dashboard', '/dashboard', 'ph-house', 10, TRUE, TRUE),
('sidebar', 'example-1', 'menu.example1', '/example-1', 'ph-folder-open', 20, TRUE, FALSE),
('sidebar', 'example-2', 'menu.example2', '/example-2', 'ph-list', 30, TRUE, FALSE)
ON CONFLICT (key) DO NOTHING;
-- ============================================================================
-- TOPBAR (user dropdown in navbar)
-- ============================================================================
INSERT INTO nav.menu_items (menu_type, key, translation_key, url, icon, sort_order, is_active, is_system_required)
VALUES
('topbar', 'profile', 'menu.profile', '/profile', 'ph-user', 10, TRUE, TRUE),
('topbar', 'settings', 'menu.settings', '/settings', 'ph-gear', 20, TRUE, TRUE)
ON CONFLICT (key) DO NOTHING;
-- ============================================================================
-- SUBBAR (Settings dropdown items)
-- ============================================================================
-- Uncomment / customize if your app exposes a Settings dropdown in the subbar.
--
-- INSERT INTO nav.menu_items (menu_type, key, translation_key, url, icon, sort_order, is_active, is_system_required)
-- VALUES
-- ('subbar', 'general-settings', 'menu.generalSettings', '/settings/general', 'ph-sliders', 10, TRUE, FALSE),
-- ('subbar', 'notifications', 'menu.notifications', '/settings/notifications', 'ph-bell', 20, TRUE, FALSE)
-- ON CONFLICT (key) DO NOTHING;
-- ============================================================================
-- NESTED EXAMPLES (parent + children)
-- ============================================================================
-- For nested sidebar items, set url='#' on the parent and reference its id
-- via parent_id on children. Example:
--
-- WITH parent AS (
-- INSERT INTO nav.menu_items (menu_type, key, translation_key, url, icon, sort_order)
-- VALUES ('sidebar', 'reports', 'menu.reports', '#', 'ph-chart-bar', 90)
-- ON CONFLICT (key) DO UPDATE SET sort_order = EXCLUDED.sort_order
-- RETURNING id
-- )
-- INSERT INTO nav.menu_items (menu_type, parent_id, key, translation_key, url, icon, sort_order)
-- SELECT 'sidebar', parent.id, 'reports-sales', 'menu.reportsSales', '/reports/sales', 'ph-chart-line', 10 FROM parent
-- UNION ALL
-- SELECT 'sidebar', parent.id, 'reports-leads', 'menu.reportsLeads', '/reports/leads', 'ph-chart-pie', 20 FROM parent
-- ON CONFLICT (key) DO NOTHING;

View File

@@ -0,0 +1,117 @@
-- @gsc/web-kit nav schema (canonical, apply verbatim, never edit)
--
-- Creates the navigation tables consumed by @gsc/web-kit/chrome AdminShell:
-- - nav.menu_items — per-app sidebar/topbar/subbar items
-- - nav.menu_role_requirements — role gating (enforcement TBD)
-- - nav.menu_permission_requirements
-- - nav.menu_product_requirements
-- - nav.apps — cross-app "browse apps" panel data
--
-- Per-app philosophy: each app applies this schema to its OWN database
-- (gsc_crm, gsc_chronos, gsc_admin, ...). Menu items are app-specific;
-- the apps list is replicated from nav-apps-seed.up.sql so every app's
-- "browse apps" panel shows the same canonical list.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE SCHEMA IF NOT EXISTS nav;
-- ---------------------------------------------------------------------------
-- Enums
-- ---------------------------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'menu_type' AND n.nspname = 'nav'
) THEN
CREATE TYPE nav.menu_type AS ENUM ('sidebar', 'topbar', 'subbar');
END IF;
END$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typname = 'requirement_logic' AND n.nspname = 'nav'
) THEN
CREATE TYPE nav.requirement_logic AS ENUM ('OR', 'AND');
END IF;
END$$;
-- ---------------------------------------------------------------------------
-- menu_items
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS nav.menu_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
menu_type nav.menu_type NOT NULL,
parent_id UUID NULL REFERENCES nav.menu_items(id) ON DELETE CASCADE,
key VARCHAR(64) NOT NULL UNIQUE,
translation_key VARCHAR(128) NOT NULL,
url VARCHAR(256) NOT NULL,
icon VARCHAR(64) NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_system_required BOOLEAN NOT NULL DEFAULT FALSE,
role_logic nav.requirement_logic NOT NULL DEFAULT 'OR',
permission_logic nav.requirement_logic NOT NULL DEFAULT 'OR',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_menu_items_type ON nav.menu_items (menu_type);
CREATE INDEX IF NOT EXISTS idx_menu_items_parent ON nav.menu_items (parent_id);
CREATE INDEX IF NOT EXISTS idx_menu_items_sort ON nav.menu_items (sort_order);
-- ---------------------------------------------------------------------------
-- menu requirement tables (role / permission / product)
-- Cross-DB IDs stored as plain strings; no cross-DB FKs.
-- Enforcement is not yet wired in v0.4.0 chrome.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS nav.menu_role_requirements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
menu_item_id UUID NOT NULL REFERENCES nav.menu_items(id) ON DELETE CASCADE,
role_id VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (menu_item_id, role_id)
);
CREATE TABLE IF NOT EXISTS nav.menu_permission_requirements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
menu_item_id UUID NOT NULL REFERENCES nav.menu_items(id) ON DELETE CASCADE,
permission_id VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (menu_item_id, permission_id)
);
CREATE TABLE IF NOT EXISTS nav.menu_product_requirements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
menu_item_id UUID NOT NULL REFERENCES nav.menu_items(id) ON DELETE CASCADE,
product_id VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (menu_item_id, product_id)
);
-- ---------------------------------------------------------------------------
-- apps — cross-app browse-apps panel data
-- Either icon_class (Phosphor / FA class) or icon_url (logo image) may be set.
-- icon_bg styles the colored square behind icon_class (e.g. 'bg-primary-lt').
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS nav.apps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key VARCHAR(64) NOT NULL UNIQUE,
name VARCHAR(128) NOT NULL,
description VARCHAR(256) NOT NULL DEFAULT '',
url VARCHAR(512) NOT NULL,
icon_class VARCHAR(64) NULL,
icon_url VARCHAR(512) NULL,
icon_bg VARCHAR(32) NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_apps_sort_order ON nav.apps (sort_order);

116
package-lock.json generated
View File

@@ -1,17 +1,15 @@
{
"name": "@gsc/web-kit",
"version": "0.2.0",
"version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@gsc/web-kit",
"version": "0.2.0",
"version": "0.4.0",
"license": "MIT",
"dependencies": {
"@limitless/ui": "file:../limitless-ui",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.6.1",
"zod": "^3.23.0"
},
"devDependencies": {
@@ -19,15 +17,23 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"next": "16.1.1",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.6.1",
"typescript": "^5.4.0"
},
"peerDependencies": {
"@gsc/chat": "*",
"bootstrap": "^5.3.3",
"next": ">=15.0.0",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.6.0",
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@gsc/chat": {
"optional": true
},
"bootstrap": {
"optional": true
}
@@ -62,6 +68,7 @@
"version": "0.41.2",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz",
"integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==",
"dev": true,
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
@@ -91,6 +98,7 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -101,12 +109,14 @@
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.4.tgz",
"integrity": "sha512-Lbke1aOrsygKKR09Ux0NrZgbTqpDmiwXOgzyDOJ8Owr1zd5qOKTauf62hH+Seeku3ju77rHWH9I5SfX2CN0vuA==",
"dev": true,
"license": "MIT"
},
"node_modules/@formatjs/icu-messageformat-parser": {
"version": "3.5.7",
"resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.7.tgz",
"integrity": "sha512-wJxRZ+SiUCIMTL86bQlZU9bEKDQqqvgk2ezQ1BySUdWRfHqOzj4IKUVFeUZKS9w58M4e7wMSG0Sl86LAPb7Qww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@formatjs/icu-skeleton-parser": "2.1.7"
@@ -116,12 +126,14 @@
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.7.tgz",
"integrity": "sha512-cIw1SFP0bi0CUBiJ2jzp99ws3OJNQDfStcHq9Z0iHWzItmiIikihFO+npR8C80yDlp7ZuBCLXCcKjgWjHicksA==",
"dev": true,
"license": "MIT"
},
"node_modules/@formatjs/intl-localematcher": {
"version": "0.8.6",
"resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.6.tgz",
"integrity": "sha512-AZRgUxj0q93lyF7Z5lFS85bLINXuBLX4R3tCKicO6fSWo6cvh9GQfoR3B1WlsqQwefZ1QORTivhInx7gM6HUzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@formatjs/fast-memoize": "3.1.4"
@@ -131,6 +143,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -144,6 +157,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -166,6 +180,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -188,6 +203,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -204,6 +220,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -220,6 +237,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -236,6 +254,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -252,6 +271,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -268,6 +288,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -284,6 +305,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -300,6 +322,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -316,6 +339,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -332,6 +356,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -348,6 +373,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -370,6 +396,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -392,6 +419,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -414,6 +442,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -436,6 +465,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -458,6 +488,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -480,6 +511,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -502,6 +534,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -524,6 +557,7 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -543,6 +577,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -562,6 +597,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -581,6 +617,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -601,6 +638,7 @@
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz",
"integrity": "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==",
"dev": true,
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
@@ -610,6 +648,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -626,6 +665,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -642,6 +682,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -658,6 +699,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -674,6 +716,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -690,6 +733,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -706,6 +750,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -722,6 +767,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -735,6 +781,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -744,6 +791,7 @@
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -782,6 +830,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -802,6 +851,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -822,6 +872,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -842,6 +893,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -862,6 +914,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -882,6 +935,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -902,6 +956,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -922,6 +977,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -942,6 +998,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -962,6 +1019,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -982,6 +1040,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1002,6 +1061,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1022,6 +1082,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1051,6 +1112,7 @@
"version": "1.21.5",
"resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz",
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
"dev": true,
"license": "MIT"
},
"node_modules/@swc/core-darwin-arm64": {
@@ -1060,6 +1122,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1076,6 +1139,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1092,6 +1156,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -1108,6 +1173,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1124,6 +1190,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1140,6 +1207,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1156,6 +1224,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1172,6 +1241,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1188,6 +1258,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1204,6 +1275,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1220,6 +1292,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1236,6 +1309,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1249,12 +1323,14 @@
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
@@ -1264,6 +1340,7 @@
"version": "0.1.26",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
"integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
@@ -1303,6 +1380,7 @@
"version": "2.10.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
"integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -1336,6 +1414,7 @@
"version": "1.0.30001792",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
"integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -1356,6 +1435,7 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"dev": true,
"license": "MIT"
},
"node_modules/csstype": {
@@ -1369,6 +1449,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -1378,6 +1459,7 @@
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.11.1.tgz",
"integrity": "sha512-C0tsPVuvyNp+++qWJP+mty/KLLStjerOZqu3W1xWLJkChEDbDi9Taoj6blK7L/onxbuVzwgH6k9Sf+rOV6lOvw==",
"dev": true,
"funding": [
{
"type": "individual",
@@ -1393,6 +1475,7 @@
"version": "11.2.4",
"resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.4.tgz",
"integrity": "sha512-iKP6+uJXn+XcfRgYfGPE3+mqCoODV2vATrXDLo/YkYgIdelJHJPBEbc0GZThipAYPuk+8QJFiPgOfblU085ABg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@formatjs/fast-memoize": "3.1.4",
@@ -1403,6 +1486,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -1412,6 +1496,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -1424,6 +1509,7 @@
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -1433,6 +1519,7 @@
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
@@ -1451,6 +1538,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -1460,6 +1548,7 @@
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/next/-/next-16.1.1.tgz",
"integrity": "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/env": "16.1.1",
@@ -1513,6 +1602,7 @@
"version": "5.0.0-beta.31",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz",
"integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==",
"dev": true,
"license": "ISC",
"dependencies": {
"@auth/core": "0.41.2"
@@ -1540,6 +1630,7 @@
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.11.1.tgz",
"integrity": "sha512-s32lFFLXkxrW6fy+4IVaGD5J8xPpbEDFLfBbXV73CTbHAGhOGMjYN4/rftdsKOQ44AnPhnZ5Et+ZNMr5tRpsqA==",
"dev": true,
"funding": [
{
"type": "individual",
@@ -1571,12 +1662,14 @@
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.11.1.tgz",
"integrity": "sha512-jHKGij7NoYccy2y54+e/wHVMoRgNt4h/Kn0XS9c4GbKu3KgJyANLUN8sFcDixv6sqz4V2kh6CTWgrkIidQksUg==",
"dev": true,
"license": "MIT"
},
"node_modules/next-intl/node_modules/@swc/core": {
"version": "1.15.33",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz",
"integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1617,6 +1710,7 @@
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
@@ -1628,12 +1722,14 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true,
"license": "MIT"
},
"node_modules/oauth4webapi": {
"version": "3.8.6",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -1643,12 +1739,14 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -1661,12 +1759,14 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz",
"integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -1695,6 +1795,7 @@
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -1705,6 +1806,7 @@
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"preact": ">=10"
@@ -1744,6 +1846,7 @@
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"dev": true,
"license": "ISC",
"optional": true,
"bin": {
@@ -1757,6 +1860,7 @@
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
@@ -1802,6 +1906,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -1811,6 +1916,7 @@
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
@@ -1834,6 +1940,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/typescript": {
@@ -1861,6 +1968,7 @@
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.11.1.tgz",
"integrity": "sha512-/dqWSqUSbVMzC+fdy7io8enhGYHeGeHK1bFhTLrp0ZblqdzY4FkE+tkffW6IfCauqaIA2/z4DQae4XEn93+raw==",
"dev": true,
"funding": [
{
"type": "individual",

View File

@@ -1,12 +1,16 @@
{
"name": "@gsc/web-kit",
"version": "0.3.0",
"description": "GSC web app skeleton \u2014 layout, auth, data, forms, feedback, navigation. Built on @limitless/ui. Drop into a Next.js app and just write pages.",
"version": "0.5.1",
"description": "GSC web app skeleton layout, auth, data, forms, feedback, navigation, chrome. Built on @limitless/ui. Drop into a Next.js app and just write pages.",
"license": "MIT",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"migrations"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
@@ -37,6 +41,10 @@
"types": "./dist/shell/server.d.ts",
"import": "./dist/shell/server.js"
},
"./chrome": {
"types": "./dist/chrome/index.d.ts",
"import": "./dist/chrome/index.js"
},
"./data": {
"types": "./dist/data/index.d.ts",
"import": "./dist/data/index.js"
@@ -57,10 +65,21 @@
"types": "./dist/api/index.d.ts",
"import": "./dist/api/index.js"
},
"./i18n": {
"types": "./dist/i18n/index.d.ts",
"import": "./dist/i18n/index.js"
},
"./i18n/server": {
"types": "./dist/i18n/server.d.ts",
"import": "./dist/i18n/server.js"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"import": "./dist/utils/index.js"
}
},
"./migrations/nav-schema.up.sql": "./migrations/nav-schema.up.sql",
"./migrations/nav-apps-seed.up.sql": "./migrations/nav-apps-seed.up.sql",
"./migrations/nav-menu-items-template.sql": "./migrations/nav-menu-items-template.sql"
},
"sideEffects": [
"**/*.css"
@@ -72,24 +91,28 @@
},
"dependencies": {
"@limitless/ui": "file:../limitless-ui",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.6.1",
"zod": "^3.23.0"
},
"peerDependencies": {
"@gsc/chat": "*",
"bootstrap": "^5.3.3",
"next": ">=15.0.0",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.6.0",
"react": "^18.2.0 || ^19.0.0",
"react-dom": "^18.2.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"bootstrap": { "optional": true }
"bootstrap": { "optional": true },
"@gsc/chat": { "optional": true }
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"next": "16.1.1",
"next-auth": "^5.0.0-beta.25",
"next-intl": "^4.6.1",
"typescript": "^5.4.0"
}
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useEffect, useRef } from "react";
import { useSession, signIn } from "next-auth/react";
import type { SessionUser } from "./types";
/**
* Drop-in client component that re-triggers Keycloak sign-in when the
* kit's server-side refresh flow gives up (`session.user.error` set to
* `RefreshAccessTokenError` or `RefreshTokenMissing`).
*
* Without this, a UI tab left open past the Keycloak SSO session lifetime
* holds a stale `accessToken`: the middleware sees a cookie, `requireAuth`
* sees an `accessToken` string, and the user only finds out things are
* broken when a downstream API returns 401. Mount this inside a
* `<SessionProvider>` (typically alongside other providers in the locale
* root) so the session refetch interval surfaces the error within minutes.
*
* Renders nothing.
*/
export function SessionExpirationGuard() {
const { data: session } = useSession();
const error = (session?.user as Partial<SessionUser> | undefined)?.error;
// Guard against React's StrictMode double-invoke + the brief window
// between calling signIn and the page navigating away.
const firedRef = useRef(false);
useEffect(() => {
if (!error || firedRef.current) return;
if (
error !== "RefreshAccessTokenError" &&
error !== "RefreshTokenMissing"
) {
return;
}
firedRef.current = true;
const callbackUrl =
typeof window !== "undefined"
? window.location.pathname + window.location.search
: "/";
// signIn() POSTs the CSRF-protected provider endpoint, which then
// initiates a fresh OIDC flow; the stale session cookie is replaced
// atomically when the flow completes. Keycloak's own SSO cookie may
// still log the user in transparently if it hasn't also expired.
void signIn("keycloak", { callbackUrl });
}, [error]);
return null;
}

View File

@@ -7,6 +7,7 @@
*/
export type { SessionUser } from "./types";
export { SessionExpirationGuard } from "./SessionExpirationGuard";
/**
* Client-side hard navigation to the sign-in endpoint. Use when a

View File

@@ -1,9 +1,75 @@
import NextAuth, { type NextAuthResult } from "next-auth";
import Keycloak from "next-auth/providers/keycloak";
import type { JWT } from "next-auth/jwt";
import { redirect } from "next/navigation";
import type { CreateAuthOptions, SessionUser } from "./types";
/**
* Refresh the Keycloak access token using the stored refresh_token. Returns
* an updated JWT with new accessToken/expiresAt/refreshToken/idToken, or
* the original JWT marked with `error: "RefreshAccessTokenError"` if the
* refresh failed (caller surfaces this through the session so the client
* can re-trigger sign-in).
*
* Pre-refresh skew is handled by the caller; this function unconditionally
* exchanges the refresh_token.
*/
async function refreshAccessToken(
token: JWT,
keycloak: CreateAuthOptions["keycloak"],
): Promise<JWT> {
const refreshToken = (token as { refreshToken?: string }).refreshToken;
if (!refreshToken) {
return { ...token, error: "RefreshTokenMissing" };
}
try {
const res = await fetch(
`${keycloak.issuer}/protocol/openid-connect/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: keycloak.clientId,
client_secret: keycloak.clientSecret,
refresh_token: refreshToken,
}),
},
);
const body = (await res.json()) as {
access_token?: string;
expires_in?: number;
refresh_token?: string;
id_token?: string;
error?: string;
error_description?: string;
};
if (!res.ok || !body.access_token) {
console.error(
"@gsc/web-kit: refresh_token exchange failed",
res.status,
body,
);
return { ...token, error: "RefreshAccessTokenError" };
}
return {
...token,
accessToken: body.access_token,
expiresAt:
Math.floor(Date.now() / 1000) + (body.expires_in ?? 300),
// Keycloak rotates refresh tokens by default; fall back to the
// existing one when rotation is disabled at the realm.
refreshToken: body.refresh_token ?? refreshToken,
idToken: body.id_token ?? token.idToken,
error: undefined,
} as JWT;
} catch (err) {
console.error("@gsc/web-kit: refresh_token exchange threw", err);
return { ...token, error: "RefreshAccessTokenError" };
}
}
export type { SessionUser, CreateAuthOptions };
/**
@@ -62,6 +128,35 @@ export interface AuthBundle {
* },
* });
*/
/**
* Decode the Keycloak access_token (JWS) and return its `realm_access.roles`.
* Realm roles are only present in the access_token, never in the OIDC
* userinfo `profile` — so callbacks that need to gate on roles must reach
* into `account.access_token`, not `profile`. We don't verify the signature
* here: NextAuth has already verified the ID token via the OIDC flow, the
* access_token came back over the same TLS exchange, and the only use of
* the decoded roles is local sign-in policy.
*/
function rolesFromAccessToken(accessToken: string | undefined): string[] {
if (!accessToken) return [];
const parts = accessToken.split(".");
if (parts.length < 2) return [];
try {
const payload = parts[1];
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
const decoded = Buffer.from(
padded.replace(/-/g, "+").replace(/_/g, "/"),
"base64",
).toString("utf-8");
const claims = JSON.parse(decoded) as {
realm_access?: { roles?: string[] };
};
return claims.realm_access?.roles ?? [];
} catch {
return [];
}
}
export function createAuth(opts: CreateAuthOptions): AuthBundle {
// NextAuth v5: provider-specific paths like /api/auth/signin/keycloak
// are POST-only (CSRF-protected form submit). A GET redirect there
@@ -70,8 +165,6 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
// lists configured providers. Apps wanting one-click Keycloak can
// override signInPath with a custom page that calls signIn('keycloak').
const signInPath = opts.signInPath ?? "/api/auth/signin";
const defaultTenantId =
opts.defaultTenantId ?? "00000000-0000-0000-0000-000000000000";
const na = NextAuth({
providers: [
@@ -82,35 +175,76 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
}),
],
trustHost: true,
pages: opts.pages,
callbacks: {
async signIn({ account }) {
// Per-client access gate. The kit's consumers pass a predicate
// (e.g. "must be in gsccrm_* group") via `requireClientRole`; we
// run it against the access_token's `realm_access.roles`. Realm
// roles are NOT present in the OIDC userinfo `profile`, only in
// the access_token — so we decode it. Return false → NextAuth
// refuses to mint a session and bounces the user to the configured
// error page with `?error=AccessDenied`.
if (!opts.requireClientRole) return true;
const roles = rolesFromAccessToken(account?.access_token);
return opts.requireClientRole(roles);
},
async jwt({ token, account, profile }) {
// `account` + `profile` are only set on the initial sign-in.
// On subsequent calls the token is read from the encrypted JWT
// cookie, so the fields we set here persist for the session.
// Initial sign-in: capture identity claims + refresh material.
if (account && profile) {
// Realm convention: GSC identity ships as three claims sourced
// from FreeIPA user attributes via the gosecCloud realm's LDAP
// federation + per-client OIDC protocol mappers.
const p = profile as {
gscTenantId?: string;
gscCustomerId?: string;
gscSID?: string;
};
if (!p.gscTenantId || !p.gscCustomerId || !p.gscSID) {
throw new Error(
`Keycloak token missing required GSC identity claims (gscTenantId=${!!p.gscTenantId}, gscCustomerId=${!!p.gscCustomerId}, gscSID=${!!p.gscSID})`,
);
}
token.keycloakId = profile.sub;
token.tenantId =
(profile as { tenant_id?: string }).tenant_id ?? defaultTenantId;
token.tenantId = p.gscTenantId;
token.customerId = p.gscCustomerId;
token.gscSid = p.gscSID;
token.displayName =
profile.name ??
(profile as { preferred_username?: string }).preferred_username ??
"";
token.givenName = profile.given_name ?? "";
token.familyName = profile.family_name ?? "";
token.roles =
(profile as { realm_access?: { roles?: string[] } }).realm_access
?.roles ?? [];
// Realm roles are in the access_token, not the OIDC profile.
token.roles = rolesFromAccessToken(account.access_token);
token.accessToken = account.access_token;
token.idToken = account.id_token;
token.refreshToken = account.refresh_token;
// `expires_at` is Unix seconds (NextAuth's normalized form);
// fall back to `expires_in` (relative) if the provider omitted it.
token.expiresAt =
(account.expires_at as number | undefined) ??
Math.floor(Date.now() / 1000) +
((account.expires_in as number | undefined) ?? 300);
return token;
}
return token;
// Subsequent calls: refresh if the access token is about to expire.
// Skew of 30s avoids handing out a token that will time out mid-flight.
const expiresAt = (token as { expiresAt?: number }).expiresAt ?? 0;
if (Math.floor(Date.now() / 1000) < expiresAt - 30) {
return token;
}
return refreshAccessToken(token, opts.keycloak);
},
async session({ session, token }) {
if (!token) return session;
const user: SessionUser = {
id: token.sub as string,
keycloakId: (token.keycloakId as string) ?? (token.sub as string),
tenantId: (token.tenantId as string) ?? defaultTenantId,
tenantId: token.tenantId as string,
customerId: token.customerId as string,
gscSid: token.gscSid as string,
email: session.user?.email ?? "",
displayName: (token.displayName as string) ?? "",
givenName: (token.givenName as string) ?? "",
@@ -118,6 +252,7 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
roles: (token.roles as string[]) ?? [],
accessToken: (token.accessToken as string) ?? "",
idToken: token.idToken as string | undefined,
error: (token as { error?: string }).error,
};
// NextAuth's default `session.user` type is narrow; we replace
// it wholesale with our canonical shape. The cast keeps TS quiet
@@ -131,7 +266,11 @@ export function createAuth(opts: CreateAuthOptions): AuthBundle {
async function requireAuth(): Promise<SessionUser> {
const session = await na.auth();
const user = (session as unknown as { user?: SessionUser } | null)?.user;
if (!user || !user.accessToken) {
// `user.error` is set when the kit's silent refresh failed (refresh
// token expired/revoked or Keycloak rejected the exchange). The
// session cookie is still present, so the middleware won't catch
// this — bouncing here is what forces a fresh sign-in flow.
if (!user || !user.accessToken || user.error) {
redirect(signInPath);
}
return user;

View File

@@ -7,8 +7,16 @@ export interface SessionUser {
id: string;
/** Same as `id`. Exposed separately so callers can name-distinguish. */
keycloakId: string;
/** Tenant the user is operating in. May fall back to defaultTenantId. */
/** Tenant the user is operating in. From the Keycloak `gscTenantId` claim. */
tenantId: string;
/** Customer the user belongs to. From the Keycloak `gscCustomerId` claim. */
customerId: string;
/**
* GSC composite security identifier (tenant + customer + user parts).
* From the Keycloak `gscSID` claim. Distinct from any Keycloak/OIDC
* session `sid`.
*/
gscSid: string;
email: string;
displayName: string;
givenName: string;
@@ -19,6 +27,13 @@ export interface SessionUser {
accessToken: string;
/** Keycloak ID token. Optional; only present if the IdP returns it. */
idToken?: string;
/**
* Set when the kit's silent refresh failed (refresh_token expired or
* revoked). Clients should treat this as a forced re-auth signal —
* any value here means the accessToken is stale and won't be refreshed
* by the kit on its own.
*/
error?: string;
}
/**
@@ -30,12 +45,34 @@ export interface CreateAuthOptions {
clientSecret: string;
issuer: string;
};
/** Used when Keycloak claims don't carry a `tenant_id`. */
defaultTenantId?: string;
/**
* Where `requireAuth()` / the middleware redirects unauthenticated
* users. Default: `/api/auth/signin/keycloak` (NextAuth's auto-redirect
* endpoint). Override if the app has a custom landing page.
*/
signInPath?: string;
/**
* Optional gate: predicate that runs on initial sign-in against the
* Keycloak `realm_access.roles` array. Return `true` to allow the
* sign-in, `false` to deny — denial bounces the user to NextAuth's
* error route with `?error=AccessDenied` and no session is created.
*
* Use this to enforce "must be in <app>_* group" semantics without
* relying solely on per-endpoint role gates in downstream services.
* Example for gscCRM:
* requireClientRole: (roles) => roles.some((r) => r.startsWith("gsccrm_"))
*/
requireClientRole?: (roles: string[]) => boolean;
/**
* NextAuth page overrides. Set `pages.error` to a custom branded
* full-page message route when `requireClientRole` may deny sign-in,
* so users see something meaningful instead of NextAuth's default
* error page. The route should be public (skipped by auth middleware).
*/
pages?: {
error?: string;
signIn?: string;
};
}

1152
src/chrome/AdminShell.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
"use client";
/**
* Default flow: a plain anchor to `/api/auth/signout` (overridable via
* the `signoutPath` prop). The route handler runs RP-initiated logout
* in a single redirect — kills the NextAuth cookie, ends the Keycloak
* SSO session, lands on the app's signed-out page.
*
* Apps with a different shape (no `/api/auth/signout`, need to fire
* custom telemetry, etc.) pass `onSignOut` to replace the navigation
* with a button + custom handler.
*/
type LogoutButtonProps = {
label: string;
signoutPath?: string;
onSignOut?: () => void | Promise<void>;
};
export function LogoutButton({
label,
signoutPath = "/api/auth/signout",
onSignOut,
}: LogoutButtonProps) {
if (onSignOut) {
return (
<button
type="button"
onClick={() => {
void onSignOut();
}}
className="dropdown-item"
>
<i className="ph-sign-out me-2"></i>
{label}
</button>
);
}
return (
<a href={signoutPath} className="dropdown-item">
<i className="ph-sign-out me-2"></i>
{label}
</a>
);
}

17
src/chrome/_ambient.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
// Ambient declarations for peer dependencies we don't install in the kit.
// `@gsc/chat` is provided by the consuming app — declared here so the kit
// can typecheck against the surface we use without owning a hard dep.
declare module "@gsc/chat" {
import type { FC } from "react";
export const ChatBubble: FC<{
isOpen: boolean;
onClick: () => void;
}>;
export const ChatOverlay: FC<{
isOpen: boolean;
onClose: () => void;
}>;
}

37
src/chrome/brandIcons.ts Normal file
View File

@@ -0,0 +1,37 @@
// Returns a Next.js Metadata `icons` object derived from a Brand.
// Apps drop this into their root layout's `metadata` export to ship
// the brand logo as the favicon — fixes the "favicon 404" every
// consumer of the kit was shipping with.
//
// import type { Metadata } from "next";
// import { brandIcons } from "@gsc/web-kit/chrome";
// import { brand } from "@/config/brand";
//
// export const metadata: Metadata = {
// title: brand.product,
// icons: brandIcons(brand),
// };
import type { Brand } from "./types";
interface BrandMetaIcons {
icon: { url: string; type?: string }[];
shortcut?: { url: string; type?: string }[];
apple?: { url: string; type?: string }[];
}
export function brandIcons(brand: Brand): BrandMetaIcons {
const url = brand.faviconUrl ?? brand.logoUrl;
// Heuristic: trust the file extension to set the MIME type. Most
// brand logos in the GoSec assets bucket are SVG.
const type =
/\.svg(\?|$)/i.test(url) ? "image/svg+xml" :
/\.png(\?|$)/i.test(url) ? "image/png" :
/\.ico(\?|$)/i.test(url) ? "image/x-icon" :
undefined;
return {
icon: [{ url, ...(type ? { type } : {}) }],
shortcut: [{ url }],
apple: [{ url }],
};
}

View File

@@ -0,0 +1,110 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import type { AppListItem, ChromeLabels } from "../types";
type BrowseAppsProps = {
show?: boolean;
apps: AppListItem[];
viewAllUrl?: string; // default "/apps"
labels: Pick<ChromeLabels, "browseApps" | "viewAll">;
};
export function BrowseApps({ show = true, apps, viewAllUrl = "/apps", labels }: BrowseAppsProps) {
const [open, setOpen] = useState(false);
const close = () => {
if (open) setOpen(false);
};
const enabled = apps.filter((a) => a.enabled).sort((a, b) => a.sortOrder - b.sortOrder);
return (
<>
{/* Mobile search icon (always rendered alongside browse-apps trigger) */}
<li className="nav-item d-lg-none">
<Link
href="#navbar_search"
className="navbar-nav-link navbar-nav-link-icon rounded-pill"
>
<i className="ph ph-magnifying-glass"></i>
</Link>
</li>
{show && (
<li
className="nav-item nav-item-dropdown-lg dropdown"
onMouseLeave={close}
>
<Link
href="#"
className="navbar-nav-link navbar-nav-link-icon rounded-pill"
onClick={(e) => {
e.preventDefault();
setOpen(!open);
}}
>
<i className="ph ph-squares-four"></i>
</Link>
<div
className={
open
? "dropdown-menu dropdown-menu-scrollable-sm wmin-lg-600 p-0 show"
: "dropdown-menu dropdown-menu-scrollable-sm wmin-lg-600 p-0"
}
>
<div className="d-flex align-items-center border-bottom p-3">
<h6 className="mb-0">{labels.browseApps}</h6>
<Link href={viewAllUrl} className="ms-auto">
{labels.viewAll}
<i className="ph ph-arrow-circle-right ms-1"></i>
</Link>
</div>
<div className="row row-cols-1 row-cols-sm-2 g-0">
{enabled.map((app, idx) => {
const isLast = idx === enabled.length - 1;
const isEvenColumn = idx % 2 === 0;
const itemClasses = [
"dropdown-item",
"text-wrap",
"h-100",
"align-items-start",
isEvenColumn ? "border-end-sm" : "",
isLast ? "" : "border-bottom",
"p-3",
]
.filter(Boolean)
.join(" ");
return (
<div className="col" key={app.key}>
<Link href={app.url} className={itemClasses}>
<div>
{app.iconUrl ? (
<img src={app.iconUrl} className="h-40px mb-2" alt="" />
) : app.iconClass ? (
<div
className={`d-flex align-items-center justify-content-center h-40px w-40px rounded mb-2 ${app.iconBg ?? "bg-secondary-lt"}`}
>
<i className={`${app.iconClass} fs-2`}></i>
</div>
) : null}
<div className="fw-semibold my-1">{app.name}</div>
{app.description && (
<div className="text-muted">{app.description}</div>
)}
</div>
</Link>
</div>
);
})}
</div>
</div>
</li>
)}
</>
);
}

View File

@@ -0,0 +1,12 @@
"use client";
// Placeholder for parity with gscAdmin's contacts header zone. Renders nothing
// today; an app may pass slots.pageHeaderExtras to render its own contacts UI.
type HeaderContactsProps = {
show?: boolean;
};
export function HeaderContacts({ show = true }: HeaderContactsProps) {
if (!show) return null;
return null;
}

View File

@@ -0,0 +1,98 @@
"use client";
import Link from "next/link";
import { useState } from "react";
export type CustomerOption = {
id: number | string;
name: string;
logo: string;
description?: string;
};
type HeaderCustomersProps = {
show?: boolean;
customers?: CustomerOption[];
};
export function HeaderCustomers({ show = true, customers = [] }: HeaderCustomersProps) {
const [open, setOpen] = useState(false);
const [selectedName, setSelectedName] = useState("");
const [selectedLogo, setSelectedLogo] = useState("");
const toggle = () => setOpen((v) => !v);
const select = (c: CustomerOption) => {
setSelectedName(c.name);
setSelectedLogo(c.logo);
setOpen(false);
};
if (!show || customers.length === 0) return null;
const current = customers[0];
return (
<div className="dropdown w-100 w-sm-auto">
<Link
href="#"
className="d-flex align-items-center text-body lh-1 dropdown-toggle py-sm-2"
onClick={(e) => {
e.preventDefault();
toggle();
}}
>
<img
src={selectedLogo || current.logo}
className="w-32px h-32px me-2"
alt=""
/>
<div className="me-auto me-lg-1">
<div className="fs-sm text-muted mb-1">Customer</div>
<div className="fw-semibold">{selectedName || current.name}</div>
</div>
</Link>
<div
className={
open
? "dropdown-menu dropdown-menu-lg-end w-100 w-lg-auto wmin-300 wmin-sm-350 pt-0 show"
: "dropdown-menu dropdown-menu-lg-end w-100 w-lg-auto wmin-300 wmin-sm-350 pt-0"
}
onMouseLeave={toggle}
>
<div className="d-flex align-items-center p-3">
<h6 className="fw-semibold mb-0">Customers</h6>
<Link href="#" className="ms-auto">
View all
<i className="ph ph-arrow-circle-right ms-1"></i>
</Link>
</div>
{customers.map((c) => (
<Link
href="#"
className={
selectedName === c.name
? "dropdown-item py-2 active"
: "dropdown-item py-2"
}
onClick={(e) => {
e.preventDefault();
select(c);
}}
key={c.id}
>
<img src={c.logo} className="w-32px h-32px me-2" alt="" />
<div>
<div className="fw-semibold">{c.name}</div>
{c.description && (
<div className="fs-sm text-muted">{c.description}</div>
)}
</div>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
type MessagesProps = {
show?: boolean;
onOpenChat?: () => void;
};
export function Messages({ show = true, onOpenChat }: MessagesProps) {
if (!show) return null;
return (
<li className="nav-item">
<button
type="button"
className="navbar-nav-link navbar-nav-link-icon rounded-pill border-0 bg-transparent position-relative"
title="Open chat"
aria-label="Open chat"
onClick={onOpenChat}
>
<i className="ph ph-chat-circle"></i>
</button>
</li>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { SearchHistory } from "./SearchHistory";
import { SearchOptions } from "./SearchOptions";
type SearchProps = {
show?: boolean;
showHistory?: boolean;
showOptions?: boolean;
};
export function Search({ show = true, showHistory = true, showOptions = true }: SearchProps) {
if (!show) return null;
return (
<div
className="navbar-collapse justify-content-center flex-lg-1 order-2 order-lg-1 collapse"
id="navbar_search"
>
<div className="navbar-search flex-fill position-relative mt-2 mt-lg-0 mx-lg-3">
{showHistory && <SearchHistory />}
{showOptions && <SearchOptions />}
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { useState } from "react";
export function SearchHistory() {
const [showSearchHistory, setShowSearchHistory] = useState(false);
const close = () => {
if (showSearchHistory) setShowSearchHistory(false);
};
return (
<div
className="form-control-feedback form-control-feedback-start flex-grow-1"
data-color-theme="dark"
onMouseLeave={close}
>
<input
type="text"
className="form-control bg-transparent rounded-pill"
placeholder="Search"
onClick={() => setShowSearchHistory(!showSearchHistory)}
/>
<div className="form-control-feedback-icon">
<i className="ph ph-magnifying-glass"></i>
</div>
{showSearchHistory && (
<div className="dropdown-menu w-100 show" data-color-theme="light">
<div className="dropdown-item text-muted">
<div className="text-center w-32px me-3">
<i className="ph ph-magnifying-glass"></i>
</div>
<span>Type to search...</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import Link from "next/link";
import { useState } from "react";
export function SearchOptions() {
const [showSearchOptions, setShowSearchOptions] = useState(false);
const close = () => {
if (showSearchOptions) setShowSearchOptions(false);
};
return (
<div>
<Link
href="#"
className="navbar-nav-link align-items-center justify-content-center w-40px h-32px rounded-pill position-absolute end-0 top-50 translate-middle-y p-0 me-1"
onClick={() => setShowSearchOptions(!showSearchOptions)}
>
<i className="ph ph-faders-horizontal"></i>
</Link>
<div
className={
showSearchOptions
? "dropdown-menu w-100 p-3 show"
: "dropdown-menu w-100 p-3"
}
onMouseLeave={close}
>
<div className="d-flex align-items-center mb-3">
<h6 className="mb-0">Search options</h6>
<Link href="#" className="text-body rounded-pill ms-auto">
<i className="ph ph-clock-counter-clockwise"></i>
</Link>
</div>
<div className="mb-3">
<span className="d-block form-label">Category</span>
<label className="form-check form-check-inline">
<input type="checkbox" className="form-check-input" />
<span className="form-check-label">Invoices</span>
</label>
<label className="form-check form-check-inline">
<input type="checkbox" className="form-check-input" />
<span className="form-check-label">Files</span>
</label>
<label className="form-check form-check-inline">
<input type="checkbox" className="form-check-input" />
<span className="form-check-label">Users</span>
</label>
</div>
<div className="mb-3">
<label className="form-label">Addition</label>
<div className="input-group">
<select className="form-select w-auto flex-grow-0">
<option value="1">has</option>
<option value="2">has not</option>
</select>
<input
type="text"
className="form-control"
placeholder="Enter the word(s)"
/>
</div>
</div>
<div className="mb-3">
<label className="form-label">Status</label>
<div className="input-group">
<select className="form-select w-auto flex-grow-0">
<option value="1">is</option>
<option value="2">is not</option>
</select>
<select className="form-select">
<option value="1">Active</option>
<option value="2">Inactive</option>
<option value="3">New</option>
<option value="4">Expired</option>
<option value="5">Pending</option>
</select>
</div>
</div>
<div className="d-flex">
<button type="button" className="btn btn-light">
Reset
</button>
<div className="ms-auto">
<button type="button" className="btn btn-light">
Cancel
</button>
<button type="button" className="btn btn-primary ms-2">
Apply
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
export { Search } from "./Search";
export { SearchHistory } from "./SearchHistory";
export { SearchOptions } from "./SearchOptions";
export { Messages } from "./Messages";
export { BrowseApps } from "./BrowseApps";
export { HeaderContacts } from "./HeaderContacts";
export { HeaderCustomers, type CustomerOption } from "./HeaderCustomers";

26
src/chrome/index.ts Normal file
View File

@@ -0,0 +1,26 @@
export { AdminShell } from "./AdminShell";
export { LogoutButton } from "./LogoutButton";
export {
Search,
SearchHistory,
SearchOptions,
Messages,
BrowseApps,
HeaderContacts,
HeaderCustomers,
type CustomerOption,
} from "./header";
export { useChromeLabels, DEFAULT_CHROME_LABELS } from "./labels";
export { brandIcons } from "./brandIcons";
export type {
AdminShellProps,
ActivityFeedItem,
AppListItem,
Brand,
ChromeFeatures,
ChromeLabels,
ChromeSlots,
ChromeUser,
DbMenuItem,
MenuData,
} from "./types";

73
src/chrome/labels.ts Normal file
View File

@@ -0,0 +1,73 @@
"use client";
import { useTranslations } from "next-intl";
import type { ChromeLabels } from "./types";
/**
* Default English chrome labels. Used as fallback when next-intl namespace
* "chrome" doesn't resolve a key and no per-key override is passed in props.
*/
export const DEFAULT_CHROME_LABELS: ChromeLabels = {
navigation: "Navigation",
main: "MAIN",
dashboard: "Dashboard",
support: "Support",
settings: "Settings",
allSettings: "All Settings",
logout: "Logout",
myProfile: "My Profile",
docs: "Docs",
browseApps: "Browse apps",
viewAll: "View all",
activityTitle: "Activity",
newNotifications: "New notifications",
olderNotifications: "Older notifications",
noOlderNotifications: "No older notifications",
noNewNotifications: "No new notifications",
expandSidebar: "Expand sidebar",
collapseSidebar: "Collapse sidebar",
closeSidebar: "Close sidebar",
};
/**
* Resolve chrome labels with three-level precedence:
* 1. `overrides` prop (per-key escape hatch)
* 2. next-intl namespace "chrome" message
* 3. hardcoded English default
*
* Apps add `"chrome": { ... }` to their next-intl messages to translate.
*/
export function useChromeLabels(overrides?: Partial<ChromeLabels>): ChromeLabels {
let t: ((key: string) => string) | null = null;
try {
t = useTranslations("chrome");
} catch {
t = null;
}
const resolved = { ...DEFAULT_CHROME_LABELS };
if (t) {
for (const key of Object.keys(resolved) as (keyof ChromeLabels)[]) {
try {
const v = t(key);
// next-intl returns the key path when the message is missing — treat
// that as "no translation" so we fall back to the English default.
if (v && v !== key && !v.startsWith("chrome.")) {
resolved[key] = v;
}
} catch {
// Missing key — keep the default.
}
}
}
if (overrides) {
for (const k of Object.keys(overrides) as (keyof ChromeLabels)[]) {
const v = overrides[k];
if (v != null) resolved[k] = v;
}
}
return resolved;
}

190
src/chrome/types.ts Normal file
View File

@@ -0,0 +1,190 @@
import type { ReactNode } from "react";
// ============================================================================
// Menu data (Prisma-shaped, returned by app's getMenuItemsByType())
// ============================================================================
export type DbMenuItem = {
id: string;
key: string;
translationKey: string;
url: string;
icon: string | null;
sortOrder: number;
isActive: boolean;
isSystemRequired: boolean;
children?: DbMenuItem[];
};
export type MenuData = {
sidebar: DbMenuItem[];
topbar: DbMenuItem[];
subbar: DbMenuItem[];
};
// ============================================================================
// Browse-apps panel data (returned by app's getApps())
// ============================================================================
export type AppListItem = {
key: string;
name: string;
description?: string;
url: string;
iconClass?: string | null; // e.g. "ph-phone"
iconUrl?: string | null; // image URL (preferred when set)
iconBg?: string | null; // background utility class for iconClass square
sortOrder: number;
enabled: boolean;
};
// ============================================================================
// Activity panel data (when features.activityPanel:true)
// Optional — apps may pass `slots.activityPanel` to render their own body.
// ============================================================================
export type ActivityFeedItem = {
id: string;
actorName: string;
actorAvatarUrl?: string;
message: ReactNode; // freeform body, may contain JSX
timestamp: string; // pre-formatted display string (e.g. "2 hours ago")
group?: "new" | "older"; // default "new"
};
// ============================================================================
// Brand (replaces site-informations.json)
// ============================================================================
export type Brand = {
name: string; // "CRM"
product: string; // "GoSec CRM"
logoUrl: string; // full navbar logo
logoSmallUrl?: string; // optional compact logo
faviconUrl?: string; // optional favicon override (defaults to logoUrl)
websiteUrl: string; // footer brand link
supportUrl: string; // subbar Support + footer Support link
docsUrl: string; // footer docs link
copyrightStartYear: number; // e.g. 2023
};
// ============================================================================
// User
// ============================================================================
export type ChromeUser = {
displayName: string;
email?: string;
avatarUrl?: string;
roles?: string[]; // future: role-gated menu filtering
};
// ============================================================================
// Feature toggles
// ============================================================================
export type ChromeFeatures = {
// Navbar
search?: boolean; // default true
searchHistory?: boolean; // default true
searchOptions?: boolean; // default true
browseApps?: boolean; // default true
messages?: boolean; // default true
notifications?: boolean; // default true
// Subbar
subbar?: boolean; // default true
subbarSupport?: boolean; // default true
subbarSettings?: boolean; // default true
// Page header
pageHeader?: boolean; // default true
pageHeaderCustomers?: boolean; // default false (CRM-specific)
pageHeaderContacts?: boolean; // default false (CRM-specific)
// Overlays
activityPanel?: boolean; // default false
chat?: boolean; // default false
// Footer
footer?: boolean; // default true
};
// ============================================================================
// Slots (app-rendered content; overrides built-in renderers)
// ============================================================================
export type ChromeSlots = {
pageTitle?: ReactNode;
pageHeaderExtras?: ReactNode;
subbarExtras?: ReactNode;
activityPanel?: ReactNode; // overrides built-in activity body
navbarExtras?: ReactNode;
footerExtras?: ReactNode;
};
// ============================================================================
// i18n labels (chrome strings, overridable per-key)
// Defaults shipped in labels.ts; apps may also set keys under next-intl
// namespace "chrome".
// ============================================================================
export type ChromeLabels = {
navigation: string; // sidebar header "Navigation"
main: string; // sidebar group header "MAIN"
dashboard: string; // default page title for "/"
support: string; // subbar / footer "Support"
settings: string; // subbar "Settings"
allSettings: string; // subbar dropdown "All Settings"
logout: string; // user dropdown "Logout"
myProfile: string; // user dropdown "My Profile" (cross-app link to gscMy)
docs: string; // footer "Docs"
browseApps: string; // browse-apps "Browse apps"
viewAll: string; // browse-apps "View all"
activityTitle: string; // activity panel header
newNotifications: string; // activity panel "New notifications"
olderNotifications: string; // activity panel "Older notifications"
noOlderNotifications: string; // activity panel empty older state
noNewNotifications: string; // activity panel empty new state
expandSidebar: string; // aria-label
collapseSidebar: string; // aria-label
closeSidebar: string; // aria-label
};
// ============================================================================
// AdminShell props
// ============================================================================
export type AdminShellProps = {
// Data
menus: MenuData;
apps: AppListItem[];
user: ChromeUser;
notificationCount?: number;
activity?: ActivityFeedItem[];
// Brand (required)
brand: Brand;
// Feature toggles
features?: ChromeFeatures;
// Slots
slots?: ChromeSlots;
// Behavior
onSignOut?: () => void | Promise<void>;
/**
* Override the path the LogoutButton anchor points at. Defaults to
* `/api/auth/signout` — the canonical RP-initiated logout endpoint
* (single redirect: kills NextAuth cookie + ends Keycloak SSO + lands
* on the app's signed-out page).
*/
signoutPath?: string;
/**
* URL the "My Profile" item in the user dropdown points at. Defaults
* to the canonical gscMy profile page (`https://my.gosec.internal/profile`).
* gscMy itself should override this to its own local `/profile` route.
* Pass `null` to omit the item entirely.
*/
myProfileUrl?: string | null;
labels?: Partial<ChromeLabels>;
children: ReactNode;
};

View File

@@ -59,7 +59,6 @@ export {
useValidation,
useFieldValidation,
useAddressAutocomplete,
loadGoogleMapsScript,
} from "@limitless/ui";
// Validation — format validators

7
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* @gsc/web-kit/i18n — placeholder for future client-side i18n helpers
* (locale switcher component, etc.). The server-side factory lives in
* `@gsc/web-kit/i18n/server` so consumer client bundles don't pull in
* next/headers and next-intl/server.
*/
export {};

140
src/i18n/server.ts Normal file
View File

@@ -0,0 +1,140 @@
/**
* @gsc/web-kit/i18n/server — next-intl request config factory.
*
* Resolves the active locale from:
* 1. `NEXT_LOCALE` cookie (set by an in-app language switcher; beats
* the token claim until the next refresh).
* 2. Keycloak access-token `preferred_language` claim, sourced from
* FreeIPA's `preferredLanguage` user attribute via the gosecCloud
* realm's `preferred-language` LDAP mapper + per-client OIDC
* `oidc-usermodel-attribute-mapper`.
* 3. `Accept-Language` request header.
* 4. `defaultLocale`.
*
* Mirrors `createAuth()` from `@gsc/web-kit/auth/server`: one factory
* call, app supplies the locale list and message loader, kit owns the
* resolution chain.
*
* @example
* // src/i18n/request.ts
* import { createI18nConfig } from "@gsc/web-kit/i18n/server";
* import { auth } from "@/auth";
*
* export const locales = ["en", "de", "fr"] as const;
* export const defaultLocale = "en" as const;
*
* export default createI18nConfig({
* locales,
* defaultLocale,
* getAccessToken: async () =>
* (await auth())?.user?.accessToken as string | undefined,
* loadMessages: (locale) =>
* import(`../../public/locales/${locale}/common.json`).then((m) => m.default),
* });
*/
import { getRequestConfig } from "next-intl/server";
import { cookies, headers } from "next/headers";
export interface CreateI18nOptions<L extends string> {
/** Supported locale codes. First entry is conventional UI fallback order. */
locales: readonly L[];
/** Locale used when nothing in the resolution chain matches. */
defaultLocale: L;
/**
* Return the current user's Keycloak access token, or undefined if no
* session. Typically `async () => (await auth())?.user?.accessToken`.
* The kit doesn't import the app's auth module — pass it explicitly so
* the kit stays decoupled from how the app wires NextAuth.
*/
getAccessToken?: () => Promise<string | undefined>;
/** Load the message bundle for a resolved locale. */
loadMessages: (locale: L) => Promise<Record<string, unknown>>;
/**
* JWT claim name carrying the user's preferred language. Default
* `preferred_language` — matches the gosecCloud per-client OIDC mapper
* named `preferredLanguage` (config `claim.name=preferred_language`).
*/
claimName?: string;
/** Cookie name carrying a recent in-app language switch. Default `NEXT_LOCALE`. */
cookieName?: string;
}
function decodeAccessTokenClaim(
accessToken: string,
claimName: string,
): string | undefined {
// Plain base64url-decode of the JWT payload. We don't verify here:
// NextAuth already verified the ID token via OIDC, the access token
// came back over the same TLS exchange, and the only use is a local
// locale preference. Same approach as `rolesFromAccessToken` in
// auth/server.ts.
const parts = accessToken.split(".");
if (parts.length < 2) return undefined;
try {
const payload = parts[1];
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
const decoded = Buffer.from(
padded.replace(/-/g, "+").replace(/_/g, "/"),
"base64",
).toString("utf-8");
const claims = JSON.parse(decoded) as Record<string, unknown>;
const value = claims[claimName];
return typeof value === "string" ? value : undefined;
} catch {
return undefined;
}
}
export function createI18nConfig<L extends string>(opts: CreateI18nOptions<L>) {
const claimName = opts.claimName ?? "preferred_language";
const cookieName = opts.cookieName ?? "NEXT_LOCALE";
const localeSet = new Set<string>(opts.locales);
const isLocale = (v: unknown): v is L =>
typeof v === "string" && localeSet.has(v);
return getRequestConfig(async () => {
const cookieStore = await cookies();
const cookieLocale = cookieStore.get(cookieName)?.value;
let locale: L | undefined = isLocale(cookieLocale) ? cookieLocale : undefined;
if (!locale && opts.getAccessToken) {
try {
const token = await opts.getAccessToken();
if (token) {
const claim = decodeAccessTokenClaim(token, claimName);
if (isLocale(claim)) locale = claim;
}
} catch {
// Session read failed (e.g. missing cookie outside a request
// scope). Fall through to Accept-Language.
}
}
if (!locale) {
const headersList = await headers();
const acceptLanguage = headersList.get("accept-language");
if (acceptLanguage) {
const candidate = acceptLanguage.split(",")[0]?.split("-")[0];
if (isLocale(candidate)) locale = candidate;
}
}
if (!locale) locale = opts.defaultLocale;
return {
locale,
messages: await opts.loadMessages(locale),
onError(error) {
if (error.code === "MISSING_MESSAGE") {
console.warn(error.message);
} else {
console.error(error);
}
},
getMessageFallback({ key }) {
return key;
},
};
});
}

View File

@@ -6,6 +6,7 @@
export * from "./layout/index";
export * from "./shell/index";
export * from "./chrome/index";
export * from "./data/index";
export * from "./forms/index";
export * from "./feedback/index";