@isi-ui7/bos7-shared 0.2.2 → 0.2.4
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/auth7/delegated-proxy.d.ts +8 -1
- package/dist/auth7/index.d.ts +2 -0
- package/dist/auth7/switch-branch-bff.d.ts +7 -0
- package/dist/crud-types.d.ts +2 -0
- package/dist/data-table/proxy.d.ts +25 -0
- package/dist/index.d.ts +2 -0
- package/dist/{index.es-Bylk5fh-.js → index.es-B4p6mwaz.js} +1 -1
- package/dist/index.js +3244 -3111
- package/dist/{jspdf.es.min-Dzm5j8wC.js → jspdf.es.min-CpRUhh3E.js} +2 -2
- package/dist/{jspdf.plugin.autotable-DcntYaes.js → jspdf.plugin.autotable-DFaknRns.js} +19 -22
- package/dist/purify.es-Cm3utOpm.js +560 -0
- package/package.json +8 -8
- package/src/auth7/delegated-proxy.ts +62 -5
- package/src/auth7/index.ts +2 -0
- package/src/auth7/switch-branch-bff.test.ts +121 -0
- package/src/auth7/switch-branch-bff.ts +119 -0
- package/src/crud-types.ts +2 -0
- package/src/data-table/proxy.ts +73 -0
- package/src/index.ts +2 -0
- package/src/shell/app-shell-layout.tsx +36 -5
- package/dist/purify.es-CiEWEeUM.js +0 -605
|
@@ -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
|
-
*
|
|
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
|
}
|
package/src/auth7/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/crud-types.ts
CHANGED
|
@@ -44,6 +44,8 @@ export type CrudListSchema = {
|
|
|
44
44
|
showSort?: boolean;
|
|
45
45
|
/** Enable search bar. Defaults to true. */
|
|
46
46
|
showSearch?: boolean;
|
|
47
|
+
/** Endpoint for LookupInput (defaults to apiPath if omitted). In Pattern B both DataTable and LookupInput share the same /query endpoint. */
|
|
48
|
+
lookupApiPath?: string;
|
|
47
49
|
/**
|
|
48
50
|
* Level 2: schema-driven confirmation modals for custom popup actions.
|
|
49
51
|
* Keyed by popup menu item id. Actions not listed here fall through to onCustomAction (Level 1).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Server-side only. Forwards a DataTable POST /query request to a backend service
|
|
2
|
+
// using a delegated JWT (RFC 8693 token exchange).
|
|
3
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
4
|
+
import { exchangeUserToken } from "../auth7/token-exchange";
|
|
5
|
+
|
|
6
|
+
export interface ProxyBackendPostOptions {
|
|
7
|
+
backendUrl: string;
|
|
8
|
+
path: string;
|
|
9
|
+
audience: string;
|
|
10
|
+
accessTokenCookie?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function proxyBackendPost(
|
|
14
|
+
req: NextRequest,
|
|
15
|
+
opts: ProxyBackendPostOptions,
|
|
16
|
+
): Promise<NextResponse> {
|
|
17
|
+
const accessToken = req.cookies.get(opts.accessTokenCookie ?? "access_token")?.value;
|
|
18
|
+
const result = await exchangeUserToken(accessToken ?? "", opts.audience);
|
|
19
|
+
const body = await req.json();
|
|
20
|
+
const res = await fetch(`${opts.backendUrl}${opts.path}`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: {
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
Authorization: `Bearer ${result.accessToken}`,
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
cache: "no-store",
|
|
28
|
+
});
|
|
29
|
+
const data = await res.json();
|
|
30
|
+
return NextResponse.json(data, { status: res.status });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ProxyQueryRouteOptions extends ProxyBackendPostOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Extra column type metadata to merge into the backend response's columnTypes.
|
|
36
|
+
* Useful when the BFF layer knows additional column types not present in the backend response.
|
|
37
|
+
* Optional — if omitted, backend response is forwarded as-is.
|
|
38
|
+
*/
|
|
39
|
+
extraColumnTypes?: Record<string, string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Forwards a DataTable POST /query request to a backend Pattern B endpoint.
|
|
44
|
+
* Semantically identical to proxyBackendPost but signals "this is a query/lookup
|
|
45
|
+
* endpoint" — used by ServerDataTable and LookupInput interchangeably.
|
|
46
|
+
*
|
|
47
|
+
* If extraColumnTypes is provided, merges them into the response's columnTypes field.
|
|
48
|
+
*/
|
|
49
|
+
export async function proxyQueryRoute(
|
|
50
|
+
req: NextRequest,
|
|
51
|
+
opts: ProxyQueryRouteOptions,
|
|
52
|
+
): Promise<NextResponse> {
|
|
53
|
+
if (!opts.extraColumnTypes) {
|
|
54
|
+
return proxyBackendPost(req, opts);
|
|
55
|
+
}
|
|
56
|
+
// Re-implement forward to avoid double body-stream consumption when merging columnTypes.
|
|
57
|
+
const accessToken = req.cookies.get(opts.accessTokenCookie ?? "access_token")?.value;
|
|
58
|
+
const result = await exchangeUserToken(accessToken ?? "", opts.audience);
|
|
59
|
+
const body = await req.json();
|
|
60
|
+
const res = await fetch(`${opts.backendUrl}${opts.path}`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: {
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
Authorization: `Bearer ${result.accessToken}`,
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify(body),
|
|
67
|
+
cache: "no-store",
|
|
68
|
+
});
|
|
69
|
+
const data = await res.json() as Record<string, unknown>;
|
|
70
|
+
const existing = (data.columnTypes as Record<string, string>) ?? {};
|
|
71
|
+
data.columnTypes = { ...existing, ...opts.extraColumnTypes };
|
|
72
|
+
return NextResponse.json(data, { status: res.status });
|
|
73
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,3 +18,5 @@ export * from './crud-types';
|
|
|
18
18
|
export * from './crud-hooks';
|
|
19
19
|
export * from './form-types';
|
|
20
20
|
export * from './i18n';
|
|
21
|
+
export { proxyBackendPost, proxyQueryRoute } from './data-table/proxy';
|
|
22
|
+
export type { ProxyBackendPostOptions, ProxyQueryRouteOptions } from './data-table/proxy';
|
|
@@ -139,7 +139,13 @@ function BranchDropdown({
|
|
|
139
139
|
<button type="button" className="profile-branch-dropdown__trigger" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
|
|
140
140
|
<span className="profile-branch-dropdown__current">
|
|
141
141
|
<span className="profile-branch-section__label" style={{ margin: 0 }}>{t("ui7.branchLabel", "Cabang")}</span>
|
|
142
|
-
<span className="profile-branch-item__code">
|
|
142
|
+
<span className="profile-branch-item__code">
|
|
143
|
+
{currentBranch
|
|
144
|
+
? (currentBranch.code && currentBranch.name && currentBranch.code !== currentBranch.name
|
|
145
|
+
? `${currentBranch.code} — ${currentBranch.name}`
|
|
146
|
+
: (currentBranch.code || currentBranch.name || "—"))
|
|
147
|
+
: "—"}
|
|
148
|
+
</span>
|
|
143
149
|
</span>
|
|
144
150
|
<span className="profile-branch-dropdown__chevron" aria-hidden="true">{open ? "▲" : "▼"}</span>
|
|
145
151
|
</button>
|
|
@@ -400,24 +406,49 @@ function ShellInner({
|
|
|
400
406
|
const [currentBranch, setCurrentBranch] = useState<Branch | null>(null);
|
|
401
407
|
|
|
402
408
|
useEffect(() => {
|
|
409
|
+
// API contract is snake_case (`branch_code`, `branch_name`); UI
|
|
410
|
+
// canonical fields are `code` and `name`. Map at the seam so downstream
|
|
411
|
+
// components stay clean. Empty branch_name falls back to branch_code as
|
|
412
|
+
// a visible label (rather than rendering an empty string).
|
|
413
|
+
type ApiBranch = {
|
|
414
|
+
id: string;
|
|
415
|
+
branch_code?: string;
|
|
416
|
+
branch_name?: string;
|
|
417
|
+
code?: string;
|
|
418
|
+
name?: string;
|
|
419
|
+
is_primary?: boolean;
|
|
420
|
+
};
|
|
421
|
+
const normalize = (b: ApiBranch): Branch => {
|
|
422
|
+
const code = b.branch_code ?? b.code ?? "";
|
|
423
|
+
const apiName = b.branch_name ?? b.name ?? "";
|
|
424
|
+
const name = apiName !== "" ? apiName : code;
|
|
425
|
+
return {
|
|
426
|
+
id: b.id,
|
|
427
|
+
name,
|
|
428
|
+
code,
|
|
429
|
+
is_primary: b.is_primary,
|
|
430
|
+
is_current: b.id === userBranchId,
|
|
431
|
+
};
|
|
432
|
+
};
|
|
433
|
+
|
|
403
434
|
fetch("/api/auth/branches")
|
|
404
435
|
.then((r) => (r.ok ? r.json() : null))
|
|
405
|
-
.then((d: { branches?:
|
|
436
|
+
.then((d: { branches?: ApiBranch[] } | null) => {
|
|
406
437
|
if (!d?.branches?.length) {
|
|
407
438
|
if (userBranchId) {
|
|
408
|
-
const fallback = { id: userBranchId, name: userBranchId, code: userBranchId, is_current: true };
|
|
439
|
+
const fallback: Branch = { id: userBranchId, name: userBranchId, code: userBranchId, is_current: true };
|
|
409
440
|
setBranches([fallback]);
|
|
410
441
|
setCurrentBranch(fallback);
|
|
411
442
|
}
|
|
412
443
|
return;
|
|
413
444
|
}
|
|
414
|
-
const normalized = d.branches.map(
|
|
445
|
+
const normalized = d.branches.map(normalize);
|
|
415
446
|
setBranches(normalized);
|
|
416
447
|
setCurrentBranch(normalized.find((b) => b.is_current) ?? normalized[0]);
|
|
417
448
|
})
|
|
418
449
|
.catch(() => {
|
|
419
450
|
if (userBranchId) {
|
|
420
|
-
const fallback = { id: userBranchId, name: userBranchId, code: userBranchId, is_current: true };
|
|
451
|
+
const fallback: Branch = { id: userBranchId, name: userBranchId, code: userBranchId, is_current: true };
|
|
421
452
|
setBranches([fallback]);
|
|
422
453
|
setCurrentBranch(fallback);
|
|
423
454
|
}
|