@autobusal/common 1.25.1 → 1.26.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.26.0
4
+
5
+ ### Added
6
+
7
+ - **`HandoffBanner`** — "You are managing *X* as its administrator", shown on a
8
+ brand's own domain for the length of a brand handoff, with a **Finish**
9
+ button that ends the session. Rendered by both apps' private layouts; renders
10
+ nothing at all unless `/bff/session` reports a handoff, so an ordinary
11
+ session costs one request. See `magus/BRAND_HANDOFF_PLAN.md`.
12
+
13
+ Not the same component as magus's `ImpersonationBanner` and not driven by the
14
+ same signal: that one reads `localStorage['impersonating']`, which page
15
+ JavaScript sets and can therefore be wrong. This reads the BFF session, which
16
+ is the only thing that actually knows. There is also nothing on this origin to
17
+ *revert* to — the admin's own session is alive and untouched on a different
18
+ origin — so the action is an explicit end, not a revert.
19
+
20
+ - **`handoff` action on `Table`** — its own icon and label rather than borrowing
21
+ `login_as`, for the same reason `reply` is not `approve`: `login_as` changes
22
+ who you are *within* a brand, a handoff leaves for another brand's domain in a
23
+ new tab.
24
+
25
+ ### Notes
26
+
27
+ - **There is deliberately no `pagehide`/`sendBeacon` revoke.** One was built, to
28
+ shorten the window when somebody closes the tab, and testing removed it:
29
+ `pagehide` with `persisted === false` also fires on a plain **page refresh**,
30
+ so F5 revoked the token and dumped the admin on the logged-out screen.
31
+ Measured — an identical reload driven by curl, with no beacon in play, keeps
32
+ the session. No browser API distinguishes closing from reloading at that
33
+ moment, so what remains is two mechanisms that both work (the Finish button
34
+ and the two-hour token expiry) rather than three where one misfires.
35
+
36
+ - The banner puts **Finish next to the message** rather than at the far edge.
37
+ `space-between` reads better but the whitelabel admin's content column is a
38
+ fixed 1491px that overflows the viewport at ordinary window sizes, which put
39
+ the button off-screen at x=1856 — the one control that reliably ends the
40
+ session cannot be the one you have to scroll sideways to find.
41
+
3
42
  ## 1.25.1
4
43
 
5
44
  **The SEO override wears the site's own form clothes.**
@@ -0,0 +1,144 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { TFunction } from 'i18next';
3
+ import apiClient, { isBffMode } from '@autobusal/providers/Queries/apiClient';
4
+ import { Banner, Text, Brand, Origin, FinishButton } from './styles';
5
+
6
+ export interface HandoffState {
7
+ /**
8
+ * The brand whose admin started this - shown so it is obvious this session
9
+ * did not begin here
10
+ */
11
+ from?: string | null
12
+
13
+ /**
14
+ * The brand being managed
15
+ */
16
+ brand?: string | null
17
+ }
18
+
19
+ interface Props {
20
+ /**
21
+ * Both apps render this from their own private layout, and they load
22
+ * different primary namespaces - magus 'normal', alvavel 'whitelabel' - so
23
+ * this is typed to the one they share and every string below asks for
24
+ * `common` explicitly. Same shape as Breadcrumbs, which both also render.
25
+ */
26
+ t: TFunction<'common'>
27
+ }
28
+
29
+ /**
30
+ * "You are managing X as its administrator."
31
+ *
32
+ * Ferjolt Ozuni - Date: 2026-08-05
33
+ *
34
+ * Shown on the TARGET brand's own domain for the length of a brand handoff -
35
+ * see magus/BRAND_HANDOFF_PLAN.md. Deliberately not the same component as
36
+ * ImpersonationBanner and not driven by the same signal:
37
+ *
38
+ * - ImpersonationBanner reads localStorage['impersonating'], which page
39
+ * JavaScript sets and can therefore be wrong. This reads the BFF session
40
+ * itself, which is the only thing that actually knows.
41
+ * - There is no `impersonator_token` on this origin to revert to. The
42
+ * admin's own session is alive and untouched on a DIFFERENT origin, so
43
+ * the action here is an explicit END, not a revert.
44
+ *
45
+ * The domain in the address bar, the logo and the colour scheme have already
46
+ * told the user where they are - every brand carries its own identity, which
47
+ * is most of why this design was chosen over a label switcher. The bar is
48
+ * the part that says the session is temporary and how to end it.
49
+ */
50
+ const HandoffBanner = ({ t }: Props): (JSX.Element | null) => {
51
+ const [ handoff, setHandoff ] = useState<HandoffState | null>(null);
52
+
53
+ useEffect(() => {
54
+ // bearer-mode brands have no BFF, so there is no session endpoint to ask
55
+ // and no handoff that could have happened.
56
+ if (!isBffMode) {
57
+ return;
58
+ }
59
+
60
+ let cancelled = false;
61
+
62
+ apiClient
63
+ .get('/session')
64
+ .then(response => {
65
+ if (!cancelled && response.data?.handoff) {
66
+ setHandoff(response.data.handoff);
67
+ }
68
+ })
69
+ // A failure here means "no handoff", which is the default. It must not
70
+ // surface as an error toast on every page load of every ordinary
71
+ // session.
72
+ .catch(() => undefined);
73
+
74
+ return () => {
75
+ cancelled = true;
76
+ };
77
+ }, []);
78
+
79
+ /**
80
+ * THERE IS DELIBERATELY NO pagehide/sendBeacon REVOKE HERE.
81
+ *
82
+ * Ferjolt Ozuni - Date: 2026-08-05
83
+ *
84
+ * The plan called for one, to shorten the window when somebody closes the
85
+ * tab: the BFF cookie is `lifetime => 0`, a BROWSER-session cookie, so it
86
+ * survives a closed tab and dies only with the whole browser, and
87
+ * sendBeacon is the only request a browser reliably delivers from a dying
88
+ * page. It was built, and TESTING KILLED IT.
89
+ *
90
+ * `pagehide` does not mean "the user is leaving". With
91
+ * `event.persisted === false` it also fires on a plain PAGE REFRESH and on
92
+ * any hard navigation within the same tab - so pressing F5 revoked the
93
+ * token and dumped the admin on the logged-out screen. Measured: an
94
+ * identical reload driven by curl, with no beacon in play, keeps the
95
+ * session; the browser one destroyed it every time.
96
+ *
97
+ * There is no browser API that distinguishes closing from reloading at
98
+ * pagehide time - PerformanceNavigationTiming only knows AFTER the next
99
+ * load, which is too late to have not revoked. So the choice was a
100
+ * shorter window in one case against a refresh silently ending the session
101
+ * in every case, and the window was never the bigger risk: it is already
102
+ * bounded at two hours by the token's own expiry.
103
+ *
104
+ * What remains is two mechanisms that both work, rather than three where
105
+ * one misfires: the Finish button below, and the two-hour expiry.
106
+ */
107
+
108
+ if (!handoff) {
109
+ return null;
110
+ }
111
+
112
+ const onFinish = (): void => {
113
+ // /bff/logout already revokes the token upstream and destroys the local
114
+ // session, so this needs no endpoint of its own. A hard assignment
115
+ // rather than a router navigation, because every cached query in this
116
+ // tab belongs to a session that no longer exists.
117
+ apiClient
118
+ .post('/logout')
119
+ .catch(() => undefined)
120
+ .finally(() => {
121
+ window.location.href = '/';
122
+ });
123
+ };
124
+
125
+ return (
126
+ <Banner>
127
+ <Text>
128
+ { t('handoff.managing', { ns: 'common' }) }
129
+
130
+ <Brand>{ handoff.brand }</Brand>
131
+
132
+ { t('handoff.as_admin', { ns: 'common' }) }
133
+
134
+ { handoff.from ? <Origin>{ t('handoff.from', { brand: handoff.from, ns: 'common' }) }</Origin> : null }
135
+ </Text>
136
+
137
+ <FinishButton type="button" onClick={ onFinish }>
138
+ { t('handoff.finish', { ns: 'common' }) }
139
+ </FinishButton>
140
+ </Banner>
141
+ );
142
+ };
143
+
144
+ export default HandoffBanner;
@@ -0,0 +1,77 @@
1
+ import styled from 'styled-components';
2
+
3
+ /**
4
+ * Ferjolt Ozuni - Date: 2026-08-05
5
+ *
6
+ * Full width, above everything, and NOT dismissible - a session that is
7
+ * writing to somebody else's brand should not be possible to forget you are
8
+ * in. It uses the warning colour rather than the info colour that
9
+ * ImpersonationBanner uses, because the two states can never both be true on
10
+ * one screen and reading differently at a glance is the point.
11
+ */
12
+ export const Banner = styled.div`
13
+ display: flex;
14
+ flex-wrap: wrap;
15
+ align-items: center;
16
+
17
+ /**
18
+ * Finish sits NEXT TO the message, not pushed to the far edge.
19
+ *
20
+ * space-between reads better on a normal container and is wrong here. The
21
+ * whitelabel admin's content column is a fixed 1491px that overflows the
22
+ * viewport at every ordinary window size, so a right-aligned button landed
23
+ * at x=1856 - off-screen, reachable only by scrolling sideways. The one
24
+ * control that reliably ends a session on somebody else's brand cannot be
25
+ * the one you have to go looking for.
26
+ */
27
+ justify-content: flex-start;
28
+ gap: 14px;
29
+ margin-bottom: 20px;
30
+ padding: 12px 16px;
31
+ color: ${ props => props.theme.font.warning };
32
+ background: ${ props => props.theme.background.neutral };
33
+ border: 1px solid ${ props => props.theme.font.warning };
34
+ border-radius: ${ props => props.theme.borderRadius };
35
+ font-size: ${ props => props.theme.size.s };
36
+ `;
37
+
38
+ export const Text = styled.div`
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ align-items: baseline;
42
+ gap: 6px;
43
+ `;
44
+
45
+ export const Brand = styled.strong`
46
+ color: ${ props => props.theme.font.normal };
47
+ font-weight: 700;
48
+ `;
49
+
50
+ /**
51
+ * Where the session came from - quieter than the rest, because it answers a
52
+ * question somebody might have rather than one they are being asked.
53
+ */
54
+ export const Origin = styled.span`
55
+ color: ${ props => props.theme.font.faded };
56
+ font-size: ${ props => props.theme.size.xs };
57
+ `;
58
+
59
+ export const FinishButton = styled.button`
60
+ flex-shrink: 0;
61
+ padding: 6px 16px;
62
+ color: ${ props => props.theme.font.warning };
63
+ background: ${ props => props.theme.background.normal };
64
+ border: 1px solid ${ props => props.theme.font.warning };
65
+ border-radius: ${ props => props.theme.borderRadius };
66
+ font-family: inherit;
67
+ font-size: ${ props => props.theme.size.s };
68
+ font-weight: 700;
69
+ white-space: nowrap;
70
+ cursor: pointer;
71
+ transition: all 0.3s ease;
72
+
73
+ &:hover {
74
+ color: ${ props => props.theme.background.normal };
75
+ background: ${ props => props.theme.font.warning };
76
+ }
77
+ `;
@@ -4,7 +4,7 @@ import { BiEditAlt, BiTransfer, BiTrash } from 'react-icons/bi';
4
4
  import { FaTrash } from 'react-icons/fa';
5
5
  import { GiMoneyStack } from 'react-icons/gi';
6
6
  import { IoDocumentLockOutline } from 'react-icons/io5';
7
- import { RiBusLine, RiEyeLine, RiLoginCircleLine, RiReplyLine } from 'react-icons/ri';
7
+ import { RiBusLine, RiExternalLinkLine, RiEyeLine, RiLoginCircleLine, RiReplyLine } from 'react-icons/ri';
8
8
 
9
9
  interface Props {
10
10
  type: string
@@ -23,6 +23,14 @@ const Icon = ({ type, t }: Props): (JSX.Element | null) => {
23
23
  case 'login_as':
24
24
  return <RiLoginCircleLine title={ t('table.actions.custom.login_as', { ns: 'common' }) } />;
25
25
 
26
+ // Edited: Ferjolt Ozuni - Date: 2026-08-05
27
+ // Its own action rather than borrowing 'login_as', for the same reason
28
+ // 'reply' below is not 'approve': the verbs differ. login_as changes who
29
+ // you are WITHIN this brand; a handoff leaves for another brand's own
30
+ // domain in a new tab, and the leaving is the part worth showing.
31
+ case 'handoff':
32
+ return <RiExternalLinkLine title={ t('table.actions.custom.handoff', { ns: 'common' }) } />;
33
+
26
34
  case 'approve':
27
35
  return <AiOutlineCheck title={ t('table.actions.custom.approve', { ns: 'common' }) } />;
28
36
 
package/index.ts CHANGED
@@ -34,6 +34,7 @@ import Required from './Required/Required';
34
34
  import Road from './Road/Road';
35
35
  import LocaleGate from './Locale/LocaleGate';
36
36
  import SeoOverride from './SeoOverride/SeoOverride';
37
+ import HandoffBanner from './HandoffBanner/HandoffBanner';
37
38
  import localiseRoutes from './Locale/routes';
38
39
  import { splitLocale, withLocale, localeFromPath } from './Locale/locale';
39
40
  import RouteItem from './RouteItem/RouteItem';
@@ -85,6 +86,7 @@ export {
85
86
  Road,
86
87
  LocaleGate,
87
88
  SeoOverride,
89
+ HandoffBanner,
88
90
  localiseRoutes,
89
91
  splitLocale,
90
92
  withLocale,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autobusal/common",
3
- "version": "1.25.1",
3
+ "version": "1.26.0",
4
4
  "author": "Ferjolt Ozuni",
5
5
  "type": "module",
6
6
  "main": "index.ts"