@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 +39 -0
- package/HandoffBanner/HandoffBanner.tsx +144 -0
- package/HandoffBanner/styles.ts +77 -0
- package/Table/Actions/Icon.tsx +9 -1
- package/index.ts +2 -0
- package/package.json +1 -1
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
|
+
`;
|
package/Table/Actions/Icon.tsx
CHANGED
|
@@ -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,
|