@isi-ui7/bos7-shared 0.2.11 → 0.2.13

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.
@@ -9216,7 +9216,7 @@ function(n) {
9216
9216
  var d = h.getContext("2d");
9217
9217
  d.fillStyle = "#fff", d.fillRect(0, 0, h.width, h.height);
9218
9218
  var m = { ignoreMouse: !0, ignoreAnimation: !0, ignoreDimensions: !0 }, _ = this;
9219
- return (Kt.canvg ? Promise.resolve(Kt.canvg) : import("./index.es-Bylk5fh-.js")).catch(function(k) {
9219
+ return (Kt.canvg ? Promise.resolve(Kt.canvg) : import("./index.es-Dy3lNA6h.js")).catch(function(k) {
9220
9220
  return Promise.reject(new Error("Could not load canvg: " + k));
9221
9221
  }).then(function(k) {
9222
9222
  return k.default ? k.default : k;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isi-ui7/bos7-shared",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Shared auth7 and layout primitives for bos7 applications.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,6 +42,11 @@
42
42
  "./shell/provider": "./src/shell/provider.tsx",
43
43
  "./shell/app-shell-layout": "./src/shell/app-shell-layout.tsx",
44
44
  "./notifications-bff": "./src/notifications-bff.ts",
45
+ "./bff/auth-me": "./src/bff/auth-me.ts",
46
+ "./bff/auth-refresh": "./src/bff/auth-refresh.ts",
47
+ "./bff/auth-logout": "./src/bff/auth-logout.ts",
48
+ "./bff/auth-preferences": "./src/bff/auth-preferences.ts",
49
+ "./bff/health": "./src/bff/health.ts",
45
50
  "./observability/otel-init": "./src/observability/otel-init.ts",
46
51
  "./form-types": "./src/form-types.ts",
47
52
  "./form-numeric": "./src/form-numeric.ts",
@@ -62,67 +67,67 @@
62
67
  "src"
63
68
  ],
64
69
  "peerDependencies": {
65
- "react": ">=18",
66
- "react-dom": ">=18",
67
- "next": "16.x",
70
+ "@carbon/icons-react": "^11.71.0",
71
+ "@carbon/react": "^1.97.0",
68
72
  "@isi-ui7/corporate-themes": ">=0.2.0",
73
+ "@isi-ui7/data-table": ">=0.2.0",
74
+ "@isi-ui7/editable-table": ">=0.2.0",
69
75
  "@isi-ui7/i18n": ">=0.1.0",
70
- "@isi-ui7/ui-shell": ">=0.2.0",
76
+ "@isi-ui7/lookup-input": ">=0.2.0",
71
77
  "@isi-ui7/modal-manager": ">=0.2.0",
72
78
  "@isi-ui7/realtime": ">=0.2.0",
73
- "@carbon/react": "^1.97.0",
74
- "@carbon/icons-react": "^11.71.0",
75
- "@isi-ui7/data-table": ">=0.2.0",
76
- "@isi-ui7/lookup-input": ">=0.2.0",
77
- "@isi-ui7/editable-table": ">=0.2.0"
79
+ "@isi-ui7/ui-shell": ">=0.2.0",
80
+ "next": "16.x",
81
+ "react": ">=18",
82
+ "react-dom": ">=18"
78
83
  },
79
84
  "devDependencies": {
85
+ "@carbon/icons-react": "^11.71.0",
86
+ "@carbon/react": "^1.97.0",
80
87
  "@eslint/eslintrc": "^3.3.1",
81
88
  "@eslint/js": "^9.39.0",
89
+ "@testing-library/dom": "^10.0.0",
90
+ "@testing-library/react": "^16.0.0",
91
+ "@testing-library/user-event": "^14.5.0",
82
92
  "@types/react": "^19.0.0",
83
93
  "@types/react-dom": "^19.0.0",
84
94
  "@typescript-eslint/eslint-plugin": "^8.52.0",
85
95
  "@typescript-eslint/parser": "^8.52.0",
86
96
  "@vitejs/plugin-react": "^4.2.0",
87
- "@testing-library/dom": "^10.0.0",
88
- "@testing-library/react": "^16.0.0",
89
- "@testing-library/user-event": "^14.5.0",
90
97
  "eslint": "9.39.2",
91
98
  "eslint-config-prettier": "^9.1.0",
92
99
  "eslint-plugin-jsx-a11y": "^6.7.1",
93
100
  "eslint-plugin-react": "^7.33.2",
94
101
  "eslint-plugin-react-hooks": "^4.6.2",
95
102
  "jsdom": "^22.1.0",
103
+ "next": "16.1.1",
96
104
  "typescript": "^5.3.3",
97
105
  "vite": "^5.0.0",
98
106
  "vite-plugin-dts": "^3.6.0",
99
107
  "vitest": "^1.0.0",
100
- "next": "16.1.1",
101
- "@carbon/react": "^1.97.0",
102
- "@carbon/icons-react": "^11.71.0",
103
- "@isi-ui7/i18n": "0.2.2",
104
- "@isi-ui7/corporate-themes": "0.2.4",
105
- "@isi-ui7/ui-shell": "0.2.4",
108
+ "@isi-ui7/corporate-themes": "0.2.5",
109
+ "@isi-ui7/i18n": "0.2.3",
106
110
  "@isi-ui7/modal-manager": "0.2.2",
107
111
  "@isi-ui7/realtime": "0.2.3",
108
- "@isi-ui7/data-table": "0.2.3",
109
- "@isi-ui7/lookup-input": "0.2.3",
112
+ "@isi-ui7/ui-shell": "0.2.8",
113
+ "@isi-ui7/data-table": "0.2.4",
114
+ "@isi-ui7/lookup-input": "0.2.4",
110
115
  "@isi-ui7/editable-table": "0.2.3"
111
116
  },
112
117
  "dependencies": {
113
118
  "@carbon/icons-react": "^11.71.0",
114
119
  "@carbon/react": "^1.97.0",
120
+ "@opentelemetry/auto-instrumentations-node": "^0.57.0",
121
+ "@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
122
+ "@opentelemetry/sdk-node": "^0.57.0",
123
+ "axios": "^1.7.7",
124
+ "exceljs": "^4.4.0",
115
125
  "jose": "^5.9.6",
116
- "xlsx": "^0.18.5",
117
126
  "jspdf": "^4.2.1",
118
127
  "jspdf-autotable": "^5.0.7",
119
- "axios": "^1.7.7",
120
- "@opentelemetry/sdk-node": "^0.57.0",
121
- "@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
122
- "@opentelemetry/auto-instrumentations-node": "^0.57.0",
123
- "@isi-ui7/data-table": "0.2.3",
124
- "@isi-ui7/lookup-input": "0.2.3",
125
- "@isi-ui7/editable-table": "0.2.3"
128
+ "@isi-ui7/lookup-input": "0.2.4",
129
+ "@isi-ui7/editable-table": "0.2.3",
130
+ "@isi-ui7/data-table": "0.2.4"
126
131
  },
127
132
  "scripts": {
128
133
  "build": "vite build",
@@ -0,0 +1,106 @@
1
+ // bff/auth-logout.ts — Shared BFF handlers for /api/auth/logout
2
+ // Logout endpoint for auth7. Canonical source: bos7-template.
3
+ // Byte-identical across bos7-template, bos7-enterprise, bos7-portal.
4
+ //
5
+ // Config is fully env-driven (no per-app hardcoded values). Module-level env
6
+ // assertions throw at import time, matching the original per-app behavior.
7
+
8
+ import { NextRequest, NextResponse } from 'next/server';
9
+
10
+ const AUTH7_API_URL = process.env.AUTH7_API_URL;
11
+ if (!AUTH7_API_URL) throw new Error('[bos7] AUTH7_API_URL env var is required');
12
+ const CLIENT_ID = process.env.AUTH7_CLIENT_ID || process.env.NEXT_PUBLIC_AUTH7_CLIENT_ID;
13
+ if (!CLIENT_ID) throw new Error('[bos7] AUTH7_CLIENT_ID env var is required');
14
+ const AUTH7_UI_URL = process.env.AUTH7_UI_URL || process.env.NEXT_PUBLIC_AUTH7_URL;
15
+ const APP_URL = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || '';
16
+ const LOGOUT_MARKER_COOKIE = 'bos7_logout_at';
17
+ const LOGOUT_MARKER_DOMAIN = process.env.BOS7_COOKIE_DOMAIN || '.bos7.local';
18
+
19
+ function getPostLogoutRedirectUri(request: NextRequest): string {
20
+ return new URL('/', APP_URL || request.url).toString();
21
+ }
22
+
23
+ function getAuth7LogoutUrl(request: NextRequest): string {
24
+ const url = new URL('/oauth2/logout', AUTH7_UI_URL);
25
+ url.searchParams.set('client_id', CLIENT_ID!);
26
+ url.searchParams.set('post_logout_redirect_uri', getPostLogoutRedirectUri(request));
27
+ return url.toString();
28
+ }
29
+
30
+ function clearAuthCookies(response: NextResponse): void {
31
+ const cookies = [
32
+ 'access_token',
33
+ 'refresh_token',
34
+ 'id_token',
35
+ 'token_expires_at',
36
+ 'token_scope',
37
+ 'auth7_pkce_verifier',
38
+ 'auth7_oauth_state',
39
+ 'auth7_return_url',
40
+ ];
41
+
42
+ cookies.forEach((name) => {
43
+ response.cookies.set(name, '', {
44
+ path: '/',
45
+ maxAge: 0,
46
+ sameSite: 'lax',
47
+ });
48
+ });
49
+ }
50
+
51
+ function setGlobalLogoutMarker(response: NextResponse): void {
52
+ response.cookies.set(LOGOUT_MARKER_COOKIE, Date.now().toString(), {
53
+ path: '/',
54
+ sameSite: 'lax',
55
+ secure: process.env.NODE_ENV === 'production',
56
+ domain: LOGOUT_MARKER_DOMAIN,
57
+ maxAge: 60 * 60 * 24 * 30,
58
+ });
59
+ }
60
+
61
+ // Public signatures use Web `Request`/`Response` (see auth-me.ts for why); the
62
+ // runtime object is always a real NextRequest.
63
+
64
+ // POST: clear httpOnly cookies (called from JS before navigating to auth7 SSO logout)
65
+ export async function POST(request: Request): Promise<Response> {
66
+ const req = request as unknown as NextRequest;
67
+ const accessToken = req.cookies.get('access_token')?.value;
68
+ if (accessToken) {
69
+ try {
70
+ await fetch(`${AUTH7_API_URL}/v1/auth/logout`, {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}` },
73
+ });
74
+ } catch { /* best-effort */ }
75
+ }
76
+ const res = NextResponse.json({ ok: true });
77
+ clearAuthCookies(res);
78
+ setGlobalLogoutMarker(res);
79
+ return res;
80
+ }
81
+
82
+ export async function GET(request: Request): Promise<Response> {
83
+ const req = request as unknown as NextRequest;
84
+ const accessToken = req.cookies.get('access_token')?.value;
85
+
86
+ // Invalidate session on auth7 backend (best-effort)
87
+ if (accessToken) {
88
+ try {
89
+ await fetch(`${AUTH7_API_URL}/v1/auth/logout`, {
90
+ method: 'POST',
91
+ headers: {
92
+ 'Content-Type': 'application/json',
93
+ Authorization: `Bearer ${accessToken}`,
94
+ },
95
+ });
96
+ } catch {
97
+ // Ignore backend errors — we still clear local cookies
98
+ }
99
+ }
100
+
101
+ const response = NextResponse.redirect(getAuth7LogoutUrl(req));
102
+ clearAuthCookies(response);
103
+ setGlobalLogoutMarker(response);
104
+
105
+ return response;
106
+ }
@@ -0,0 +1,74 @@
1
+ // bff/auth-me.ts — Shared BFF handler for /api/auth/me
2
+ // Proxy to auth7 /oauth2/userinfo, augmenting userinfo with JWT claims
3
+ // (branch_id, roles) for shell display. Canonical source: bos7-template.
4
+ //
5
+ // Apps re-export the ready GET (default error code 'unauthorized'), or build a
6
+ // variant via makeAuthMeRoute({ errorCode }) — e.g. bos7-enterprise uses
7
+ // 'unauthenticated'.
8
+
9
+ import { NextRequest, NextResponse } from 'next/server';
10
+
11
+ const AUTH7_API_URL = process.env.AUTH7_API_URL || 'http://localhost:8090';
12
+
13
+ export interface AuthMeRouteOptions {
14
+ /** Error code returned in the 401 body when no access_token cookie is present. */
15
+ errorCode?: string;
16
+ }
17
+
18
+ function decodeJWTPayload(token: string): Record<string, unknown> | null {
19
+ try {
20
+ const parts = token.split('.');
21
+ if (parts.length < 2) return null;
22
+ const payload = parts[1];
23
+ const decoded = Buffer.from(payload, 'base64url').toString('utf-8');
24
+ return JSON.parse(decoded);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export function makeAuthMeRoute(options: AuthMeRouteOptions = {}) {
31
+ const errorCode = options.errorCode ?? 'unauthorized';
32
+
33
+ // NOTE: The public signature uses the Web `Request`/`Response` types (not
34
+ // NextRequest/NextResponse) so the exported route handler stays assignable to
35
+ // Next's RouteHandlerConfig regardless of which `next` install resolves the
36
+ // type. At runtime Next always passes a real NextRequest, so the internal
37
+ // cast is safe.
38
+ return async function GET(request: Request): Promise<Response> {
39
+ const req = request as unknown as NextRequest;
40
+ const accessToken = req.cookies.get('access_token')?.value;
41
+
42
+ if (!accessToken) {
43
+ return NextResponse.json({ error: errorCode }, { status: 401 });
44
+ }
45
+
46
+ try {
47
+ // Fetch userinfo from auth7 OAuth2 endpoint
48
+ const response = await fetch(`${AUTH7_API_URL}/oauth2/userinfo`, {
49
+ headers: {
50
+ Authorization: `Bearer ${accessToken}`,
51
+ },
52
+ });
53
+
54
+ if (!response.ok) {
55
+ return NextResponse.json({ error: 'userinfo_failed' }, { status: response.status });
56
+ }
57
+
58
+ const userInfo = await response.json();
59
+
60
+ // Augment with JWT claims for shell display
61
+ const jwtClaims = decodeJWTPayload(accessToken);
62
+ if (jwtClaims) {
63
+ userInfo.roles = jwtClaims.roles || userInfo.roles || [];
64
+ userInfo.branch_id = jwtClaims.branch_id || null;
65
+ }
66
+
67
+ return NextResponse.json(userInfo);
68
+ } catch {
69
+ return NextResponse.json({ error: 'server_error' }, { status: 500 });
70
+ }
71
+ };
72
+ }
73
+
74
+ export const GET = makeAuthMeRoute();
@@ -0,0 +1,80 @@
1
+ // bff/auth-preferences.ts — Shared BFF handlers for /api/auth/preferences
2
+ // Locale preference (GET/POST) backed by auth7 profile + core7_locale cookie.
3
+ // Canonical source: bos7-template. Byte-identical across bos7-template,
4
+ // bos7-portal, bos7-workflow, auth7-ui.
5
+
6
+ import { NextRequest, NextResponse } from "next/server";
7
+
8
+ const COOKIE_KEY = "core7_locale";
9
+ const VALID = new Set(["id", "en"]);
10
+ const AUTH7_API_URL = process.env.AUTH7_API_URL || "http://localhost:8090";
11
+
12
+ // Public signatures use Web `Request`/`Response` (see bff/auth-me.ts for why);
13
+ // the runtime object is always a real NextRequest.
14
+ export async function GET(request: Request): Promise<Response> {
15
+ const req = request as unknown as NextRequest;
16
+ const cookieLocale = req.cookies.get(COOKIE_KEY)?.value ?? "id";
17
+ const accessToken = req.cookies.get("access_token")?.value;
18
+ if (!accessToken) {
19
+ return NextResponse.json({ locale: VALID.has(cookieLocale) ? cookieLocale : "id" });
20
+ }
21
+
22
+ try {
23
+ const res = await fetch(`${AUTH7_API_URL}/v1/auth/me`, {
24
+ headers: { Authorization: `Bearer ${accessToken}` },
25
+ cache: "no-store",
26
+ });
27
+ if (!res.ok) {
28
+ return NextResponse.json({ locale: VALID.has(cookieLocale) ? cookieLocale : "id" });
29
+ }
30
+ const data = (await res.json()) as { preferred_locale?: string };
31
+ const locale = data?.preferred_locale && VALID.has(data.preferred_locale) ? data.preferred_locale : (VALID.has(cookieLocale) ? cookieLocale : "id");
32
+ const response = NextResponse.json({ locale });
33
+ response.cookies.set(COOKIE_KEY, locale, {
34
+ httpOnly: false,
35
+ secure: process.env.NODE_ENV === "production",
36
+ sameSite: "lax",
37
+ maxAge: 31536000,
38
+ path: "/",
39
+ });
40
+ return response;
41
+ } catch {
42
+ return NextResponse.json({ locale: VALID.has(cookieLocale) ? cookieLocale : "id" });
43
+ }
44
+ }
45
+
46
+ export async function POST(request: Request): Promise<Response> {
47
+ const req = request as unknown as NextRequest;
48
+ const accessToken = req.cookies.get("access_token")?.value;
49
+ let locale = "id";
50
+ try {
51
+ const body = (await req.json()) as { locale?: string };
52
+ if (body?.locale && VALID.has(body.locale)) locale = body.locale;
53
+ } catch {
54
+ return NextResponse.json({ error: "invalid_request" }, { status: 400 });
55
+ }
56
+
57
+ const res = NextResponse.json({ locale });
58
+ if (accessToken) {
59
+ try {
60
+ await fetch(`${AUTH7_API_URL}/v1/auth/profile`, {
61
+ method: "PUT",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ Authorization: `Bearer ${accessToken}`,
65
+ },
66
+ body: JSON.stringify({ preferred_locale: locale }),
67
+ });
68
+ } catch {
69
+ // keep local cookie fallback even if backend sync fails
70
+ }
71
+ }
72
+ res.cookies.set(COOKIE_KEY, locale, {
73
+ httpOnly: false,
74
+ secure: process.env.NODE_ENV === "production",
75
+ sameSite: "lax",
76
+ maxAge: 31536000,
77
+ path: "/",
78
+ });
79
+ return res;
80
+ }
@@ -0,0 +1,106 @@
1
+ // bff/auth-refresh.ts — Shared BFF handler for /api/auth/refresh
2
+ // Silent token refresh endpoint. Canonical source: bos7-template.
3
+ //
4
+ // The only per-app difference is the CLIENT_ID fallback used when
5
+ // AUTH7_CLIENT_ID is unset. Apps re-export the ready POST (fallback
6
+ // 'bos7-template') when byte-identical, or build a variant via
7
+ // makeAuthRefreshRoute({ clientIdFallback }).
8
+
9
+ import { NextRequest, NextResponse } from 'next/server';
10
+
11
+ const AUTH7_API_URL = process.env.AUTH7_API_URL || 'http://localhost:8090';
12
+
13
+ export interface AuthRefreshRouteOptions {
14
+ /** Fallback client_id used when AUTH7_CLIENT_ID env var is unset. */
15
+ clientIdFallback?: string;
16
+ }
17
+
18
+ function getRefreshToken(request: NextRequest): string | null {
19
+ return request.cookies.get('refresh_token')?.value ?? null;
20
+ }
21
+
22
+ export function makeAuthRefreshRoute(options: AuthRefreshRouteOptions = {}) {
23
+ const CLIENT_ID = process.env.AUTH7_CLIENT_ID || options.clientIdFallback || 'bos7-template';
24
+
25
+ // Public signature uses Web `Request`/`Response` (see auth-me.ts for why); the
26
+ // runtime object is always a real NextRequest.
27
+ return async function POST(request: Request): Promise<Response> {
28
+ const refreshToken = getRefreshToken(request as unknown as NextRequest);
29
+
30
+ if (!refreshToken) {
31
+ return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
32
+ }
33
+
34
+ try {
35
+ const body = new URLSearchParams({
36
+ grant_type: 'refresh_token',
37
+ refresh_token: refreshToken,
38
+ client_id: CLIENT_ID,
39
+ });
40
+
41
+ const response = await fetch(`${AUTH7_API_URL}/oauth2/token`, {
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/x-www-form-urlencoded',
45
+ },
46
+ body: body.toString(),
47
+ });
48
+
49
+ if (!response.ok) {
50
+ const errorData = await response.json().catch(() => ({}));
51
+ throw new Error(errorData.error_description || errorData.error || 'Token refresh failed');
52
+ }
53
+
54
+ const tokens = await response.json();
55
+ const expiresAt = Date.now() + tokens.expires_in * 1000;
56
+
57
+ const jsonResponse = NextResponse.json({ success: true });
58
+
59
+ jsonResponse.cookies.set('access_token', tokens.access_token, {
60
+ httpOnly: true,
61
+ secure: process.env.NODE_ENV === 'production',
62
+ sameSite: 'lax',
63
+ maxAge: tokens.expires_in,
64
+ path: '/',
65
+ });
66
+
67
+ jsonResponse.cookies.set('token_expires_at', expiresAt.toString(), {
68
+ httpOnly: true,
69
+ secure: process.env.NODE_ENV === 'production',
70
+ sameSite: 'lax',
71
+ maxAge: tokens.expires_in,
72
+ path: '/',
73
+ });
74
+
75
+ jsonResponse.cookies.set('token_scope', tokens.scope, {
76
+ httpOnly: true,
77
+ secure: process.env.NODE_ENV === 'production',
78
+ sameSite: 'lax',
79
+ maxAge: tokens.expires_in,
80
+ path: '/',
81
+ });
82
+
83
+ if (tokens.refresh_token) {
84
+ jsonResponse.cookies.set('refresh_token', tokens.refresh_token, {
85
+ httpOnly: true,
86
+ secure: process.env.NODE_ENV === 'production',
87
+ sameSite: 'lax',
88
+ maxAge: 8 * 3600,
89
+ path: '/',
90
+ });
91
+ }
92
+
93
+ return jsonResponse;
94
+ } catch (err) {
95
+ const response = NextResponse.json({ error: (err as Error).message }, { status: 401 });
96
+ response.cookies.set('access_token', '', { path: '/', maxAge: 0, sameSite: 'lax' });
97
+ response.cookies.set('refresh_token', '', { path: '/', maxAge: 0, sameSite: 'lax' });
98
+ response.cookies.set('id_token', '', { path: '/', maxAge: 0, sameSite: 'lax' });
99
+ response.cookies.set('token_expires_at', '', { path: '/', maxAge: 0, sameSite: 'lax' });
100
+ response.cookies.set('token_scope', '', { path: '/', maxAge: 0, sameSite: 'lax' });
101
+ return response;
102
+ }
103
+ };
104
+ }
105
+
106
+ export const POST = makeAuthRefreshRoute();
@@ -0,0 +1,12 @@
1
+ // bff/health.ts — Shared BFF handler for /api/health
2
+ // Liveness probe. Canonical source: bos7-template.
3
+ // Byte-identical across bos7-template and bos7-enterprise.
4
+
5
+ export async function GET(): Promise<Response> {
6
+ return Response.json({
7
+ status: 'ok',
8
+ version: process.env.APP_VERSION ?? 'unknown',
9
+ timestamp: new Date().toISOString(),
10
+ env: process.env.NODE_ENV ?? 'unknown',
11
+ });
12
+ }
@@ -13,6 +13,7 @@ import {
13
13
  ProgressIndicator,
14
14
  ProgressStep,
15
15
  ToastNotification,
16
+ Toggle,
16
17
  } from "@carbon/react";
17
18
  import { ServerDataTable } from "@isi-ui7/data-table";
18
19
  import type { I_DataTblColumn } from "@isi-ui7/data-table";
@@ -50,14 +51,29 @@ export function SharedCrudListPage({
50
51
  schema,
51
52
  tableKey,
52
53
  onAdd,
53
- onPopupClick
54
+ onPopupClick,
55
+ toolbarExtra,
54
56
  }: {
55
57
  schema: CrudListSchema;
56
58
  tableKey?: number;
57
59
  onAdd: () => void;
58
60
  onPopupClick: (row: Record<string, unknown>, actionId: string) => void;
61
+ /**
62
+ * Optional extra toolbar action(s) rendered to the LEFT of the built-in Add
63
+ * button (e.g. a secondary "Bulk Create" button). Additive + backward-compatible:
64
+ * omit it and the toolbar renders exactly as before.
65
+ */
66
+ toolbarExtra?: ReactNode;
59
67
  }) {
60
68
  const labels = useBosSharedI18n();
69
+ // "Show Inactive" toggle state (opt-in via schema.showInactiveToggle). When
70
+ // on, the list request carries ?<param>=true so the backend surfaces
71
+ // deactivated / soft-deleted rows. Survives table remounts (tableKey).
72
+ const [showInactive, setShowInactive] = useState(false);
73
+ const inactiveParam = schema.includeInactiveParam ?? "include_inactive";
74
+ const additionalParams = schema.showInactiveToggle
75
+ ? { [inactiveParam]: showInactive ? "true" : "" }
76
+ : undefined;
61
77
  return (
62
78
  <PageLayout>
63
79
  <PageHeader title={schema.title} subtitle={schema.subtitle} />
@@ -65,14 +81,34 @@ export function SharedCrudListPage({
65
81
  <ServerDataTable
66
82
  key={tableKey}
67
83
  apiPath={schema.apiPath}
84
+ additionalParams={additionalParams}
68
85
  columns={schema.columns as Record<string, I_DataTblColumn>}
69
86
  showSearch={schema.showSearch ?? true}
70
87
  showSort={schema.showSort ?? true}
71
- popupMenu={{ src: "local", items: schema.popupMenuItems, onClick: onPopupClick }}
88
+ popupMenu={{
89
+ src: "local",
90
+ items: schema.popupMenuItems,
91
+ itemsForRow: schema.popupMenuItemsForRow,
92
+ onClick: onPopupClick,
93
+ }}
72
94
  toolbarActions={
73
- <Button renderIcon={Add} size="sm" onClick={onAdd}>
74
- {schema.addButtonLabel || labels.next}
75
- </Button>
95
+ <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
96
+ {schema.showInactiveToggle ? (
97
+ <Toggle
98
+ id={`show-inactive-${schema.apiPath}`}
99
+ size="sm"
100
+ labelText=""
101
+ labelA={schema.showInactiveLabel ?? labels.showInactive}
102
+ labelB={schema.showInactiveLabel ?? labels.showInactive}
103
+ toggled={showInactive}
104
+ onToggle={(checked) => setShowInactive(checked)}
105
+ />
106
+ ) : null}
107
+ {toolbarExtra}
108
+ <Button renderIcon={Add} size="sm" onClick={onAdd}>
109
+ {schema.addButtonLabel || labels.next}
110
+ </Button>
111
+ </div>
76
112
  }
77
113
  />
78
114
  </PageBody>
@@ -92,6 +128,7 @@ export function CrudSchemaListPage<TData extends Record<string, unknown>>({
92
128
  onEdit,
93
129
  deleteApiPath,
94
130
  onCustomAction,
131
+ toolbarExtra,
95
132
  }: {
96
133
  listSchema: CrudListSchema;
97
134
  deleteSchema?: CrudDeleteSchema<TData>;
@@ -99,6 +136,8 @@ export function CrudSchemaListPage<TData extends Record<string, unknown>>({
99
136
  onView?: (id: string) => void;
100
137
  onEdit?: (id: string) => void;
101
138
  deleteApiPath?: (id: string) => string;
139
+ /** Optional extra toolbar action(s) rendered beside the built-in Add button. */
140
+ toolbarExtra?: ReactNode;
102
141
  /**
103
142
  * Level 1: called for any popup action id not handled by built-in logic
104
143
  * (view / edit / delete) and not in listSchema.customActionSchemas (Level 2).
@@ -231,6 +270,7 @@ export function CrudSchemaListPage<TData extends Record<string, unknown>>({
231
270
  tableKey={tableKey}
232
271
  onAdd={onAdd}
233
272
  onPopupClick={onPopupClick}
273
+ toolbarExtra={toolbarExtra}
234
274
  />
235
275
 
236
276
  {wfToast ? (
package/src/crud-types.ts CHANGED
@@ -57,6 +57,25 @@ export type CrudListSchema = {
57
57
  * Keyed by popup menu item id. Actions not listed here fall through to onCustomAction (Level 1).
58
58
  */
59
59
  customActionSchemas?: Record<string, CrudCustomActionSchema>;
60
+ /**
61
+ * Show a "Show Inactive" toolbar toggle. When on, the list request carries
62
+ * `?<includeInactiveParam>=true` so the backend surfaces soft-deleted /
63
+ * deactivated rows too. Default off keeps the list active-only. Pair with
64
+ * `popupMenuItemsForRow` so inactive rows expose "Reactivate".
65
+ */
66
+ showInactiveToggle?: boolean;
67
+ /** Query param name toggled by the Show-Inactive control. Default "include_inactive". */
68
+ includeInactiveParam?: string;
69
+ /** Label for the Show-Inactive toggle (i18n'd by the caller). Default "Tampilkan Nonaktif". */
70
+ showInactiveLabel?: string;
71
+ /**
72
+ * Per-row popup items. When provided, overrides `popupMenuItems` for each row
73
+ * so the menu can depend on row state — e.g. active rows show "Deactivate",
74
+ * inactive rows show "Reactivate". Returned item ids route the same way as
75
+ * `popupMenuItems` (view/edit/delete built-in; others via customActionSchemas
76
+ * or onCustomAction).
77
+ */
78
+ popupMenuItemsForRow?: (row: Record<string, unknown>) => CrudPopupMenuItem[];
60
79
  };
61
80
 
62
81
  export type CrudDeleteSchema<TData extends Record<string, unknown>> = {