Compare commits

..

11 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
Claude
387e10b2fb fix(auth/middleware): recognize chunked session cookies
NextAuth v5 chunks the session cookie when the JWT payload exceeds
~4KB (we hit this easily: keycloakId + tenantId + display names +
roles + accessToken JWT + idToken). When chunked, the bare
'authjs.session-token' cookie is removed in favour of
'authjs.session-token.0', '.1', etc. Looking up only the bare name
returned undefined and the middleware redirected freshly-logged-in
users back to /api/auth/signin in a loop.

Match presence-only on any cookie whose name starts with either
canonical prefix. Server-side NextAuth still validates the token on
every RSC render — this check only gates the redirect.

Repro: gscCRM/v2.3.3 with the proxy fix in place. Keycloak auth
completes, /api/auth/callback/keycloak 302s, but every subsequent
request to / 307s straight back to signin because the middleware
doesn't see the now-chunked cookie.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:46:01 +02:00
Claude
360b611ae6 fix(auth): default signInPath to /api/auth/signin (NextAuth v5)
Provider-specific paths like /api/auth/signin/keycloak are POST only
in NextAuth v5 — they're the form-submit endpoint with CSRF. A GET
redirect there bounces to /api/auth/error?error=Configuration with
"UnknownAction".

/api/auth/signin (no provider segment) is the GET-accessible page
that lists configured providers. Apps that want one-click Keycloak
should set signInPath to a custom page that calls signIn('keycloak').

Repros against next-auth 5.0.0-beta.31 on Next 16.1.1. Pre-existing
bug in createAuth + createAuthMiddleware + signInRedirect; surfaced
when first user-driven login was attempted against the live CRM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:48:50 +02:00
Claude
b0e2c21d0a fix(auth): widen AuthBundle.handlers to (Request) => Promise<Response>
NextAuthResult["handlers"] embeds NextRequest from the kit's own
next/node_modules copy, which conflicts with the consumer's next and
produces a spurious RouteHandlerConfig mismatch in
.next/types/validator.ts for every app's [...nextauth]/route.ts.

Replace the inferred type with a structural AuthRouteHandlers shape
using web-standard Request/Response. NextRequest extends Request, so
function-parameter contravariance makes this assignable wherever
Next's validator wants (request: NextRequest, ctx) => ...

Cast through unknown at the return site since TS can't prove the
contravariance across the two next copies on its own.

Verified: gscCRM `npm run build` clean, `tsc --noEmit` no longer
flags validator.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:35:08 +02:00
Claude
d430680df5 feat: v0.3.0 — Phase 3/4 façades + AppLayout on AppShell
- Curated re-exports from @limitless/ui through /forms, /data,
  /feedback, /navigation, /utils sub-paths so apps stop importing
  from the lower layer.
- /forms also re-exports the full @limitless/ui validation surface
  (hooks, format/security/address validators, types).
- AppLayout is now a thin wrapper over @limitless/ui's <AppShell> —
  same ShellConfig DTO, no duplicated chrome code.
- shell/types + shell/index re-export from @limitless/ui to keep one
  canonical type and one shared context.
- auth middleware: loose NextRequestLike typing to avoid two-copies-
  of-next conflict with the consumer's next.
- postbuild: rewrite ../images/ to ./images/ in copied CSS so refs
  resolve in dist/styles/.

Widget family in /data is the intersection of limitless's two
Widget files (Widget.d.ts vs Widget/index.d.ts collision in dist);
upstream fix needed before exposing IconWidget/UserWidget/etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:26:30 +02:00
38 changed files with 3373 additions and 611 deletions

211
README.md
View File

@@ -1,6 +1,6 @@
# `@gsc/web-kit`
App skeleton for GSC Next.js frontends. Wraps `@limitless/ui` primitives into a pre-configured layout + auth + data/forms/feedback stack so apps just write their domain pages.
App skeleton for GSC Next.js frontends. Curates `@limitless/ui` primitives behind a pre-configured layout + auth + data/forms/feedback/navigation stack so apps just write their domain pages.
See the implementation plan in the parent repo for the full module map. This is a `file:` dep consumed by every GSC frontend.
@@ -28,23 +28,208 @@ your app
```ts
import "@gsc/web-kit/css"; // CSS bundle (layout-3 + JetBrains Mono)
// 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 { createAuth } from "@gsc/web-kit/auth/server";
import { useShell } from "@gsc/web-kit/shell";
import { fetchShellConfig } from "@gsc/web-kit/shell/server";
import { EntityList } from "@gsc/web-kit/data";
import { Form, TextField } from "@gsc/web-kit/forms";
import { useToast } from "@gsc/web-kit/feedback";
import { Breadcrumbs } from "@gsc/web-kit/navigation";
import { createApiClient } from "@gsc/web-kit/api";
import { formatCurrency } from "@gsc/web-kit/utils";
// Auth (NextAuth v5 + Keycloak)
import { createAuth } from "@gsc/web-kit/auth/server";
import { createAuthMiddleware } from "@gsc/web-kit/auth/middleware";
import { signInRedirect } from "@gsc/web-kit/auth";
// Building blocks — curated re-exports from @limitless/ui
import {
Table, DataTable, Pagination, TreeView, Timeline,
Calendar, Gallery, Sortable, ListGroup,
StatWidget, ProgressWidget, ChartWidget, ContentWidget,
} from "@gsc/web-kit/data";
import {
FormGroup, FormControl, FormCheck, Select, InputGroup,
SelectSingle, MultiSelect, TagsSelect, AsyncSelect,
DatePicker, ColorPicker, TagInput, FileUpload,
Slider, Rating, DualListBox, ImageCropper, Wizard, Stepper,
// validation
useValidation, useFieldValidation, required, email, password,
noInjection, europeanAddress, /* …etc */
} from "@gsc/web-kit/forms";
import {
Alert, Toast, Notification, Modal, Offcanvas,
Popover, Tooltip, SweetAlert, Spinner,
Progress, ProgressStacked, IdleTimeout, FAB,
} from "@gsc/web-kit/feedback";
import {
Breadcrumbs, Nav, Tabs, Pills, Dropdown, ContextMenu,
Scrollspy, PageHeader, Accordion, Collapse, Carousel,
Embed, SyntaxHighlighter, Card, Badge, Button, Media,
} from "@gsc/web-kit/navigation";
import { useDisclosure } from "@gsc/web-kit/utils";
```
The `/api` sub-export is reserved for a future HTTP client helper; it currently re-exports nothing.
## Phases
| Phase | Scope | Status |
|---|---|---|
| 1 | Package scaffold + CSS bundle + sub-export stubs | **in progress** |
| 2 | layout · auth · shell — usable end-to-end with shell-api | planned |
| 3 | data · forms — EntityList, Form, DefineAction | planned |
| 4 | feedback · navigation · api · utils | planned |
| 5 | Roll out to gscCRM / gscChronos / gscAdmin / gscPortal | planned |
| 1 | Package scaffold + CSS bundle + sub-export stubs | **done** |
| 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)** |
| 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
- `AppLayout` is a thin wrapper around `<AppShell>` from `@limitless/ui` — they share the `ShellConfig` DTO, so the kit owns the consumer-facing surface without duplicating chrome code.
- All form, data, feedback, and navigation modules are **curated re-exports**: apps should never need to reach into `@limitless/ui` directly.
- The `Widget` family in `/data` is intentionally narrow (`StatWidget`, `ProgressWidget`, `ChartWidget`, `ContentWidget`) — the installed limitless dist has a duplicate `Widget` file/folder collision, so only names exported by both are passed through.

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);

264
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,85 @@
{
"name": "@gsc/web-kit",
"version": "0.2.0",
"description": "GSC web app skeleton — 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": {
".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
"./css": "./dist/styles/index.css",
"./layout": { "import": "./dist/layout/index.js", "types": "./dist/layout/index.d.ts" },
"./auth": { "import": "./dist/auth/index.js", "types": "./dist/auth/index.d.ts" },
"./auth/server":{ "import": "./dist/auth/server.js", "types": "./dist/auth/server.d.ts" },
"./auth/middleware": { "import": "./dist/auth/middleware.js", "types": "./dist/auth/middleware.d.ts" },
"./shell": { "import": "./dist/shell/index.js", "types": "./dist/shell/index.d.ts" },
"./shell/server":{ "import": "./dist/shell/server.js", "types": "./dist/shell/server.d.ts" },
"./data": { "import": "./dist/data/index.js", "types": "./dist/data/index.d.ts" },
"./forms": { "import": "./dist/forms/index.js", "types": "./dist/forms/index.d.ts" },
"./feedback": { "import": "./dist/feedback/index.js", "types": "./dist/feedback/index.d.ts" },
"./navigation": { "import": "./dist/navigation/index.js", "types": "./dist/navigation/index.d.ts" },
"./api": { "import": "./dist/api/index.js", "types": "./dist/api/index.d.ts" },
"./utils": { "import": "./dist/utils/index.js", "types": "./dist/utils/index.d.ts" }
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./css": "./dist/styles/index.css",
"./layout": {
"types": "./dist/layout/index.d.ts",
"import": "./dist/layout/index.js"
},
"./auth": {
"types": "./dist/auth/index.d.ts",
"import": "./dist/auth/index.js"
},
"./auth/server": {
"types": "./dist/auth/server.d.ts",
"import": "./dist/auth/server.js"
},
"./auth/middleware": {
"types": "./dist/auth/middleware.d.ts",
"import": "./dist/auth/middleware.js"
},
"./shell": {
"types": "./dist/shell/index.d.ts",
"import": "./dist/shell/index.js"
},
"./shell/server": {
"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"
},
"./forms": {
"types": "./dist/forms/index.d.ts",
"import": "./dist/forms/index.js"
},
"./feedback": {
"types": "./dist/feedback/index.d.ts",
"import": "./dist/feedback/index.js"
},
"./navigation": {
"types": "./dist/navigation/index.d.ts",
"import": "./dist/navigation/index.js"
},
"./api": {
"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"
@@ -33,20 +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 },
"@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

@@ -25,6 +25,18 @@ function copyDir(src, dst) {
}
if (fs.existsSync(srcStyles)) {
copyDir(srcStyles, distStyles);
// Rewrite ../images/... references in any copied CSS to ./images/...
// The source CSS assumes a sibling src/images/ dir; in dist/ the images
// live at dist/styles/images/ alongside the CSS, so the relative path
// is one level shorter.
for (const f of fs.readdirSync(distStyles)) {
if (!f.endsWith(".css")) continue;
const p = path.join(distStyles, f);
const c = fs.readFileSync(p, "utf8");
const fixed = c.replace(/\.\.\/images\//g, "./images/");
if (fixed !== c) fs.writeFileSync(p, fixed);
}
}
// 2. Fix ESM relative imports: append `.js` to bare specifiers like

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
@@ -17,7 +18,9 @@ export type { SessionUser } from "./types";
*/
export function signInRedirect(callbackUrl?: string): void {
if (typeof window === "undefined") return;
const target = "/api/auth/signin/keycloak";
// Match the server-side default. /api/auth/signin/<provider> is POST
// only in NextAuth v5 — a GET navigation there returns UnknownAction.
const target = "/api/auth/signin";
const url = new URL(target, window.location.origin);
url.searchParams.set("callbackUrl", callbackUrl ?? window.location.pathname);
window.location.href = url.toString();

View File

@@ -1,4 +1,17 @@
import { NextResponse, type NextRequest } from "next/server";
import { NextResponse } from "next/server";
// Loose local typing for NextRequest. Importing next's official type
// here pulls in `next/server` from THIS package's node_modules, which
// causes a "two copies of next" type conflict with the consumer's
// next. The shape below is the surface area the middleware actually
// uses; structural typing is enough at the call site.
interface NextRequestLike {
nextUrl: URL;
cookies: {
get(name: string): { value: string } | undefined;
getAll(): Array<{ name: string; value: string }>;
};
}
export interface AuthMiddlewareOptions {
/**
@@ -36,9 +49,11 @@ export interface AuthMiddlewareOptions {
*/
export function createAuthMiddleware(opts: AuthMiddlewareOptions = {}) {
const publicRoutes = opts.publicRoutes ?? [];
const signInPath = opts.signInPath ?? "/api/auth/signin/keycloak";
// See createAuth for why /api/auth/signin (not /api/auth/signin/keycloak):
// provider-specific paths are POST-only in NextAuth v5.
const signInPath = opts.signInPath ?? "/api/auth/signin";
return function middleware(req: NextRequest) {
return function middleware(req: NextRequestLike) {
const { pathname } = req.nextUrl;
if (isAlwaysAllowed(pathname)) {
@@ -48,10 +63,7 @@ export function createAuthMiddleware(opts: AuthMiddlewareOptions = {}) {
return NextResponse.next();
}
const sessionCookie =
req.cookies.get("authjs.session-token") ??
req.cookies.get("__Secure-authjs.session-token");
if (sessionCookie) {
if (hasSessionCookie(req)) {
return NextResponse.next();
}
@@ -61,6 +73,32 @@ export function createAuthMiddleware(opts: AuthMiddlewareOptions = {}) {
};
}
/**
* NextAuth v5 chunks the session cookie when the JWT payload exceeds
* ~4 KB (claims + accessToken + roles add up fast). When chunked, the
* bare `authjs.session-token` cookie is *removed* in favour of
* `authjs.session-token.0`, `.1`, etc. Looking up only the bare name
* misses the chunked form and the middleware loops the user back to
* signin even though they have a valid session.
*
* Match presence-only on any cookie whose name starts with either
* canonical prefix. Token validity is still verified server-side by
* NextAuth on every RSC render — this check only gates the redirect.
*/
function hasSessionCookie(req: NextRequestLike): boolean {
for (const c of req.cookies.getAll()) {
if (
c.name === "authjs.session-token" ||
c.name === "__Secure-authjs.session-token" ||
c.name.startsWith("authjs.session-token.") ||
c.name.startsWith("__Secure-authjs.session-token.")
) {
return true;
}
}
return false;
}
function isAlwaysAllowed(pathname: string): boolean {
return (
pathname.startsWith("/api/auth/") ||

View File

@@ -1,18 +1,102 @@
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 };
/**
* Shape of the GET/POST route handlers NextAuth returns. Declared
* structurally (Request → Response) rather than via
* `NextAuthResult["handlers"]` — that type embeds `NextRequest` from
* the kit's own copy of `next`, which conflicts with the consumer's
* `next` and produces a spurious `RouteHandlerConfig` mismatch in
* `.next/types/validator.ts` for every app's `[...nextauth]/route.ts`.
*
* Web-standard `Request`/`Response` are valid for Next.js route
* handlers (NextRequest extends Request), and TS function-parameter
* contravariance makes this assignable wherever the validator wants
* `(request: NextRequest, ctx) => ...`.
*/
export type AuthRouteHandlers = {
GET: (request: Request) => Promise<Response>;
POST: (request: Request) => Promise<Response>;
};
/**
* Result of `createAuth()`. Mirrors NextAuth's return value plus
* `requireAuth` and the resolved `signInPath` so consumers don't have to
* thread these around.
*/
export interface AuthBundle {
handlers: NextAuthResult["handlers"];
handlers: AuthRouteHandlers;
signIn: NextAuthResult["signIn"];
signOut: NextAuthResult["signOut"];
auth: NextAuthResult["auth"];
@@ -44,10 +128,43 @@ 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 {
const signInPath = opts.signInPath ?? "/api/auth/signin/keycloak";
const defaultTenantId =
opts.defaultTenantId ?? "00000000-0000-0000-0000-000000000000";
// NextAuth v5: provider-specific paths like /api/auth/signin/keycloak
// are POST-only (CSRF-protected form submit). A GET redirect there
// bounces to /api/auth/error?error=Configuration ("UnknownAction").
// /api/auth/signin (no provider) is the GET-accessible page that
// 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 na = NextAuth({
providers: [
@@ -58,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) ?? "",
@@ -94,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
@@ -107,14 +266,23 @@ 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;
}
return {
handlers: na.handlers,
// Cast through unknown: NextAuth's handler param type references its
// own NextRequest; the AuthRouteHandlers shape is structurally
// compatible (NextRequest extends Request), but TS can't prove the
// function-parameter contravariance across the two `next` copies on
// its own.
handlers: na.handlers as unknown as AuthRouteHandlers,
signIn: na.signIn,
signOut: na.signOut,
auth: na.auth,

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

@@ -1,2 +1,37 @@
// @gsc/web-kit/data — Phase 1 stub. Real surface lands in later phases.
export {};
/**
* @gsc/web-kit/data — data display primitives.
*
* Curated re-export from @limitless/ui. Use these for entity lists,
* detail panels, and timeline/calendar views.
*
* The Widget family is intentionally narrow: only names that exist in
* both Widget.d.ts and Widget/index.d.ts are re-exported, because the
* installed limitless dist has a duplicate-file collision there. If
* apps need IconWidget / UserWidget / etc., import from @limitless/ui
* directly until upstream is fixed.
*/
export {
Table,
DataTable,
Pagination,
TreeView,
Timeline,
Calendar,
Gallery,
Sortable,
ListGroup,
// Widget family (intersection of the two Widget files in dist)
StatWidget,
ProgressWidget,
ChartWidget,
ContentWidget,
} from "@limitless/ui";
export type {
DataTableProps,
StatWidgetProps,
ProgressWidgetProps,
ChartWidgetProps,
ContentWidgetProps,
} from "@limitless/ui";

View File

@@ -1,2 +1,25 @@
// @gsc/web-kit/feedback — Phase 1 stub. Real surface lands in later phases.
export {};
/**
* @gsc/web-kit/feedback — overlays, toasts, status indicators.
*
* Curated re-export from @limitless/ui.
*/
export {
Alert,
Toast,
Notification,
Modal,
Offcanvas,
Popover,
Tooltip,
SweetAlert,
Spinner,
Progress,
ProgressStacked,
IdleTimeout,
FAB,
} from "@limitless/ui";
export type {
ToastProps,
} from "@limitless/ui";

View File

@@ -1,2 +1,145 @@
// @gsc/web-kit/forms — Phase 1 stub. Real surface lands in later phases.
export {};
/**
* @gsc/web-kit/forms — form primitives + validation.
*
* Curated re-export from @limitless/ui. Apps import everything here
* instead of reaching into the lower layer:
*
* import { Form, FormGroup, FormControl, Select, SelectSingle,
* useValidation, required, email } from "@gsc/web-kit/forms";
*/
// Form primitives
export {
FormGroup,
FormControl,
FormCheck,
Select,
InputGroup,
} from "@limitless/ui";
export type {
FormGroupProps,
FormControlProps,
FormCheckProps,
SelectOption,
SelectProps,
InputGroupProps,
} from "@limitless/ui";
// Rich selects (react-select family — AdvancedSelect module)
export {
SelectSingle,
MultiSelect,
TagsSelect,
AsyncSelect,
} from "@limitless/ui";
export type {
SelectSingleProps,
SelectMultiProps,
CreatableSelectProps,
AsyncSelectProps,
} from "@limitless/ui";
// Rich inputs
export {
DatePicker,
ColorPicker,
TagInput,
FileUpload,
Slider,
Rating,
DualListBox,
ImageCropper,
} from "@limitless/ui";
// Multi-step
export { Wizard, Stepper } from "@limitless/ui";
// Validation — hooks (re-exported via @limitless/ui root: `export * from './validation'`)
export {
useValidation,
useFieldValidation,
useAddressAutocomplete,
} from "@limitless/ui";
// Validation — format validators
export {
required,
minLength,
maxLength,
pattern,
matches,
email,
url,
tel,
number,
range,
date,
time,
datetimeLocal,
month,
week,
color,
search,
password,
getPasswordStrength,
file,
checkbox,
radio,
} from "@limitless/ui";
// Validation — security
export {
noInjection,
noSqlInjection,
noScriptInjection,
noHtmlInjection,
noPromptInjection,
detectInjectionType,
SQL_INJECTION_PATTERNS,
SCRIPT_INJECTION_PATTERNS,
HTML_INJECTION_PATTERNS,
PROMPT_INJECTION_PATTERNS,
} from "@limitless/ui";
// Validation — address
export {
postalCode,
europeanPostalCode,
city,
streetAddress,
houseNumber,
europeanCountry,
europeanAddress,
getCountryName,
getPostalCodeHint,
POSTAL_CODE_PATTERNS,
EU_COUNTRIES,
EEA_COUNTRIES,
EUROPEAN_COUNTRIES,
} from "@limitless/ui";
// Validation — types
export type {
ValidationStatus,
ValidationResult,
FieldState,
FormState,
ValidatorFn,
FieldSchema,
FormSchema,
EuropeanAddress,
AddressSuggestion,
SecurityValidationOptions,
PasswordStrength,
PasswordOptions,
PhoneOptions,
NumberOptions,
DateOptions,
TimeOptions,
FileOptions,
UseValidationOptions,
UseFieldValidationOptions,
UseAddressAutocompleteOptions,
ServerValidationSchema,
ServerValidationResult,
} from "@limitless/ui";

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";

View File

@@ -1,14 +1,9 @@
"use client";
import React, { useEffect, useState } from "react";
import React from "react";
import { AppShell } from "@limitless/ui";
import { ShellProvider } from "../shell/index";
import type {
ShellConfig,
ShellMenuItem,
} from "../shell/types";
const SIDEBAR_COLLAPSED_KEY = "gsc-web-kit-sidebar-collapsed";
import type { ShellConfig } from "../shell/types";
export interface AppLayoutProps {
/** Pre-resolved chrome config. Fetch with `fetchShellConfig()` server-side. */
@@ -26,378 +21,11 @@ export interface AppLayoutProps {
}
/**
* The chrome. Renders a Bootstrap-Layout-3 navbar / sidebar / footer
* shell from a `ShellConfig`. Apps wrap their `[locale]/layout.tsx`
* children in this and get a consistent app look for free.
* GSC app chrome. Thin wrapper around `<AppShell>` from `@limitless/ui` —
* the two share the same `ShellConfig` DTO. Kept as a separate export so
* the kit owns the consumer-facing surface and can add GSC-only props
* (telemetry, feature flags, etc.) without forking limitless.
*/
export function AppLayout({
config,
currentPath,
translate,
onSignOut,
navbarExtras,
children,
}: AppLayoutProps) {
const t = translate ?? ((k: string) => k);
const path =
currentPath ??
(typeof window !== "undefined" ? window.location.pathname : "/");
return (
<ShellProvider value={config}>
<AdminNavbar
config={config}
t={t}
onSignOut={onSignOut}
navbarExtras={navbarExtras}
/>
<div
className="page-content d-flex align-items-start p-3 gap-3"
style={{ minHeight: 0 }}
>
<AdminSidebar config={config} t={t} pathname={path} />
<main className="flex-grow-1" style={{ minWidth: 0 }}>
{children}
</main>
</div>
<AdminFooter config={config} t={t} />
</ShellProvider>
);
}
// ─── Navbar ────────────────────────────────────────────────────────────────────
function AdminNavbar({
config,
t,
onSignOut,
navbarExtras,
}: {
config: ShellConfig;
t: (k: string) => string;
onSignOut?: () => void;
navbarExtras?: React.ReactNode;
}) {
const [showUserMenu, setShowUserMenu] = useState(false);
const initials =
config.user.displayName
?.split(" ")
.map((n) => n[0])
.filter(Boolean)
.join("")
.slice(0, 2)
.toUpperCase() || "?";
const userMenuItems = config.menus["user-menu"] ?? [];
return (
<div className="navbar navbar-dark navbar-expand-lg navbar-static">
<div className="container-fluid">
<div className="navbar-brand wmin-200">
<a href={config.app.baseUrl} className="d-inline-block">
<img
src={config.branding.logoUrl}
className="h-36px"
alt={config.branding.productName}
/>
</a>
</div>
{navbarExtras}
<ul className="nav flex-row justify-content-end order-1 order-lg-2 ms-auto">
<li
className="nav-item nav-item-dropdown-lg dropdown ms-lg-2"
onMouseLeave={() => setShowUserMenu(false)}
>
<a
href="#"
className="navbar-nav-link align-items-center rounded-pill p-1"
onClick={(e) => {
e.preventDefault();
setShowUserMenu((open) => !open);
}}
>
<div className="status-indicator-container">
<span
className="w-32px h-32px rounded-pill bg-primary bg-opacity-20 text-primary d-inline-flex align-items-center justify-content-center fw-semibold"
style={{ fontSize: "0.75rem" }}
>
{initials}
</span>
<span className="status-indicator bg-success" />
</div>
<span className="d-none d-lg-inline-block mx-lg-2">
{config.user.displayName || config.user.email || ""}
</span>
</a>
<div
className={
showUserMenu
? "dropdown-menu dropdown-menu-end show"
: "dropdown-menu dropdown-menu-end"
}
style={{
position: "absolute",
inset: "0px 0px auto auto",
margin: "0px",
transform: "translate3d(0px, 44px, 0px)",
}}
>
{userMenuItems.map((m) => {
const isLogout = m.key === "logout";
return (
<a
key={m.id}
href={m.href}
target={m.isExternal ? "_blank" : undefined}
rel={m.isExternal ? "noopener noreferrer" : undefined}
className="dropdown-item"
onClick={
isLogout && onSignOut
? (e) => {
e.preventDefault();
onSignOut();
}
: undefined
}
>
{m.icon ? <i className={`${m.icon} me-2`} /> : null}
{t(m.translationKey)}
</a>
);
})}
</div>
</li>
</ul>
</div>
</div>
);
}
// ─── Sidebar ──────────────────────────────────────────────────────────────────
function AdminSidebar({
config,
t,
pathname,
}: {
config: ShellConfig;
t: (k: string) => string;
pathname: string;
}) {
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const saved = window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
if (saved === "true") setCollapsed(true);
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(collapsed));
}, [collapsed]);
const sidebarClasses = [
"sidebar",
"sidebar-light",
"sidebar-main",
"sidebar-expand-lg",
"align-self-start",
collapsed ? "sidebar-main-resized" : "",
]
.filter(Boolean)
.join(" ");
const items = config.menus.sidebar ?? [];
const strippedPath = stripLocale(pathname);
return (
<div className={sidebarClasses}>
<div className="sidebar-content">
<div className="sidebar-section">
<div className="sidebar-section-body d-flex justify-content-center">
{!collapsed && (
<h5 className="sidebar-resize-hide flex-grow-1 my-auto">
{config.branding.productName}
</h5>
)}
<button
type="button"
className="btn btn-light btn-icon btn-sm rounded-pill border-transparent sidebar-control sidebar-main-resize d-none d-lg-inline-flex"
onClick={() => setCollapsed((c) => !c)}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
<i className="ph-arrows-left-right" />
</button>
</div>
</div>
<div className="sidebar-section">
<ul className="nav nav-sidebar" data-nav-type="accordion">
{items.map((item) => (
<SidebarNavItem
key={item.id}
item={item}
pathname={strippedPath}
collapsed={collapsed}
t={t}
/>
))}
</ul>
</div>
</div>
</div>
);
}
function SidebarNavItem({
item,
pathname,
collapsed,
t,
}: {
item: ShellMenuItem;
pathname: string;
collapsed: boolean;
t: (k: string) => string;
}) {
const hasChildren = !!item.children?.length;
const active = isActiveHref(item.href, pathname);
const childActive = !!item.children?.some((c) =>
isActiveHref(c.href, pathname),
);
const [open, setOpen] = useState(childActive);
const [hovered, setHovered] = useState(false);
const icon = item.icon ? <i className={item.icon} /> : null;
if (hasChildren) {
const showFlyout = collapsed && hovered;
const showInline = !collapsed && open;
const navItemClasses = [
"nav-item",
"nav-item-submenu",
showInline ? "nav-item-open" : "",
showFlyout ? "nav-group-sub-visible" : "",
]
.filter(Boolean)
.join(" ");
return (
<li
className={navItemClasses}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
>
<a
href="#"
className={`nav-link ${active || childActive ? "active" : ""}`}
title={collapsed ? t(item.translationKey) : undefined}
onClick={(e) => {
e.preventDefault();
setOpen((o) => !o);
}}
>
{icon}
{!collapsed && <span>{t(item.translationKey)}</span>}
</a>
<ul
className={
collapsed
? "nav-group-sub nav-group-sub-flyout collapse"
: `nav-group-sub collapse ${open ? "show" : ""}`
}
data-submenu-title={t(item.translationKey)}
>
{item.children!.map((c) => (
<li className="nav-item" key={c.id}>
<a
href={c.href}
target={c.isExternal ? "_blank" : undefined}
rel={c.isExternal ? "noopener noreferrer" : undefined}
className={`nav-link ${isActiveHref(c.href, pathname) ? "active" : ""}`}
>
{c.icon && <i className={c.icon} />}
{t(c.translationKey)}
</a>
</li>
))}
</ul>
</li>
);
}
return (
<li className="nav-item">
<a
href={item.href}
target={item.isExternal ? "_blank" : undefined}
rel={item.isExternal ? "noopener noreferrer" : undefined}
className={`nav-link ${active ? "active" : ""}`}
title={collapsed ? t(item.translationKey) : undefined}
>
{icon}
{!collapsed && <span>{t(item.translationKey)}</span>}
</a>
</li>
);
}
// ─── Footer ───────────────────────────────────────────────────────────────────
function AdminFooter({
config,
t,
}: {
config: ShellConfig;
t: (k: string) => string;
}) {
const items = config.menus.footer ?? [];
return (
<div className="navbar navbar-sm navbar-footer border-top">
<div className="container-fluid">
<span>
{config.branding.footerHtml ? (
<span
dangerouslySetInnerHTML={{ __html: config.branding.footerHtml }}
/>
) : (
<>© {new Date().getFullYear()} GoSec Cloud</>
)}
</span>
{items.length > 0 && (
<ul className="navbar-nav flex-row gap-3 ms-auto">
{items.map((m) => (
<li className="nav-item" key={m.id}>
<a
href={m.href}
target={m.isExternal ? "_blank" : undefined}
rel={m.isExternal ? "noopener noreferrer" : undefined}
className="navbar-nav-link"
>
{m.icon && <i className={`${m.icon} me-1`} />}
{t(m.translationKey)}
</a>
</li>
))}
</ul>
)}
</div>
</div>
);
}
// ─── helpers ──────────────────────────────────────────────────────────────────
function isActiveHref(href: string, currentPath: string): boolean {
if (!href || href === "#") return false;
if (href === "/") return currentPath === "/";
return currentPath === href || currentPath.startsWith(`${href}/`);
}
function stripLocale(p: string): string {
return p.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/";
export function AppLayout(props: AppLayoutProps) {
return <AppShell {...props} />;
}

View File

@@ -1,2 +1,26 @@
// @gsc/web-kit/navigation — Phase 1 stub. Real surface lands in later phases.
export {};
/**
* @gsc/web-kit/navigation — nav, breadcrumbs, page chrome, content
* organisers.
*
* Curated re-export from @limitless/ui.
*/
export {
Breadcrumbs,
Nav,
Tabs,
Pills,
Dropdown,
ContextMenu,
Scrollspy,
PageHeader,
Accordion,
Collapse,
Carousel,
Embed,
SyntaxHighlighter,
Card,
Badge,
Button,
Media,
} from "@limitless/ui";

View File

@@ -1,8 +1,12 @@
"use client";
import { createContext, useContext } from "react";
// Client surface for @gsc/web-kit/shell.
//
// The context provider lives inside @limitless/ui's <AppShell>; we just
// re-export the `useShell()` hook so pages rendered under <AppLayout>
// (which delegates to AppShell) can read the config.
import type { ShellConfig } from "./types";
export { useShell } from "@limitless/ui";
export type {
ShellApp,
@@ -12,17 +16,3 @@ export type {
ShellMenuZone,
ShellUser,
} from "./types";
const ShellContext = createContext<ShellConfig | null>(null);
/** Provider used by `<AppLayout>`; rarely needed directly. */
export const ShellProvider = ShellContext.Provider;
/** Read the current ShellConfig anywhere inside `<AppLayout>`. */
export function useShell(): ShellConfig {
const cfg = useContext(ShellContext);
if (!cfg) {
throw new Error("useShell must be used inside <AppLayout>");
}
return cfg;
}

View File

@@ -1,48 +1,11 @@
/**
* Shape of what gsc-shell-api returns. Mirror of the Go service's DTO.
* If you change one, change both — there's a runtime contract in
* between, not a code-generator.
*/
export type ShellMenuZone = "topbar" | "sidebar" | "footer" | "user-menu";
export interface ShellMenuItem {
id: string;
key: string;
translationKey: string;
href: string;
icon?: string;
isExternal?: boolean;
children?: ShellMenuItem[];
}
export interface ShellApp {
key: string;
displayName: string;
baseUrl: string;
}
export interface ShellBranding {
logoUrl: string;
productName: string;
footerHtml?: string;
brandColor?: string;
}
export interface ShellUser {
id: string;
email?: string;
displayName: string;
givenName?: string;
familyName?: string;
tenantId?: string;
roles: string[];
}
export interface ShellConfig {
version: number;
app: ShellApp;
branding: ShellBranding;
user: ShellUser;
menus: Partial<Record<ShellMenuZone, ShellMenuItem[]>>;
}
// Re-export from @limitless/ui so the kit and the underlying AppShell
// share one canonical type for ShellConfig. Diverging here would mean
// the kit's <AppLayout> can't be fed by limitless and vice versa.
export type {
ShellApp,
ShellBranding,
ShellConfig,
ShellMenuItem,
ShellMenuZone,
ShellUser,
} from "@limitless/ui";

View File

@@ -1,2 +1,7 @@
// @gsc/web-kit/utils — Phase 1 stub. Real surface lands in later phases.
export {};
/**
* @gsc/web-kit/utils — shared hooks and helpers.
*
* Curated re-export from @limitless/ui.
*/
export { useDisclosure } from "@limitless/ui";