@isi-ui7/bos7-shared 0.2.0

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.
Files changed (108) hide show
  1. package/dist/api.d.ts +4 -0
  2. package/dist/auth7/client.d.ts +26 -0
  3. package/dist/auth7/delegated-proxy.d.ts +33 -0
  4. package/dist/auth7/hooks/useAuth.d.ts +10 -0
  5. package/dist/auth7/hooks/useSession.d.ts +12 -0
  6. package/dist/auth7/index.d.ts +12 -0
  7. package/dist/auth7/provider.d.ts +13 -0
  8. package/dist/auth7/scope-guard.d.ts +5 -0
  9. package/dist/auth7/service-account.d.ts +2 -0
  10. package/dist/auth7/token-exchange.d.ts +11 -0
  11. package/dist/auth7/types.d.ts +63 -0
  12. package/dist/backend.d.ts +20 -0
  13. package/dist/crud-components.d.ts +47 -0
  14. package/dist/crud-hooks.d.ts +15 -0
  15. package/dist/crud-types.d.ts +86 -0
  16. package/dist/data-table/cursor-adapter.d.ts +74 -0
  17. package/dist/event-bus/index.d.ts +20 -0
  18. package/dist/event-bus/useEventBus.d.ts +21 -0
  19. package/dist/event-bus.d.ts +1 -0
  20. package/dist/export.d.ts +7 -0
  21. package/dist/form-layout.d.ts +39 -0
  22. package/dist/form-numeric.d.ts +35 -0
  23. package/dist/form-renderer-utils.d.ts +10 -0
  24. package/dist/form-renderer.d.ts +18 -0
  25. package/dist/form-rules.d.ts +12 -0
  26. package/dist/form-types.d.ts +130 -0
  27. package/dist/html2canvas.esm-d2sM-0Wm.js +4870 -0
  28. package/dist/i18n.d.ts +35 -0
  29. package/dist/index.d.ts +18 -0
  30. package/dist/index.es-Bylk5fh-.js +5649 -0
  31. package/dist/index.js +20779 -0
  32. package/dist/jspdf.es.min-Dzm5j8wC.js +9977 -0
  33. package/dist/jspdf.plugin.autotable-DcntYaes.js +1078 -0
  34. package/dist/modal-layout.d.ts +45 -0
  35. package/dist/nats/client.d.ts +54 -0
  36. package/dist/notif7.d.ts +21 -0
  37. package/dist/notification/routing.d.ts +4 -0
  38. package/dist/notifications-bff.d.ts +22 -0
  39. package/dist/observability/otel-init.d.ts +1 -0
  40. package/dist/page-layout.d.ts +24 -0
  41. package/dist/purify.es-CiEWEeUM.js +605 -0
  42. package/dist/shell/app-shell-layout.d.ts +70 -0
  43. package/dist/shell/branch.d.ts +6 -0
  44. package/dist/shell/interaction.d.ts +2 -0
  45. package/dist/shell/provider.d.ts +15 -0
  46. package/dist/style-contract.d.ts +72 -0
  47. package/dist/types/core.d.ts +39 -0
  48. package/dist/types/notification.d.ts +30 -0
  49. package/dist/workflow/api-client.d.ts +2 -0
  50. package/dist/workflow/backend.d.ts +9 -0
  51. package/dist/workflow/crud-client.d.ts +3 -0
  52. package/dist/workflow/notif7.d.ts +6 -0
  53. package/dist/workflow/starter.d.ts +44 -0
  54. package/dist/workflow/use-crud-form.d.ts +27 -0
  55. package/dist/workflow/use-workflow-tracker.d.ts +40 -0
  56. package/package.json +92 -0
  57. package/src/api.ts +40 -0
  58. package/src/auth7/client.ts +248 -0
  59. package/src/auth7/delegated-proxy.ts +80 -0
  60. package/src/auth7/hooks/useAuth.ts +21 -0
  61. package/src/auth7/hooks/useSession.ts +75 -0
  62. package/src/auth7/index.ts +14 -0
  63. package/src/auth7/provider.tsx +256 -0
  64. package/src/auth7/scope-guard.ts +54 -0
  65. package/src/auth7/service-account.ts +71 -0
  66. package/src/auth7/token-exchange.ts +125 -0
  67. package/src/auth7/types.ts +72 -0
  68. package/src/backend.ts +96 -0
  69. package/src/crud-components.tsx +580 -0
  70. package/src/crud-hooks.test.ts +32 -0
  71. package/src/crud-hooks.ts +31 -0
  72. package/src/crud-types.ts +64 -0
  73. package/src/data-table/cursor-adapter.ts +239 -0
  74. package/src/event-bus/index.ts +90 -0
  75. package/src/event-bus/useEventBus.ts +93 -0
  76. package/src/event-bus.ts +1 -0
  77. package/src/export.ts +62 -0
  78. package/src/form-contract.css +52 -0
  79. package/src/form-layout.tsx +90 -0
  80. package/src/form-numeric.ts +173 -0
  81. package/src/form-renderer-utils.ts +139 -0
  82. package/src/form-renderer.tsx +624 -0
  83. package/src/form-rules.ts +54 -0
  84. package/src/form-types.ts +126 -0
  85. package/src/i18n.tsx +92 -0
  86. package/src/index.ts +20 -0
  87. package/src/modal-layout.tsx +60 -0
  88. package/src/nats/client.ts +130 -0
  89. package/src/notif7.ts +76 -0
  90. package/src/notification/routing.ts +64 -0
  91. package/src/notifications-bff.ts +273 -0
  92. package/src/observability/otel-init.ts +41 -0
  93. package/src/page-layout.tsx +69 -0
  94. package/src/shell/app-shell-layout.tsx +494 -0
  95. package/src/shell/branch.ts +48 -0
  96. package/src/shell/interaction.ts +17 -0
  97. package/src/shell/provider.tsx +34 -0
  98. package/src/style-contract.test.ts +59 -0
  99. package/src/style-contract.ts +132 -0
  100. package/src/types/core.ts +46 -0
  101. package/src/types/notification.ts +35 -0
  102. package/src/workflow/api-client.ts +7 -0
  103. package/src/workflow/backend.ts +53 -0
  104. package/src/workflow/crud-client.ts +43 -0
  105. package/src/workflow/notif7.ts +28 -0
  106. package/src/workflow/starter.ts +153 -0
  107. package/src/workflow/use-crud-form.ts +176 -0
  108. package/src/workflow/use-workflow-tracker.ts +109 -0
@@ -0,0 +1,248 @@
1
+ // lib/auth7/client.ts — Auth7 API client for frontend
2
+
3
+ import type { TokenSet, UserProfile } from './types';
4
+
5
+ function requireEnv(name: string): string {
6
+ const v = (process.env as Record<string, string | undefined>)[name];
7
+ if (!v) throw new Error(`[bos7] ${name} env var is required`);
8
+ return v;
9
+ }
10
+
11
+ function getRedirectUri(): string {
12
+ if (typeof window !== 'undefined') {
13
+ return `${window.location.origin}/api/auth/callback`;
14
+ }
15
+ return '';
16
+ }
17
+
18
+ async function generateCodeVerifier(): Promise<string> {
19
+ const array = new Uint8Array(32);
20
+ crypto.getRandomValues(array);
21
+ return base64URLEncode(array);
22
+ }
23
+
24
+ async function generateCodeChallenge(verifier: string): Promise<string> {
25
+ const encoder = new TextEncoder();
26
+ const data = encoder.encode(verifier);
27
+ const digest = await crypto.subtle.digest('SHA-256', data);
28
+ return base64URLEncode(new Uint8Array(digest));
29
+ }
30
+
31
+ function base64URLEncode(buffer: Uint8Array): string {
32
+ return btoa(String.fromCharCode(...buffer))
33
+ .replace(/\+/g, '-')
34
+ .replace(/\//g, '_')
35
+ .replace(/=/g, '');
36
+ }
37
+
38
+ function generateState(): string {
39
+ const array = new Uint8Array(16);
40
+ crypto.getRandomValues(array);
41
+ return base64URLEncode(array);
42
+ }
43
+
44
+ export async function buildAuthorizationUrl(): Promise<{
45
+ url: string;
46
+ codeVerifier: string;
47
+ state: string;
48
+ }> {
49
+ const AUTH7_URL = requireEnv('NEXT_PUBLIC_AUTH7_URL');
50
+ const CLIENT_ID = requireEnv('AUTH7_CLIENT_ID');
51
+
52
+ const codeVerifier = await generateCodeVerifier();
53
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
54
+ const state = generateState();
55
+
56
+ const redirectUri = getRedirectUri();
57
+ const scope = 'openid profile email roles offline_access';
58
+
59
+ const params = new URLSearchParams({
60
+ client_id: CLIENT_ID,
61
+ redirect_uri: redirectUri,
62
+ response_type: 'code',
63
+ scope,
64
+ state,
65
+ code_challenge: codeChallenge,
66
+ code_challenge_method: 'S256',
67
+ });
68
+
69
+ const url = `${AUTH7_URL}/oauth2/authorize?${params.toString()}`;
70
+
71
+ return { url, codeVerifier, state };
72
+ }
73
+
74
+ export async function exchangeCodeForTokens(
75
+ code: string,
76
+ codeVerifier: string
77
+ ): Promise<TokenSet> {
78
+ const AUTH7_URL = requireEnv('NEXT_PUBLIC_AUTH7_URL');
79
+ const CLIENT_ID = requireEnv('AUTH7_CLIENT_ID');
80
+
81
+ const redirectUri = getRedirectUri();
82
+
83
+ const body = new URLSearchParams({
84
+ grant_type: 'authorization_code',
85
+ code,
86
+ redirect_uri: redirectUri,
87
+ client_id: CLIENT_ID,
88
+ code_verifier: codeVerifier,
89
+ });
90
+
91
+ const response = await fetch(`${AUTH7_URL}/oauth2/token`, {
92
+ method: 'POST',
93
+ headers: {
94
+ 'Content-Type': 'application/x-www-form-urlencoded',
95
+ },
96
+ body: body.toString(),
97
+ });
98
+
99
+ if (!response.ok) {
100
+ const error = await response.json().catch(() => ({}));
101
+ throw new Error(error.error_description || error.error || 'Token exchange failed');
102
+ }
103
+
104
+ return response.json();
105
+ }
106
+
107
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
108
+ export async function refreshAccessToken(_refreshToken: string): Promise<TokenSet> {
109
+ const response = await fetch('/api/auth/refresh', {
110
+ method: 'POST',
111
+ headers: {
112
+ 'Content-Type': 'application/x-www-form-urlencoded',
113
+ },
114
+ });
115
+
116
+ if (!response.ok) {
117
+ const error = await response.json().catch(() => ({}));
118
+ throw new Error(error.error || 'Token refresh failed');
119
+ }
120
+
121
+ // The refresh route sets new cookies — we need to return the new token data
122
+ // so AuthProvider can update its state
123
+ const token = getStoredTokens();
124
+ return {
125
+ access_token: token.accessToken || '',
126
+ token_type: 'Bearer',
127
+ expires_in: 900,
128
+ scope: '',
129
+ };
130
+ }
131
+
132
+ export async function revokeToken(token: string): Promise<void> {
133
+ const AUTH7_URL = requireEnv('NEXT_PUBLIC_AUTH7_URL');
134
+ const CLIENT_ID = requireEnv('AUTH7_CLIENT_ID');
135
+
136
+ const body = new URLSearchParams({
137
+ token,
138
+ token_type_hint: 'access_token',
139
+ client_id: CLIENT_ID,
140
+ });
141
+
142
+ await fetch(`${AUTH7_URL}/oauth2/revoke`, {
143
+ method: 'POST',
144
+ headers: {
145
+ 'Content-Type': 'application/x-www-form-urlencoded',
146
+ },
147
+ body: body.toString(),
148
+ });
149
+ }
150
+
151
+ export async function getUserInfo(accessToken: string): Promise<UserProfile> {
152
+ const response = await fetch('/api/auth/me', {
153
+ headers: {
154
+ Authorization: `Bearer ${accessToken}`,
155
+ },
156
+ });
157
+
158
+ if (!response.ok) {
159
+ throw new Error('Failed to get user info');
160
+ }
161
+
162
+ return response.json();
163
+ }
164
+
165
+ export function isTokenExpired(expiresAt: number): boolean {
166
+ const bufferTime = 5 * 60 * 1000;
167
+ return expiresAt <= Date.now() + bufferTime;
168
+ }
169
+
170
+ export function storeTokensToCookies(tokens: TokenSet): void {
171
+ const expiresAt = Date.now() + tokens.expires_in * 1000;
172
+
173
+ document.cookie = `access_token=${tokens.access_token}; path=/; max-age=${tokens.expires_in}; samesite=lax`;
174
+ document.cookie = `token_expires_at=${expiresAt}; path=/; max-age=${tokens.expires_in}; samesite=lax`;
175
+ document.cookie = `token_scope=${tokens.scope}; path=/; max-age=${tokens.expires_in}; samesite=lax`;
176
+
177
+ if (tokens.refresh_token) {
178
+ document.cookie = `refresh_token=${tokens.refresh_token}; path=/; max-age=${8 * 3600}; samesite=lax`;
179
+ }
180
+
181
+ if (tokens.id_token) {
182
+ document.cookie = `id_token=${tokens.id_token}; path=/; max-age=${tokens.expires_in}; samesite=lax`;
183
+ }
184
+ }
185
+
186
+ export function clearTokensFromCookies(): void {
187
+ const cookies = [
188
+ 'access_token',
189
+ 'refresh_token',
190
+ 'id_token',
191
+ 'token_expires_at',
192
+ 'token_scope',
193
+ 'auth7_pkce_verifier',
194
+ 'auth7_oauth_state',
195
+ 'auth7_return_url',
196
+ ];
197
+
198
+ cookies.forEach((name) => {
199
+ document.cookie = `${name}=; path=/; max-age=0; samesite=lax`;
200
+ });
201
+ }
202
+
203
+ export function getStoredTokens(): { accessToken: string | null; refreshToken: string | null; expiresAt: number } {
204
+ const getCookie = (name: string): string | null => {
205
+ const value = `; ${document.cookie}`;
206
+ const parts = value.split(`; ${name}=`);
207
+ if (parts.length === 2) {
208
+ return parts.pop()?.split(';').shift() || null;
209
+ }
210
+ return null;
211
+ };
212
+
213
+ const accessToken = getCookie('access_token');
214
+ const refreshToken = getCookie('refresh_token');
215
+ const expiresAtStr = getCookie('token_expires_at');
216
+ const expiresAt = expiresAtStr ? parseInt(expiresAtStr, 10) : 0;
217
+
218
+ return { accessToken, refreshToken, expiresAt };
219
+ }
220
+
221
+ export function storeOAuthParams(codeVerifier: string, state: string, returnUrl: string): void {
222
+ document.cookie = `auth7_pkce_verifier=${codeVerifier}; path=/; max-age=600; samesite=lax`;
223
+ document.cookie = `auth7_oauth_state=${state}; path=/; max-age=600; samesite=lax`;
224
+ document.cookie = `auth7_return_url=${encodeURIComponent(returnUrl)}; path=/; max-age=600; samesite=lax`;
225
+ }
226
+
227
+ export function getOAuthParams(): { codeVerifier: string | null; state: string | null; returnUrl: string | null } {
228
+ const getCookie = (name: string): string | null => {
229
+ const value = `; ${document.cookie}`;
230
+ const parts = value.split(`; ${name}=`);
231
+ if (parts.length === 2) {
232
+ return parts.pop()?.split(';').shift() || null;
233
+ }
234
+ return null;
235
+ };
236
+
237
+ return {
238
+ codeVerifier: getCookie('auth7_pkce_verifier'),
239
+ state: getCookie('auth7_oauth_state'),
240
+ returnUrl: getCookie('auth7_return_url') ? decodeURIComponent(getCookie('auth7_return_url')!) : null,
241
+ };
242
+ }
243
+
244
+ export function clearOAuthParams(): void {
245
+ document.cookie = 'auth7_pkce_verifier=; path=/; max-age=0; samesite=lax';
246
+ document.cookie = 'auth7_oauth_state=; path=/; max-age=0; samesite=lax';
247
+ document.cookie = 'auth7_return_url=; path=/; max-age=0; samesite=lax';
248
+ }
@@ -0,0 +1,80 @@
1
+ // lib/auth7/delegated-proxy.ts — RFC 8693 delegated proxy helper for BFF routes
2
+ // Server-side only.
3
+ //
4
+ // Usage in a BFF API route:
5
+ //
6
+ // const proxy = await createDelegatedProxy(accessToken, 'core7-service-enterprise');
7
+ // const { ok, data } = await proxy.get<MyData>('/v1/customers');
8
+
9
+ import { exchangeUserToken, ExchangeOptions } from './token-exchange';
10
+
11
+ const BACKEND_URL = process.env.BACKEND_URL ?? '';
12
+
13
+ export interface DelegatedProxyOptions extends ExchangeOptions {
14
+ backendUrl?: string;
15
+ }
16
+
17
+ export interface DelegatedProxy {
18
+ get<T>(path: string, query?: Record<string, string>): Promise<{ ok: boolean; data?: T; error?: string }>;
19
+ post<T>(path: string, body: unknown): Promise<{ ok: boolean; data?: T; error?: string }>;
20
+ put<T>(path: string, body: unknown): Promise<{ ok: boolean; data?: T; error?: string }>;
21
+ delete<T>(path: string): Promise<{ ok: boolean; data?: T; error?: string }>;
22
+ }
23
+
24
+ async function proxyRequest<T>(
25
+ method: string,
26
+ path: string,
27
+ delegatedToken: string,
28
+ baseUrl: string,
29
+ options: { query?: Record<string, string>; body?: unknown } = {},
30
+ ): Promise<{ ok: boolean; data?: T; error?: string }> {
31
+ let url = `${baseUrl}${path}`;
32
+ if (options.query && Object.keys(options.query).length > 0) {
33
+ url += '?' + new URLSearchParams(options.query).toString();
34
+ }
35
+ const res = await fetch(url, {
36
+ method,
37
+ headers: {
38
+ 'Content-Type': 'application/json',
39
+ Authorization: `Bearer ${delegatedToken}`,
40
+ },
41
+ ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}),
42
+ });
43
+ if (!res.ok) {
44
+ try {
45
+ const errBody = await res.json() as { error?: { message?: string; code?: string } };
46
+ const msg = errBody?.error?.message ?? errBody?.error?.code ?? `backend ${method} ${path} failed: ${res.status}`;
47
+ return { ok: false, error: msg };
48
+ } catch {
49
+ return { ok: false, error: `backend ${method} ${path} failed: ${res.status}` };
50
+ }
51
+ }
52
+ if (res.status === 204 || res.headers.get('content-length') === '0') {
53
+ return { ok: true };
54
+ }
55
+ return { ok: true, data: (await res.json()) as T };
56
+ }
57
+
58
+ /**
59
+ * Exchange the user's access token for a delegated service token (RFC 8693),
60
+ * then return a typed proxy client for backend calls.
61
+ * The exchanged token is cached per (userToken, audience) — see token-exchange.ts.
62
+ */
63
+ export async function createDelegatedProxy(
64
+ userToken: string,
65
+ audience: string,
66
+ options: DelegatedProxyOptions = {},
67
+ ): Promise<DelegatedProxy> {
68
+ const { backendUrl = BACKEND_URL, ...exchangeOpts } = options;
69
+ const { accessToken: delegatedToken } = await exchangeUserToken(userToken, audience, undefined, exchangeOpts);
70
+ return {
71
+ get: <T>(path: string, query?: Record<string, string>) =>
72
+ proxyRequest<T>('GET', path, delegatedToken, backendUrl, { query }),
73
+ post: <T>(path: string, body: unknown) =>
74
+ proxyRequest<T>('POST', path, delegatedToken, backendUrl, { body }),
75
+ put: <T>(path: string, body: unknown) =>
76
+ proxyRequest<T>('PUT', path, delegatedToken, backendUrl, { body }),
77
+ delete: <T>(path: string) =>
78
+ proxyRequest<T>('DELETE', path, delegatedToken, backendUrl),
79
+ };
80
+ }
@@ -0,0 +1,21 @@
1
+ // lib/auth7/hooks/useAuth.ts — useAuth hook for auth operations
2
+
3
+ 'use client';
4
+
5
+ import { useAuthContext } from '../provider';
6
+
7
+ export function useAuth() {
8
+ const { isAuthenticated, isLoading, user, accessToken, error, login, logout, refreshSession } =
9
+ useAuthContext();
10
+
11
+ return {
12
+ isAuthenticated,
13
+ isLoading,
14
+ user,
15
+ accessToken,
16
+ error,
17
+ login,
18
+ logout,
19
+ refreshSession,
20
+ };
21
+ }
@@ -0,0 +1,75 @@
1
+ // lib/auth7/hooks/useSession.ts — useSession hook for session management
2
+
3
+ 'use client';
4
+
5
+ import { useEffect, useState, useCallback } from 'react';
6
+ import { useAuthContext } from '../provider';
7
+ import { getStoredTokens, isTokenExpired, refreshAccessToken, storeTokensToCookies } from '../client';
8
+
9
+
10
+ interface UseSessionReturn {
11
+ isAuthenticated: boolean;
12
+ isLoading: boolean;
13
+ user: ReturnType<typeof useAuthContext>['user'];
14
+ needsRefresh: boolean;
15
+ refreshSession: () => Promise<void>;
16
+ timeUntilExpiry: number | null;
17
+ }
18
+
19
+ export function useSession(): UseSessionReturn {
20
+ const auth = useAuthContext();
21
+ const [needsRefresh, setNeedsRefresh] = useState(false);
22
+ const [timeUntilExpiry, setTimeUntilExpiry] = useState<number | null>(null);
23
+
24
+ useEffect(() => {
25
+ if (!auth.isAuthenticated || !auth.accessToken) {
26
+ return;
27
+ }
28
+
29
+ const { expiresAt } = getStoredTokens();
30
+ if (isTokenExpired(expiresAt)) {
31
+ setNeedsRefresh(true);
32
+ return;
33
+ }
34
+
35
+ const interval = setInterval(() => {
36
+ const { expiresAt: newExpiresAt } = getStoredTokens();
37
+ const remaining = newExpiresAt - Date.now();
38
+
39
+ if (remaining <= 0) {
40
+ setNeedsRefresh(true);
41
+ clearInterval(interval);
42
+ } else {
43
+ setTimeUntilExpiry(remaining);
44
+ }
45
+ }, 60000);
46
+
47
+ return () => clearInterval(interval);
48
+ }, [auth.isAuthenticated, auth.accessToken]);
49
+
50
+ const refreshSession = useCallback(async () => {
51
+ const { refreshToken } = getStoredTokens();
52
+ if (!refreshToken) {
53
+ auth.login();
54
+ return;
55
+ }
56
+
57
+ try {
58
+ const tokens = await refreshAccessToken(refreshToken);
59
+ storeTokensToCookies(tokens);
60
+
61
+ refreshSession();
62
+ } catch {
63
+ auth.logout();
64
+ }
65
+ }, [auth]);
66
+
67
+ return {
68
+ isAuthenticated: auth.isAuthenticated,
69
+ isLoading: auth.isLoading,
70
+ user: auth.user,
71
+ needsRefresh,
72
+ refreshSession,
73
+ timeUntilExpiry,
74
+ };
75
+ }
@@ -0,0 +1,14 @@
1
+ // lib/auth7/index.ts — Auth7 client exports
2
+
3
+ export { AuthProvider, useAuthContext } from './provider';
4
+ export { useAuth } from './hooks/useAuth';
5
+ export { useSession } from './hooks/useSession';
6
+ export { getServiceToken, clearServiceTokenCache } from './service-account';
7
+ export { exchangeUserToken, clearExchangeCache } from './token-exchange';
8
+ export type { TokenExchangeResult, ExchangeOptions } from './token-exchange';
9
+ export { createDelegatedProxy } from './delegated-proxy';
10
+ export type { DelegatedProxy, DelegatedProxyOptions } from './delegated-proxy';
11
+ export * from './types';
12
+ export * from './client';
13
+ export { requireScope, scopeFromRequest } from './scope-guard';
14
+ export type { ScopeLevel } from './scope-guard';
@@ -0,0 +1,256 @@
1
+ // lib/auth7/provider.tsx — AuthProvider React context for auth7 integration
2
+
3
+ 'use client';
4
+
5
+ import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
6
+ import type { AuthState } from './types';
7
+
8
+ interface AuthContextValue extends AuthState {
9
+ login: () => Promise<void>;
10
+ logout: () => Promise<void>;
11
+ refreshSession: () => Promise<void>;
12
+ }
13
+
14
+ const AuthContext = createContext<AuthContextValue | null>(null);
15
+ const LOGOUT_MARKER_COOKIE = 'bos7_logout_at';
16
+
17
+
18
+ function getRedirectUri(): string {
19
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL;
20
+ if (appUrl) {
21
+ const base = appUrl.replace(/\/+$/, '');
22
+ return `${base}/api/auth/callback`;
23
+ }
24
+ if (typeof window !== 'undefined') {
25
+ const url = new URL(window.location.origin);
26
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
27
+ return `http://${url.host}/api/auth/callback`;
28
+ }
29
+ return `${url.origin}/api/auth/callback`;
30
+ }
31
+ return '';
32
+ }
33
+
34
+ function getPostLogoutRedirectUri(): string {
35
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL;
36
+ if (appUrl) {
37
+ const base = appUrl.replace(/\/+$/, '');
38
+ return `${base}/`;
39
+ }
40
+ if (typeof window !== 'undefined') {
41
+ const url = new URL(window.location.origin);
42
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
43
+ return `http://${url.host}/`;
44
+ }
45
+ return `${url.origin}/`;
46
+ }
47
+ return '/';
48
+ }
49
+
50
+ async function generateCodeVerifier(): Promise<string> {
51
+ const array = new Uint8Array(32);
52
+ crypto.getRandomValues(array);
53
+ return btoa(String.fromCharCode(...array))
54
+ .replace(/\+/g, '-')
55
+ .replace(/\//g, '_')
56
+ .replace(/=/g, '');
57
+ }
58
+
59
+ async function generateCodeChallenge(verifier: string): Promise<string> {
60
+ const encoder = new TextEncoder();
61
+ const data = encoder.encode(verifier);
62
+ const digest = await crypto.subtle.digest('SHA-256', data);
63
+ return btoa(String.fromCharCode(...new Uint8Array(digest)))
64
+ .replace(/\+/g, '-')
65
+ .replace(/\//g, '_')
66
+ .replace(/=/g, '');
67
+ }
68
+
69
+ function generateState(): string {
70
+ const array = new Uint8Array(16);
71
+ crypto.getRandomValues(array);
72
+ return btoa(String.fromCharCode(...array))
73
+ .replace(/\+/g, '-')
74
+ .replace(/\//g, '_')
75
+ .replace(/=/g, '');
76
+ }
77
+
78
+ function getCookieValue(name: string): string | null {
79
+ if (typeof document === 'undefined') return null;
80
+ const prefix = `${name}=`;
81
+ const found = document.cookie.split('; ').find((entry) => entry.startsWith(prefix));
82
+ return found ? decodeURIComponent(found.slice(prefix.length)) : null;
83
+ }
84
+
85
+ export function AuthProvider({ children }: { children: ReactNode }) {
86
+ const [state, setState] = useState<AuthState>({
87
+ isAuthenticated: false,
88
+ isLoading: true,
89
+ user: null,
90
+ accessToken: null,
91
+ error: null,
92
+ });
93
+ const [lastLogoutMarker, setLastLogoutMarker] = useState<string | null>(null);
94
+
95
+ const initializeSession = useCallback(async () => {
96
+ try {
97
+ // Use BFF endpoint — reads httpOnly access_token cookie server-side.
98
+ // document.cookie cannot see httpOnly cookies in production or HTTPS local.
99
+ let meRes = await fetch('/api/auth/me');
100
+
101
+ if (meRes.status === 401) {
102
+ // Try silent refresh via BFF (reads httpOnly refresh_token server-side).
103
+ const refreshRes = await fetch('/api/auth/refresh', { method: 'POST' });
104
+ if (refreshRes.ok) {
105
+ meRes = await fetch('/api/auth/me');
106
+ }
107
+ }
108
+
109
+ if (meRes.ok) {
110
+ const userInfo = await meRes.json();
111
+ setState({
112
+ isAuthenticated: true,
113
+ isLoading: false,
114
+ user: userInfo,
115
+ accessToken: null,
116
+ error: null,
117
+ });
118
+ } else {
119
+ setState({ isAuthenticated: false, isLoading: false, user: null, accessToken: null, error: null });
120
+ }
121
+ } catch {
122
+ setState({ isAuthenticated: false, isLoading: false, user: null, accessToken: null, error: null });
123
+ }
124
+ }, []);
125
+
126
+ useEffect(() => {
127
+ initializeSession();
128
+ setLastLogoutMarker(getCookieValue(LOGOUT_MARKER_COOKIE));
129
+ }, [initializeSession]);
130
+
131
+ // Cross-app logout broadcast handler: marker cookie `bos7_logout_at`
132
+ // (domain=.bos7.local) di-set oleh BFF /api/auth/logout di app manapun.
133
+ // Saat marker berubah, force-clear state lokal — tidak menunggu /api/auth/me
134
+ // karena auth7 OAuth2 logout tidak invalidate JWT (token tetap valid sampai
135
+ // exp), sehingga userinfo proxy masih return 200. Tanpa force-clear, tab
136
+ // lain tetap tampak authenticated meskipun SSO logout sudah terjadi.
137
+ const handleLogoutMarker = useCallback((marker: string) => {
138
+ setLastLogoutMarker(marker);
139
+ setState({ isAuthenticated: false, isLoading: false, user: null, accessToken: null, error: null });
140
+ // Best-effort: bersihkan httpOnly cookie access_token/refresh_token via BFF
141
+ // supaya request lanjutan ke /api/auth/me tidak silent-authenticate ulang.
142
+ void fetch('/api/auth/logout', { method: 'POST' }).catch(() => null);
143
+ }, []);
144
+
145
+ useEffect(() => {
146
+ const handleVisibilityChange = () => {
147
+ if (!document.hidden) {
148
+ const marker = getCookieValue(LOGOUT_MARKER_COOKIE);
149
+ if (marker && marker !== lastLogoutMarker) {
150
+ handleLogoutMarker(marker);
151
+ return;
152
+ }
153
+ initializeSession();
154
+ }
155
+ };
156
+ document.addEventListener('visibilitychange', handleVisibilityChange);
157
+ return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
158
+ }, [initializeSession, lastLogoutMarker, handleLogoutMarker]);
159
+
160
+ useEffect(() => {
161
+ if (!state.isAuthenticated) return;
162
+ const interval = window.setInterval(() => {
163
+ const marker = getCookieValue(LOGOUT_MARKER_COOKIE);
164
+ if (marker && marker !== lastLogoutMarker) {
165
+ handleLogoutMarker(marker);
166
+ return;
167
+ }
168
+ void initializeSession();
169
+ }, 30000);
170
+ return () => window.clearInterval(interval);
171
+ }, [state.isAuthenticated, initializeSession, lastLogoutMarker, handleLogoutMarker]);
172
+
173
+ const login = useCallback(async () => {
174
+ try {
175
+ const AUTH7_URL = process.env.NEXT_PUBLIC_AUTH7_URL;
176
+ const CLIENT_ID = process.env.NEXT_PUBLIC_AUTH7_CLIENT_ID;
177
+ if (!AUTH7_URL) throw new Error('[bos7] NEXT_PUBLIC_AUTH7_URL env var is required');
178
+ if (!CLIENT_ID) throw new Error('[bos7] NEXT_PUBLIC_AUTH7_CLIENT_ID env var is required');
179
+
180
+ const codeVerifier = await generateCodeVerifier();
181
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
182
+ const state = generateState();
183
+ const redirectUri = getRedirectUri();
184
+
185
+ const returnUrl = typeof window !== 'undefined' ? window.location.pathname : '/';
186
+
187
+ document.cookie = `auth7_pkce_verifier=${codeVerifier}; path=/; max-age=600; samesite=lax`;
188
+ document.cookie = `auth7_oauth_state=${state}; path=/; max-age=600; samesite=lax`;
189
+ document.cookie = `auth7_return_url=${encodeURIComponent(returnUrl)}; path=/; max-age=600; samesite=lax`;
190
+
191
+ const params = new URLSearchParams({
192
+ client_id: CLIENT_ID,
193
+ redirect_uri: redirectUri,
194
+ response_type: 'code',
195
+ scope: 'openid profile email roles offline_access',
196
+ state,
197
+ code_challenge: codeChallenge,
198
+ code_challenge_method: 'S256',
199
+ });
200
+
201
+ window.location.href = `${AUTH7_URL}/login?${params.toString()}`;
202
+ } catch (err) {
203
+ setState((prev) => ({ ...prev, error: (err as Error).message }));
204
+ }
205
+ }, []);
206
+
207
+ const logout = useCallback(async () => {
208
+ const AUTH7_URL = process.env.NEXT_PUBLIC_AUTH7_URL;
209
+ const CLIENT_ID = process.env.NEXT_PUBLIC_AUTH7_CLIENT_ID;
210
+ if (!AUTH7_URL) throw new Error('[bos7] NEXT_PUBLIC_AUTH7_URL env var is required');
211
+ if (!CLIENT_ID) throw new Error('[bos7] NEXT_PUBLIC_AUTH7_CLIENT_ID env var is required');
212
+ // BFF /api/auth/logout clears httpOnly cookies server-side.
213
+ await fetch('/api/auth/logout', { method: 'POST' }).catch(() => null);
214
+ const logoutUrl = `${AUTH7_URL}/oauth2/logout?client_id=${CLIENT_ID}&post_logout_redirect_uri=${encodeURIComponent(getPostLogoutRedirectUri())}`;
215
+ window.location.href = logoutUrl;
216
+ }, []);
217
+
218
+ const refreshSession = useCallback(async () => {
219
+ try {
220
+ const refreshRes = await fetch('/api/auth/refresh', { method: 'POST' });
221
+ if (!refreshRes.ok) {
222
+ await login();
223
+ return;
224
+ }
225
+ const meRes = await fetch('/api/auth/me');
226
+ if (!meRes.ok) {
227
+ await login();
228
+ return;
229
+ }
230
+ const userInfo = await meRes.json();
231
+ setState({
232
+ isAuthenticated: true,
233
+ isLoading: false,
234
+ user: userInfo,
235
+ accessToken: null,
236
+ error: null,
237
+ });
238
+ } catch {
239
+ await login();
240
+ }
241
+ }, [login]);
242
+
243
+ return (
244
+ <AuthContext.Provider value={{ ...state, login, logout, refreshSession }}>
245
+ {children}
246
+ </AuthContext.Provider>
247
+ );
248
+ }
249
+
250
+ export function useAuthContext(): AuthContextValue {
251
+ const context = useContext(AuthContext);
252
+ if (!context) {
253
+ throw new Error('useAuthContext must be used within AuthProvider');
254
+ }
255
+ return context;
256
+ }