@isi-ui7/bos7-shared 0.2.3 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { ReactNode } from 'react';
1
+ import { ElementType, ReactNode } from 'react';
2
2
 
3
3
  export interface AppShellAuthState {
4
4
  isAuthenticated: boolean;
@@ -12,7 +12,22 @@ export interface AppShellAuthState {
12
12
  login: () => void;
13
13
  logout: () => Promise<void> | void;
14
14
  }
15
- export type ShellNavItemLike = any;
15
+ /**
16
+ * Structural shape of a side-nav item, mirroring ui-shell's `ShellNavItem`.
17
+ * `label`/`href` are required to stay mutually assignable with `ShellNavItem`:
18
+ * this type is used both covariantly (`navItems`) and contravariantly
19
+ * (`markActive`'s parameter), so consumers passing `ShellNavItem`-typed values
20
+ * would otherwise fail under `strict`. The index signature keeps it permissive
21
+ * for extra metadata (e.g. `requiredPermission`).
22
+ */
23
+ export interface ShellNavItemLike {
24
+ label: string;
25
+ href: string;
26
+ isActive?: boolean;
27
+ icon?: string;
28
+ children?: ShellNavItemLike[];
29
+ [key: string]: unknown;
30
+ }
16
31
  export interface ShellNotificationLike {
17
32
  id?: string;
18
33
  read?: boolean;
@@ -43,9 +58,9 @@ export interface AppShellLayoutDeps {
43
58
  theme: ThemeLike;
44
59
  };
45
60
  useNotificationBell: (opts: NotificationBellOptionsLike) => NotificationBellLike;
46
- GlobalTheme: any;
47
- ModalProvider: any;
48
- UiShell: any;
61
+ GlobalTheme: ElementType;
62
+ ModalProvider: ElementType;
63
+ UiShell: ElementType;
49
64
  useAuth?: () => AppShellAuthState;
50
65
  }
51
66
  export interface AppShellLayoutProps {
@@ -1,5 +1,15 @@
1
1
  export type Ui7FormDensity = "compact" | "comfortable";
2
2
  export type Ui7ThemeDensity = Ui7FormDensity | "normal";
3
+ /**
4
+ * Desktop form-panel width. Mobile + tablet (<1056px) always release the
5
+ * cap and stretch to 100% — the modifier only matters at ≥1056px.
6
+ *
7
+ * - "half" → 50% of page body
8
+ * - "two-thirds" → 66.6667% (default — readable column width on wide
9
+ * monitors, what the contract shipped with originally)
10
+ * - "full" → 100% (forms with many columns or wide tables inside)
11
+ */
12
+ export type Ui7FormWidth = "half" | "two-thirds" | "full";
3
13
  export interface Ui7FormVisualTokens {
4
14
  labelSpacing: "6px";
5
15
  labelFontSize: "12px";
@@ -38,6 +48,9 @@ export declare const UI7_FORM_VISUAL_CLASSNAMES: {
38
48
  readonly scope: "ui7-form-contract";
39
49
  readonly densityCompact: "ui7-form-contract--density-compact";
40
50
  readonly densityComfortable: "ui7-form-contract--density-comfortable";
51
+ readonly widthHalf: "ui7-form-contract--width-half";
52
+ readonly widthTwoThirds: "ui7-form-contract--width-two-thirds";
53
+ readonly widthFull: "ui7-form-contract--width-full";
41
54
  readonly readonly: "ui7-form-contract--readonly";
42
55
  readonly row: "ui7-form-row";
43
56
  readonly section: "ui7-form-section";
@@ -52,21 +65,23 @@ export declare const UI7_FORM_VISUAL_CLASSNAMES: {
52
65
  export declare const UI7_FORM_DENSITY_TOKENS: {
53
66
  readonly compact: {
54
67
  readonly controlHeight: "2rem";
55
- readonly rowGap: "0.75rem";
56
- readonly sectionGap: "1rem";
57
- readonly columnGap: "0.75rem";
68
+ readonly rowGap: "1rem";
69
+ readonly sectionGap: "1.25rem";
70
+ readonly columnGap: "1rem";
58
71
  };
59
72
  readonly comfortable: {
60
73
  readonly controlHeight: "2.5rem";
61
- readonly rowGap: "1rem";
74
+ readonly rowGap: "1.25rem";
62
75
  readonly sectionGap: "1.5rem";
63
- readonly columnGap: "1rem";
76
+ readonly columnGap: "1.25rem";
64
77
  };
65
78
  };
66
79
  export declare function normalizeUi7FormDensity(density?: Ui7ThemeDensity | null, fallback?: Ui7FormDensity): Ui7FormDensity;
67
80
  export declare function getUi7FormDensityClassName(density?: Ui7ThemeDensity | null, fallback?: Ui7FormDensity): string;
81
+ export declare function getUi7FormWidthClassName(width?: Ui7FormWidth | null): string;
68
82
  export declare function getUi7FormContractClassName(options?: {
69
83
  density?: Ui7ThemeDensity | null;
84
+ width?: Ui7FormWidth | null;
70
85
  readonly?: boolean;
71
86
  className?: string | null;
72
87
  }): string;
@@ -13,9 +13,22 @@ export type StartWorkflowInput = {
13
13
  flowId: string;
14
14
  payload: Record<string, unknown>;
15
15
  externalRef?: string;
16
+ /**
17
+ * Optional override for initiator identity. By default the helper decodes
18
+ * the full tenant identity (user_id, username, org_id, branch_id, branch_code)
19
+ * from the access token and uses that. Any field explicitly set here wins.
20
+ *
21
+ * Set ONLY when the caller has a verified reason to override (rare). For
22
+ * normal flow-start paths leave this undefined — letting the helper read
23
+ * the JWT keeps initiator_context aligned with the auth7 signature.
24
+ */
16
25
  initiatorContext?: {
17
26
  user_id?: string;
27
+ username?: string;
28
+ org_id?: string;
18
29
  branch_id?: string;
30
+ branch_code?: string;
31
+ ip?: string;
19
32
  };
20
33
  /** User access token dari auth7 — diforward ke workflow7 sebagai Bearer. */
21
34
  userAccessToken: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isi-ui7/bos7-shared",
3
- "version": "0.2.3",
3
+ "version": "0.2.6",
4
4
  "description": "Shared auth7 and layout primitives for bos7 applications.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -72,7 +72,8 @@
72
72
  "@carbon/react": "^1.97.0",
73
73
  "@carbon/icons-react": "^11.71.0",
74
74
  "@isi-ui7/data-table": ">=0.2.0",
75
- "@isi-ui7/lookup-input": ">=0.2.0"
75
+ "@isi-ui7/lookup-input": ">=0.2.0",
76
+ "@isi-ui7/editable-table": ">=0.2.0"
76
77
  },
77
78
  "devDependencies": {
78
79
  "@eslint/eslintrc": "^3.3.1",
@@ -99,12 +100,13 @@
99
100
  "@carbon/react": "^1.97.0",
100
101
  "@carbon/icons-react": "^11.71.0",
101
102
  "@isi-ui7/i18n": "0.2.0",
102
- "@isi-ui7/lookup-input": "0.2.1",
103
- "@isi-ui7/data-table": "0.2.1",
104
- "@isi-ui7/ui-shell": "0.2.2",
103
+ "@isi-ui7/corporate-themes": "0.2.4",
104
+ "@isi-ui7/ui-shell": "0.2.4",
105
105
  "@isi-ui7/modal-manager": "0.2.1",
106
- "@isi-ui7/corporate-themes": "0.2.2",
107
- "@isi-ui7/realtime": "0.2.2"
106
+ "@isi-ui7/realtime": "0.2.2",
107
+ "@isi-ui7/data-table": "0.2.2",
108
+ "@isi-ui7/lookup-input": "0.2.2",
109
+ "@isi-ui7/editable-table": "0.2.2"
108
110
  },
109
111
  "dependencies": {
110
112
  "@carbon/icons-react": "^11.71.0",
@@ -117,8 +119,9 @@
117
119
  "@opentelemetry/sdk-node": "^0.57.0",
118
120
  "@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
119
121
  "@opentelemetry/auto-instrumentations-node": "^0.57.0",
120
- "@isi-ui7/lookup-input": "0.2.1",
121
- "@isi-ui7/data-table": "0.2.1"
122
+ "@isi-ui7/data-table": "0.2.2",
123
+ "@isi-ui7/lookup-input": "0.2.2",
124
+ "@isi-ui7/editable-table": "0.2.2"
122
125
  },
123
126
  "scripts": {
124
127
  "build": "vite build",
@@ -21,11 +21,59 @@ export interface DelegatedProxy {
21
21
  delete<T>(path: string): Promise<{ ok: boolean; data?: T; error?: string }>;
22
22
  }
23
23
 
24
+ /**
25
+ * Tenant identity headers forwarded to the upstream service. Header names
26
+ * match lib7-service-go v0.9.0 `middleware.HeaderActor*` constants so the
27
+ * VerifyAuditSignature middleware (and any audit logger) reads them via the
28
+ * same keys regardless of caller (BFF or workflow7).
29
+ *
30
+ * These five headers duplicate information already inside the delegated JWT
31
+ * claims, so they are NOT individually signed: the JWT's RS256 signature
32
+ * covers the underlying identity. They exist so downstream services (and
33
+ * audit log enrichers) don't have to JWT-decode just to enrich a log line
34
+ * with a human-readable username or branch_code.
35
+ */
36
+ interface ActorHeaders {
37
+ 'X-Actor-UserID': string;
38
+ 'X-Actor-Username': string;
39
+ 'X-Actor-OrgID': string;
40
+ 'X-Actor-BranchID': string;
41
+ 'X-Actor-BranchCode': string;
42
+ }
43
+
44
+ /**
45
+ * Decode a JWT payload without verifying. Safe for tokens that originate
46
+ * from the current trusted session — used purely to forward identity
47
+ * downstream. Returns an empty object on malformed input rather than throw.
48
+ */
49
+ function decodeJwtPayloadUnsafe(token: string): Record<string, unknown> {
50
+ const parts = token.split('.');
51
+ if (parts.length < 2) return {};
52
+ try {
53
+ return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as Record<string, unknown>;
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ function actorHeadersFromToken(userToken: string): ActorHeaders {
60
+ const p = decodeJwtPayloadUnsafe(userToken);
61
+ const str = (v: unknown): string => (typeof v === 'string' ? v : '');
62
+ return {
63
+ 'X-Actor-UserID': str(p.sub),
64
+ 'X-Actor-Username': str(p.preferred_username),
65
+ 'X-Actor-OrgID': str(p.org_id),
66
+ 'X-Actor-BranchID': str(p.branch_id),
67
+ 'X-Actor-BranchCode': str(p.branch_code),
68
+ };
69
+ }
70
+
24
71
  async function proxyRequest<T>(
25
72
  method: string,
26
73
  path: string,
27
74
  delegatedToken: string,
28
75
  baseUrl: string,
76
+ actorHeaders: ActorHeaders,
29
77
  options: { query?: Record<string, string>; body?: unknown } = {},
30
78
  ): Promise<{ ok: boolean; data?: T; error?: string }> {
31
79
  let url = `${baseUrl}${path}`;
@@ -37,6 +85,7 @@ async function proxyRequest<T>(
37
85
  headers: {
38
86
  'Content-Type': 'application/json',
39
87
  Authorization: `Bearer ${delegatedToken}`,
88
+ ...actorHeaders,
40
89
  },
41
90
  ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
42
91
  });
@@ -58,7 +107,14 @@ async function proxyRequest<T>(
58
107
  /**
59
108
  * Exchange the user's access token for a delegated service token (RFC 8693),
60
109
  * then return a typed proxy client for backend calls.
61
- * The exchanged token is cached per (userToken, audience) — see token-exchange.ts.
110
+ *
111
+ * Identity headers are derived from the ORIGINAL user token (the source of
112
+ * truth for who initiated the request), not from the exchanged delegated
113
+ * token (whose `sub` may be remapped to a client_id depending on the auth7
114
+ * exchange policy).
115
+ *
116
+ * The exchanged token is cached per (userToken, audience) — see
117
+ * token-exchange.ts.
62
118
  */
63
119
  export async function createDelegatedProxy(
64
120
  userToken: string,
@@ -67,14 +123,15 @@ export async function createDelegatedProxy(
67
123
  ): Promise<DelegatedProxy> {
68
124
  const { backendUrl = BACKEND_URL, ...exchangeOpts } = options;
69
125
  const { accessToken: delegatedToken } = await exchangeUserToken(userToken, audience, undefined, exchangeOpts);
126
+ const actorHeaders = actorHeadersFromToken(userToken);
70
127
  return {
71
128
  get: <T>(path: string, query?: Record<string, string>) =>
72
- proxyRequest<T>('GET', path, delegatedToken, backendUrl, { query }),
129
+ proxyRequest<T>('GET', path, delegatedToken, backendUrl, actorHeaders, { query }),
73
130
  post: <T>(path: string, body: unknown) =>
74
- proxyRequest<T>('POST', path, delegatedToken, backendUrl, { body }),
131
+ proxyRequest<T>('POST', path, delegatedToken, backendUrl, actorHeaders, { body }),
75
132
  put: <T>(path: string, body: unknown) =>
76
- proxyRequest<T>('PUT', path, delegatedToken, backendUrl, { body }),
133
+ proxyRequest<T>('PUT', path, delegatedToken, backendUrl, actorHeaders, { body }),
77
134
  delete: <T>(path: string) =>
78
- proxyRequest<T>('DELETE', path, delegatedToken, backendUrl),
135
+ proxyRequest<T>('DELETE', path, delegatedToken, backendUrl, actorHeaders),
79
136
  };
80
137
  }
@@ -12,3 +12,5 @@ export * from './types';
12
12
  export * from './client';
13
13
  export { requireScope, scopeFromRequest } from './scope-guard';
14
14
  export type { ScopeLevel } from './scope-guard';
15
+ export { handleSwitchBranchRequest } from './switch-branch-bff';
16
+ export type { SwitchBranchBffOptions } from './switch-branch-bff';
@@ -0,0 +1,121 @@
1
+ import { handleSwitchBranchRequest } from './switch-branch-bff';
2
+
3
+ type CookieJar = Map<string, string>;
4
+
5
+ function mockRequest(opts: {
6
+ cookies?: Record<string, string>;
7
+ body?: unknown;
8
+ host?: string;
9
+ }): import('next/server').NextRequest {
10
+ const cookies = opts.cookies ?? {};
11
+ const host = opts.host ?? 'app.bank.co.id';
12
+ return {
13
+ cookies: {
14
+ get: (name: string) =>
15
+ cookies[name] !== undefined ? { value: cookies[name] } : undefined,
16
+ },
17
+ headers: {
18
+ get: (h: string) => (h.toLowerCase() === 'host' ? host : null),
19
+ },
20
+ json: async () => opts.body ?? {},
21
+ } as unknown as import('next/server').NextRequest;
22
+ }
23
+
24
+ function readSetCookies(res: Response): CookieJar {
25
+ const jar: CookieJar = new Map();
26
+ const raw = res.headers.get('set-cookie') ?? '';
27
+ // Next's mocked Response joins multiple Set-Cookie with comma — split conservatively.
28
+ for (const part of raw.split(/,(?=\s*[A-Za-z0-9_-]+=)/)) {
29
+ const [pair] = part.split(';');
30
+ const [name, value] = pair.split('=');
31
+ if (name && value !== undefined) jar.set(name.trim(), decodeURIComponent(value));
32
+ }
33
+ return jar;
34
+ }
35
+
36
+ describe('handleSwitchBranchRequest', () => {
37
+ const originalFetch = global.fetch;
38
+ afterEach(() => {
39
+ global.fetch = originalFetch;
40
+ });
41
+
42
+ it('returns 401 when access_token cookie is missing', async () => {
43
+ const req = mockRequest({ body: { branch_id: 'b1' } });
44
+ const res = await handleSwitchBranchRequest(req, { auth7ApiUrl: 'http://auth7' });
45
+ expect(res.status).toBe(401);
46
+ });
47
+
48
+ it('persists access_token, refresh_token, and session_id from the response', async () => {
49
+ global.fetch = async () =>
50
+ new Response(
51
+ JSON.stringify({
52
+ access_token: 'new-access',
53
+ refresh_token: 'new-refresh',
54
+ token_type: 'Bearer',
55
+ expires_in: 900,
56
+ session_id: 'sess-xyz',
57
+ branch_id: 'b1',
58
+ branch_code: 'BR01',
59
+ switched_at: '2026-06-01T00:00:00Z',
60
+ }),
61
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
62
+ );
63
+
64
+ const req = mockRequest({
65
+ cookies: { access_token: 'old-access' },
66
+ body: { branch_id: 'b1' },
67
+ });
68
+ const res = await handleSwitchBranchRequest(req, { auth7ApiUrl: 'http://auth7' });
69
+ expect(res.status).toBe(200);
70
+
71
+ const cookies = readSetCookies(res);
72
+ expect(cookies.get('access_token')).toBe('new-access');
73
+ expect(cookies.get('refresh_token')).toBe('new-refresh');
74
+ expect(cookies.get('session_id')).toBe('sess-xyz');
75
+ expect(cookies.get('bos7_branch')).toBe('b1');
76
+ expect(cookies.has('token_expires_at')).toBe(true);
77
+ });
78
+
79
+ it('does not set any cookies when upstream rejects', async () => {
80
+ global.fetch = async () =>
81
+ new Response(JSON.stringify({ error: 'forbidden' }), {
82
+ status: 403,
83
+ headers: { 'Content-Type': 'application/json' },
84
+ });
85
+
86
+ const req = mockRequest({
87
+ cookies: { access_token: 'old-access' },
88
+ body: { branch_id: 'b1' },
89
+ });
90
+ const res = await handleSwitchBranchRequest(req, { auth7ApiUrl: 'http://auth7' });
91
+ expect(res.status).toBe(403);
92
+ const cookies = readSetCookies(res);
93
+ expect(cookies.has('access_token')).toBe(false);
94
+ expect(cookies.has('refresh_token')).toBe(false);
95
+ expect(cookies.has('session_id')).toBe(false);
96
+ });
97
+
98
+ it('skips bos7_branch cookie for localhost host', async () => {
99
+ global.fetch = async () =>
100
+ new Response(
101
+ JSON.stringify({
102
+ access_token: 'a',
103
+ refresh_token: 'r',
104
+ expires_in: 900,
105
+ session_id: 's',
106
+ branch_id: 'b1',
107
+ }),
108
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
109
+ );
110
+
111
+ const req = mockRequest({
112
+ cookies: { access_token: 'old' },
113
+ body: { branch_id: 'b1' },
114
+ host: 'localhost:3000',
115
+ });
116
+ const res = await handleSwitchBranchRequest(req, { auth7ApiUrl: 'http://auth7' });
117
+ const cookies = readSetCookies(res);
118
+ expect(cookies.has('bos7_branch')).toBe(false);
119
+ expect(cookies.get('access_token')).toBe('a');
120
+ });
121
+ });
@@ -0,0 +1,119 @@
1
+ // switch-branch-bff.ts — Shared BFF handler for POST /api/auth/switch-branch
2
+ //
3
+ // Owns cookie persistence for the new session that auth7 issues on branch
4
+ // switch. Before W17 (auth7 commit b74c3a1) the endpoint returned only an
5
+ // access token; the old session+refresh stayed valid. Post-W17, auth7
6
+ // revokes the previous session and re-issues access_token + refresh_token +
7
+ // session_id together. The BFF must persist the new pair atomically,
8
+ // otherwise silent refresh at the 15-min mark uses a revoked refresh_token
9
+ // and forces re-login.
10
+
11
+ import type { NextRequest } from 'next/server';
12
+ import { NextResponse } from 'next/server';
13
+
14
+ export interface SwitchBranchBffOptions {
15
+ auth7ApiUrl: string;
16
+ branchCookieDomain?: (host: string) => string | undefined;
17
+ }
18
+
19
+ function defaultBranchCookieDomain(host: string): string | undefined {
20
+ const h = host.split(':')[0];
21
+ const parts = h.split('.');
22
+ if (parts.length < 2 || h === 'localhost') return undefined;
23
+ return '.' + parts.slice(-2).join('.');
24
+ }
25
+
26
+ export async function handleSwitchBranchRequest(
27
+ request: NextRequest,
28
+ options: SwitchBranchBffOptions
29
+ ): Promise<NextResponse> {
30
+ const accessToken = request.cookies.get('access_token')?.value;
31
+ if (!accessToken) {
32
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
33
+ }
34
+
35
+ let body: { branch_id?: string };
36
+ try {
37
+ body = await request.json();
38
+ } catch {
39
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
40
+ }
41
+
42
+ let res: Response;
43
+ try {
44
+ res = await fetch(`${options.auth7ApiUrl}/auth/switch-branch`, {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ Authorization: `Bearer ${accessToken}`,
49
+ },
50
+ body: JSON.stringify(body),
51
+ });
52
+ } catch {
53
+ return NextResponse.json({ error: 'Failed to switch branch' }, { status: 502 });
54
+ }
55
+
56
+ const data = await res.json().catch(() => ({}));
57
+ const nextRes = NextResponse.json(data, { status: res.status });
58
+
59
+ if (!data || typeof data !== 'object' || !data.access_token) {
60
+ return nextRes;
61
+ }
62
+
63
+ const secure = process.env.NODE_ENV === 'production';
64
+ const expiresIn: number = data.expires_in ?? 3600;
65
+ const newExpiresAt = Date.now() + expiresIn * 1000;
66
+
67
+ nextRes.cookies.set('access_token', data.access_token, {
68
+ httpOnly: true,
69
+ secure,
70
+ sameSite: 'lax',
71
+ maxAge: expiresIn,
72
+ path: '/',
73
+ });
74
+ nextRes.cookies.set('token_expires_at', newExpiresAt.toString(), {
75
+ httpOnly: true,
76
+ secure,
77
+ sameSite: 'lax',
78
+ maxAge: expiresIn,
79
+ path: '/',
80
+ });
81
+
82
+ if (data.refresh_token) {
83
+ nextRes.cookies.set('refresh_token', data.refresh_token, {
84
+ httpOnly: true,
85
+ secure,
86
+ sameSite: 'lax',
87
+ maxAge: 8 * 3600,
88
+ path: '/',
89
+ });
90
+ }
91
+
92
+ if (data.session_id) {
93
+ nextRes.cookies.set('session_id', data.session_id, {
94
+ httpOnly: true,
95
+ secure,
96
+ sameSite: 'lax',
97
+ maxAge: 8 * 3600,
98
+ path: '/',
99
+ });
100
+ }
101
+
102
+ const branchId = (body.branch_id as string | undefined) ?? data.branch_id;
103
+ if (branchId) {
104
+ const domainFn = options.branchCookieDomain ?? defaultBranchCookieDomain;
105
+ const rootDomain = domainFn(request.headers.get('host') || '');
106
+ if (rootDomain) {
107
+ nextRes.cookies.set('bos7_branch', branchId, {
108
+ httpOnly: false,
109
+ secure,
110
+ sameSite: 'lax',
111
+ maxAge: 8 * 3600,
112
+ path: '/',
113
+ domain: rootDomain,
114
+ });
115
+ }
116
+ }
117
+
118
+ return nextRes;
119
+ }
@@ -15,6 +15,7 @@ import {
15
15
  ToastNotification,
16
16
  } from "@carbon/react";
17
17
  import { ServerDataTable } from "@isi-ui7/data-table";
18
+ import type { I_DataTblColumn } from "@isi-ui7/data-table";
18
19
  import type { CrudCustomActionSchema, CrudDeleteSchema, CrudListSchema } from "./crud-types";
19
20
  import type { CrudForm, FormMode } from "./form-types";
20
21
  import type { Ui7FormDensity } from "./style-contract";
@@ -25,6 +26,24 @@ import { useCrudForm } from "./workflow/use-crud-form";
25
26
  import { useWorkflowTracker } from "./workflow/use-workflow-tracker";
26
27
  import { useBosSharedI18n } from "./i18n";
27
28
 
29
+ // Re-export the value_schema → form adapter so category pages (bos7-enterprise)
30
+ // can compose: [scopeSection(t), ...buildValueSections(schema, t).sections].
31
+ export {
32
+ buildValueSections,
33
+ buildXRulesValidator,
34
+ } from "./form-value-schema";
35
+ export type {
36
+ JSONSchema,
37
+ XUi,
38
+ XUiRoot,
39
+ XUiNumeric,
40
+ XUiOption,
41
+ XUiWidget,
42
+ XRule,
43
+ XRuleOp,
44
+ ValueValidatorFn,
45
+ } from "./form-value-schema";
46
+
28
47
  // ── List page (bare) ──────────────────────────────────────────────────────────
29
48
 
30
49
  export function SharedCrudListPage({
@@ -46,10 +65,10 @@ export function SharedCrudListPage({
46
65
  <ServerDataTable
47
66
  key={tableKey}
48
67
  apiPath={schema.apiPath}
49
- columns={schema.columns as any}
68
+ columns={schema.columns as Record<string, I_DataTblColumn>}
50
69
  showSearch={schema.showSearch ?? true}
51
70
  showSort={schema.showSort ?? true}
52
- popupMenu={{ src: "local", items: schema.popupMenuItems as any, onClick: onPopupClick }}
71
+ popupMenu={{ src: "local", items: schema.popupMenuItems, onClick: onPopupClick }}
53
72
  toolbarActions={
54
73
  <Button renderIcon={Add} size="sm" onClick={onAdd}>
55
74
  {schema.addButtonLabel || labels.next}
@@ -307,6 +326,9 @@ export function CrudSchemaPage<TData extends Record<string, unknown>>({
307
326
  }) {
308
327
  const labels = useBosSharedI18n();
309
328
  const density = densityProp ?? schema.density ?? "compact";
329
+ // Width default lives in the renderer — passing undefined here keeps
330
+ // the contract function's "two-thirds" fallback as the single source.
331
+ const width = schema.width;
310
332
  const {
311
333
  form,
312
334
  setForm,
@@ -379,6 +401,7 @@ export function CrudSchemaPage<TData extends Record<string, unknown>>({
379
401
  disabled={saving}
380
402
  onChange={setForm}
381
403
  density={density}
404
+ width={width}
382
405
  />
383
406
  </PageBody>
384
407
  );
@@ -437,6 +460,7 @@ export function CrudSchemaPage<TData extends Record<string, unknown>>({
437
460
  disabled={saving}
438
461
  onChange={setForm}
439
462
  density={density}
463
+ width={width}
440
464
  />
441
465
  </div>
442
466
  ))}
@@ -463,6 +487,7 @@ export function CrudSchemaPage<TData extends Record<string, unknown>>({
463
487
  disabled={saving}
464
488
  onChange={setForm}
465
489
  density={density}
490
+ width={width}
466
491
  />
467
492
  ) : null}
468
493
  </PageBody>
package/src/crud-hooks.ts CHANGED
@@ -9,9 +9,12 @@ export async function runCrudHook<TData extends Record<string, unknown>, TResult
9
9
  return hook(data, context);
10
10
  }
11
11
 
12
- export function applyCrudFieldOverrides<TField extends { key: string; readonly?: boolean; required?: boolean; helperText?: unknown }>(
12
+ export function applyCrudFieldOverrides<
13
+ TField extends { key: string; readonly?: boolean; required?: boolean; helperText?: unknown },
14
+ TData extends Record<string, unknown> = Record<string, unknown>,
15
+ >(
13
16
  fields: TField[],
14
- hooks?: CrudActionOverrideHooks<Record<string, unknown>> | CrudActionOverrideHooks<any>,
17
+ hooks?: CrudActionOverrideHooks<TData>,
15
18
  ): TField[] {
16
19
  if (!hooks?.fieldOverrides) return fields;
17
20