@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.
- package/dist/bff/auth-logout.d.ts +2 -0
- package/dist/bff/auth-me.d.ts +6 -0
- package/dist/bff/auth-preferences.d.ts +2 -0
- package/dist/bff/auth-refresh.d.ts +6 -0
- package/dist/bff/health.d.ts +1 -0
- package/dist/crud-components.d.ts +10 -2
- package/dist/crud-types.d.ts +19 -0
- package/dist/export.d.ts +22 -1
- package/dist/i18n.d.ts +1 -0
- package/dist/index-CPY3DDQN.js +28761 -0
- package/dist/{index.es-Bylk5fh-.js → index.es-Dy3lNA6h.js} +4 -7
- package/dist/index.js +72 -21147
- package/dist/{jspdf.es.min-Dzm5j8wC.js → jspdf.es.min-Ds3Fsl60.js} +1 -1
- package/package.json +34 -29
- package/src/bff/auth-logout.ts +106 -0
- package/src/bff/auth-me.ts +74 -0
- package/src/bff/auth-preferences.ts +80 -0
- package/src/bff/auth-refresh.ts +106 -0
- package/src/bff/health.ts +12 -0
- package/src/crud-components.tsx +45 -5
- package/src/crud-types.ts +19 -0
- package/src/export.ts +144 -19
- package/src/i18n.tsx +2 -0
|
@@ -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-
|
|
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.
|
|
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": "
|
|
66
|
-
"react
|
|
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/
|
|
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
|
-
"@
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
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
|
-
"
|
|
101
|
-
"@
|
|
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/
|
|
109
|
-
"@isi-ui7/
|
|
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
|
-
"
|
|
120
|
-
"@
|
|
121
|
-
"@
|
|
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
|
+
}
|
package/src/crud-components.tsx
CHANGED
|
@@ -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={{
|
|
88
|
+
popupMenu={{
|
|
89
|
+
src: "local",
|
|
90
|
+
items: schema.popupMenuItems,
|
|
91
|
+
itemsForRow: schema.popupMenuItemsForRow,
|
|
92
|
+
onClick: onPopupClick,
|
|
93
|
+
}}
|
|
72
94
|
toolbarActions={
|
|
73
|
-
<
|
|
74
|
-
{schema.
|
|
75
|
-
|
|
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>> = {
|