@autobusal/providers 1.2.8 → 1.2.10

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/CHANGELOG.md ADDED
@@ -0,0 +1,71 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@autobusal/providers` are documented here. This project
4
+ adheres to [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [1.2.10] - 2026-07-19
8
+
9
+ ### Fixed
10
+
11
+ - Resilient bff-mode auth: on a 401 the apiClient now confirms the session with the BFF (GET /bff/session) before logging out, and transparently retries the request once if still authenticated. Prevents a transient post-login 401 (an account query racing the login cookie hand-off) from bouncing the user straight back to the login page. Bearer mode is unchanged.
12
+ ## [1.2.9] - 2026-07-19
13
+
14
+ ### Added
15
+
16
+ - **Selectable auth transport in `Queries/apiClient.ts`.** A new
17
+ `VITE_AUTH_MODE` env flag (`bff` | `bearer`, defaulting to `bff`) picks how
18
+ the Axios client talks to the API. In `bff` mode the client targets the
19
+ same-origin `/bff` with `withCredentials`, relying on the httpOnly
20
+ `bff_session` cookie so no Bearer token or CSRF token ever lives in
21
+ JavaScript. `bearer` mode preserves the legacy transport (`VITE_API_OBT`
22
+ base URL, `withXSRFToken`, localStorage token) for brands that cannot run a
23
+ server-side component at their origin.
24
+ - **`isBffMode` export** derived from `VITE_AUTH_MODE`, re-exported from the
25
+ package root (`index.ts`) so consumers can branch on the active transport.
26
+ - **`GatewayCredentials<T>` interface in `types/settings.ts`**, modelling the
27
+ new per-gateway shape of `{ mode, sandbox, production }` so every payment
28
+ gateway can store a sandbox and a production credential set plus which one
29
+ is active.
30
+
31
+ ### Changed
32
+
33
+ - **`Queries/apiClient.ts` request interceptor is now bearer-only.** The
34
+ interceptor that reads `token` from localStorage and sets the
35
+ `Authorization` header is only registered when not in BFF mode; in BFF mode
36
+ there is no token in JavaScript to attach.
37
+ - **`types/settings.ts` `LabelPayments` gateways reshaped.** `bkt`, `stripe`,
38
+ `mollie`, `raiffeisen`, and `nexi` are now wrapped in
39
+ `GatewayCredentials<...>` (sandbox/production/mode). The BKT credential key
40
+ was corrected from `authid` to `authId`, Stripe gained a `webhook_secret`
41
+ field, and `devpos` gained `mode`, `sandbox_url`, and `production_url` so
42
+ its API host is configurable per-label instead of hardcoded to the demo URL.
43
+ - **`Providers.tsx` wraps the tree in an outer `<Suspense>` boundary.**
44
+ `Styles` and `Setup` both call `useGetSettings()` (a suspense query) above
45
+ the existing inner boundary; on a cold settings cache their suspension had
46
+ no boundary to catch it. The outer boundary shows the `loading` fallback
47
+ during the initial settings fetch; the module-scope QueryClient means the
48
+ suspended remount reuses the same cache with no refetch loop.
49
+ - **`Notifications/Notifications.tsx` error toast duration raised to 6000ms**
50
+ (from the 4s default) so users are less likely to miss error notifications.
51
+ - **`Setup/languages.ts` i18next `debug` is now `import.meta.env.DEV`** instead
52
+ of hardcoded `true`, silencing i18next debug logging in production builds.
53
+ - **`Errors/Message.tsx` logo is now a keyboard-accessible control** (`role`,
54
+ `tabIndex`, `onKeyDown` for Enter/Space, `alt` text) with a matching
55
+ `:focus-visible` outline and `display: inline-block` in `Errors/styles.ts`.
56
+
57
+ ### Fixed
58
+
59
+ - **Error page crash on non-English locales (`Errors/Message.tsx`).** The
60
+ translations lookup now falls back to `errors.en` when the selected locale
61
+ is absent from `errors.ts`; previously a non-English selection (e.g.
62
+ `localStorage` language `sq`) made the lookup `undefined` and crashed the
63
+ error page.
64
+ - **Crash inside the 500 response interceptor (`Queries/apiClient.ts`).** The
65
+ server-error message lookup now falls back to `languages.en` for the same
66
+ reason, so a missing locale can no longer throw from within the error
67
+ interceptor itself.
68
+
69
+ Authored by Ferjolt Ozuni. Consolidated from the magus and alvavel whitelabel
70
+ patch sets into canonical @autobusal source (eliminates per-repo patch-package
71
+ divergence).
@@ -19,15 +19,28 @@ const Message = ({ type }: Props): JSX.Element => {
19
19
 
20
20
  const language = getLanguage('en');
21
21
 
22
- const translations = errors[language as keyof typeof errors];
22
+ // Fall back to English for any locale not present in errors.ts - otherwise a
23
+ // non-English selection (e.g. localStorage language = 'sq') would make this
24
+ // `undefined` and crash the error page on `.not_found`/`.system`.
25
+ const translations = errors[language as keyof typeof errors] ?? errors.en;
23
26
 
24
27
  return (
25
28
  <>
26
29
  <Background />
27
30
 
28
31
  <Container className="box">
29
- <Logo>
30
- <img src={ `${ data.url }/logo-${ theme }.svg` } onClick={ () => navigate('/') } />
32
+ <Logo
33
+ role="button"
34
+ tabIndex={ 0 }
35
+ onClick={ () => navigate('/') }
36
+ onKeyDown={ event => {
37
+ if (event.key === 'Enter' || event.key === ' ') {
38
+ event.preventDefault();
39
+ navigate('/');
40
+ }
41
+ } }
42
+ >
43
+ <img src={ `${ data.url }/logo-${ theme }.svg` } alt="Home" />
31
44
  </Logo>
32
45
 
33
46
  <Description>
package/Errors/styles.ts CHANGED
@@ -25,12 +25,18 @@ export const Container = styled.div`
25
25
 
26
26
  export const Logo = styled.div`
27
27
  margin-bottom: 20px;
28
+ display: inline-block;
28
29
 
29
30
  & > img {
30
31
  display: block;
31
32
  width: 250px;
32
33
  cursor: pointer;
33
34
  }
35
+
36
+ &:focus-visible {
37
+ outline: 2px solid currentColor;
38
+ outline-offset: 2px;
39
+ }
34
40
  `;
35
41
 
36
42
  export const Description = styled.div`
@@ -39,6 +39,8 @@ export const Provider = ({ children }: Props): JSX.Element => {
39
39
  },
40
40
 
41
41
  error: {
42
+ // errors stay up longer - at the default 4s users often miss them
43
+ duration: 6000,
42
44
  style: {
43
45
  background: ourTheme.font.error,
44
46
  color: '#FFFFFF'
package/Providers.tsx CHANGED
@@ -12,19 +12,29 @@ interface Props {
12
12
  }
13
13
 
14
14
  const Providers = ({ type, loading, children }: Props): JSX.Element => (
15
- <Recaptcha>
16
- <Queries>
17
- <Styles>
18
- <Notifications>
19
- <Setup type={ type }>
20
- <Suspense fallback={ loading }>
21
- { children }
22
- </Suspense>
23
- </Setup>
24
- </Notifications>
25
- </Styles>
26
- </Queries>
27
- </Recaptcha>
15
+ // Styles and Setup both call useGetSettings() (a useSuspenseQuery) and sit
16
+ // ABOVE the inner <Suspense> below, so on a cold settings cache their
17
+ // suspension had no boundary to catch it and React threw error #426
18
+ // ("a component suspended while responding to synchronous input"). This
19
+ // outer boundary catches those two suspensions (showing `loading` during
20
+ // the initial settings fetch). The QueryClient in <Queries> is created at
21
+ // module scope, so remounting this subtree while suspended keeps the same
22
+ // client and cache - no refetch loop.
23
+ <Suspense fallback={ loading }>
24
+ <Recaptcha>
25
+ <Queries>
26
+ <Styles>
27
+ <Notifications>
28
+ <Setup type={ type }>
29
+ <Suspense fallback={ loading }>
30
+ { children }
31
+ </Suspense>
32
+ </Setup>
33
+ </Notifications>
34
+ </Styles>
35
+ </Queries>
36
+ </Recaptcha>
37
+ </Suspense>
28
38
  );
29
39
 
30
40
  export default Providers;
@@ -3,14 +3,30 @@ import languages from '@lang/errors';
3
3
  import { getLanguage } from '../Setup/languages';
4
4
  import { error as systemError } from '@autobusal/utilities';
5
5
 
6
- const apiClient = axios.create({
7
- baseURL: import.meta.env.VITE_API_OBT,
8
- withCredentials: true,
9
- withXSRFToken: true
10
- });
6
+ // Auth transport is selectable per brand via VITE_AUTH_MODE - ONE codebase,
7
+ // one flag, so there is a single auth path to maintain across every whitelabel:
8
+ // - 'bff' (default): all calls go through the same-origin BFF (public/bff/,
9
+ // served at /bff), which holds the auth token server-side and attaches it
10
+ // upstream. The browser carries only a first-party httpOnly `bff_session`
11
+ // cookie, so there is no Bearer token in JavaScript and no CSRF to manage.
12
+ // - 'bearer': legacy transport for a brand that cannot run a server-side
13
+ // component at its origin (pure-static hosting) or hasn't deployed its BFF
14
+ // yet - a Sanctum token from localStorage is sent as `Authorization`.
15
+ // See AUTH_STORAGE_DESIGN.md.
16
+ const authMode = import.meta.env.VITE_AUTH_MODE ?? 'bff';
17
+
18
+ export const isBffMode = authMode !== 'bearer';
11
19
 
12
- apiClient.interceptors.request.use(
13
- config => {
20
+ const apiClient = axios.create(
21
+ isBffMode
22
+ ? { baseURL: '/bff', withCredentials: true }
23
+ : { baseURL: import.meta.env.VITE_API_OBT, withCredentials: true, withXSRFToken: true }
24
+ );
25
+
26
+ // bearer mode only: attach the token from localStorage. In bff mode the token
27
+ // never enters JavaScript, so there is nothing to attach here.
28
+ if (!isBffMode) {
29
+ apiClient.interceptors.request.use(config => {
14
30
  const token = localStorage.getItem('token');
15
31
 
16
32
  if (token !== null) {
@@ -18,16 +34,40 @@ apiClient.interceptors.request.use(
18
34
  }
19
35
 
20
36
  return config;
21
- }
22
- );
37
+ });
38
+ }
23
39
 
24
40
  apiClient.interceptors.response.use(response => (
25
41
  response
26
- ), error => {
42
+ ), async (error) => {
27
43
  if (error.response) {
28
- // if we have a 401, we go to the logout page (if not already there)
44
+ // if we have a 401, the session is gone, so we go to the logout page.
29
45
  if (error.response.status === 401 && window.location.pathname !== '/account/logout') {
46
+ // bff mode: auth is a server-side httpOnly cookie the BFF manages. A
47
+ // request that races the login/cookie hand-off (e.g. the account queries
48
+ // fired the instant the SPA lands on /account) can 401 transiently while
49
+ // the session is actually valid - and a blind logout here would drop the
50
+ // user straight back out. So confirm with the BFF first; if we are still
51
+ // authenticated, transparently retry the request once instead of logging
52
+ // out. bearer mode has no such race (the token is attached synchronously
53
+ // from localStorage) and falls straight through to logout.
54
+ if (isBffMode && error.config && !error.config._bffAuthRetried) {
55
+ try {
56
+ const { data } = await apiClient.get('/session');
57
+
58
+ if (data?.authenticated) {
59
+ error.config._bffAuthRetried = true;
60
+
61
+ return await apiClient(error.config);
62
+ }
63
+ } catch {
64
+ // /session unreachable - fall through to logout.
65
+ }
66
+ }
67
+
30
68
  window.location.href = '/account/logout';
69
+
70
+ return Promise.reject(error);
31
71
  }
32
72
 
33
73
  // if the user is not valid
@@ -44,7 +84,10 @@ apiClient.interceptors.response.use(response => (
44
84
  if (error.response.status === 500) {
45
85
  const language = getLanguage('en');
46
86
 
47
- systemError(languages[language as keyof typeof languages].server);
87
+ // Fall back to English for any locale not present in errors.ts, so a
88
+ // non-English selection can't make this `undefined` and throw inside the
89
+ // error interceptor itself.
90
+ systemError((languages[language as keyof typeof languages] ?? languages.en).server);
48
91
  }
49
92
  } else if (error.request) {
50
93
  console.log('REQUEST IS NOT DEFINED');
@@ -55,4 +98,4 @@ apiClient.interceptors.response.use(response => (
55
98
  return Promise.reject(error);
56
99
  });
57
100
 
58
- export default apiClient;
101
+ export default apiClient;
@@ -20,7 +20,7 @@ const languages = (type: string, defaultLanguage: string): void => {
20
20
  },
21
21
  lng: getLanguage(defaultLanguage),
22
22
  fallbackLng: defaultLanguage,
23
- debug: true,
23
+ debug: import.meta.env.DEV,
24
24
  backend: {
25
25
  loadPath: `${ import.meta.env.VITE_API_OBT }/languages/{{lng}}/{{ns}}.json`
26
26
  },
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import Providers from './Providers';
2
- import apiClient from './Queries/apiClient';
2
+ import apiClient, { isBffMode } from './Queries/apiClient';
3
3
  import ErrorBoundary from './Errors/ErrorBoundary';
4
4
  import Message from './Errors/Message';
5
5
  import useMenuStore from './stores/menu';
@@ -7,6 +7,7 @@ import { useUserStore } from './stores/user';
7
7
 
8
8
  export {
9
9
  apiClient,
10
+ isBffMode,
10
11
  ErrorBoundary,
11
12
  Message,
12
13
  useMenuStore,
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@autobusal/providers",
3
- "version": "1.2.8",
3
+ "version": "1.2.10",
4
+ "author": "Ferjolt Ozuni",
4
5
  "type": "module",
5
6
  "main": "index.ts"
6
- }
7
+ }
package/types/settings.ts CHANGED
@@ -124,6 +124,16 @@ export interface LabelSettings {
124
124
  }
125
125
  }
126
126
 
127
+ // Edited: Ferjolt Ozuni - Date: 2026-07-18
128
+ // Added: every gateway now stores a sandbox AND a production credential
129
+ // set plus which one is active, instead of one flat set - see
130
+ // obtapi's app/Traits/PaymentCredentials.php for the backend side.
131
+ export interface GatewayCredentials<T> {
132
+ mode: 'sandbox' | 'production'
133
+ sandbox: T
134
+ production: T
135
+ }
136
+
127
137
  export interface LabelPayments {
128
138
  available: {
129
139
  reserve: boolean
@@ -135,40 +145,48 @@ export interface LabelPayments {
135
145
  nexi: boolean
136
146
  }
137
147
 
148
+ // Edited: Ferjolt Ozuni - Date: 2026-07-18
149
+ // Added: mode/sandbox_url/production_url - the DevPos API host was
150
+ // hardcoded to the demo url in obtapi's config/devpos.php, now
151
+ // configurable per-label - see App\Processes\DevPos\Client::endpoints().
138
152
  devpos: {
139
153
  vat: number
140
154
  barcode: string
141
155
  authorization: string
142
156
  exchange_rate: number
143
157
  cash_register_index: string
158
+ mode: 'sandbox' | 'production'
159
+ sandbox_url: string
160
+ production_url: string
144
161
  }
145
162
 
146
- bkt: {
163
+ bkt: GatewayCredentials<{
147
164
  url: string
148
- authid: string
165
+ authId: string
149
166
  domain: string
150
167
  storeKey: string
151
168
  currencyId: string
152
- }
169
+ }>
153
170
 
154
- stripe: {
171
+ stripe: GatewayCredentials<{
155
172
  account: string
156
173
  secret: string
157
- }
174
+ webhook_secret: string
175
+ }>
158
176
 
159
- mollie: {
177
+ mollie: GatewayCredentials<{
160
178
  key: string
161
- }
179
+ }>
162
180
 
163
- raiffeisen: {
181
+ raiffeisen: GatewayCredentials<{
164
182
  client_id: string
165
183
  username: string
166
184
  password: string
167
- }
185
+ }>
168
186
 
169
- nexi: {
187
+ nexi: GatewayCredentials<{
170
188
  api_key: string
171
- }
189
+ }>
172
190
  }
173
191
 
174
192
  export interface UploadData {