@micha.bigler/ui-core-micha 2.6.1 → 2.7.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.
@@ -51,18 +51,22 @@ jobs:
51
51
  }
52
52
 
53
53
  const current = JSON.parse(fs.readFileSync('package.json', 'utf8')).version;
54
- let previous;
54
+ let published;
55
55
  try {
56
- previous = JSON.parse(
57
- cp.execFileSync('git', ['show', 'HEAD^:package.json'], { encoding: 'utf8' })
58
- ).version;
56
+ published = cp.execFileSync(
57
+ 'npm',
58
+ ['view', '@micha.bigler/ui-core-micha', 'version'],
59
+ { encoding: 'utf8' }
60
+ ).trim();
59
61
  } catch (error) {
60
- console.log('No previous package.json found. Publishing.');
62
+ console.log(
63
+ 'Could not fetch the published npm version (the package may not exist yet or the registry is unavailable). Publishing.'
64
+ );
61
65
  writeOutput('true');
62
66
  process.exit(0);
63
67
  }
64
68
 
65
- console.log(`Previous version: ${previous}`);
69
+ console.log(`Published version: ${published}`);
66
70
  console.log(`Current version: ${current}`);
67
71
 
68
72
  function parse(version) {
@@ -82,7 +86,7 @@ jobs:
82
86
  return false;
83
87
  }
84
88
 
85
- if (greaterThan(current, previous)) {
89
+ if (greaterThan(current, published)) {
86
90
  console.log('Version increased. Publishing.');
87
91
  writeOutput('true');
88
92
  } else {
@@ -69,6 +69,11 @@ export const AuthProvider = ({ children }) => {
69
69
  const login = (userData) => {
70
70
  setUser((prev) => (Object.assign(Object.assign({}, prev), mapUserFromApi(userData))));
71
71
  };
72
+ const refreshUser = async () => {
73
+ const data = await fetchCurrentUser();
74
+ if (data)
75
+ setUser(mapUserFromApi(data));
76
+ };
72
77
  const logout = async () => {
73
78
  try {
74
79
  await logoutSession();
@@ -87,5 +92,6 @@ export const AuthProvider = ({ children }) => {
87
92
  loading,
88
93
  login,
89
94
  logout,
95
+ refreshUser,
90
96
  }, children: [children, _jsx(ReauthModal, {})] }));
91
97
  };
@@ -1,10 +1,13 @@
1
1
  export const onboardingTranslations = {
2
2
  'Onboarding.SETUP': { de: 'Einrichtung', fr: 'Configuration', en: 'Setup', sw: 'Usanidi' },
3
3
  'Onboarding.STEP_COUNTER': { de: 'Schritt {{current}} von {{total}}', fr: 'Étape {{current}} sur {{total}}', en: 'Step {{current}} of {{total}}', sw: 'Hatua ya {{current}} kati ya {{total}}' },
4
- 'Onboarding.COOKIE_CONSENT_TITLE': { de: 'Cookie-Einstellungen', fr: 'Préférences des cookies', en: 'Cookie preferences', sw: 'Mapendeleo ya vidakuzi' },
5
- 'Onboarding.COOKIE_CONSENT_BODY': { de: 'Diese App verwendet optionale Cookies, um deine Einstellungen und Präferenzen zu speichern. Möchtest du diese aktivieren?', fr: 'Cette application utilise des cookies facultatifs pour enregistrer vos réglages et préférences. Souhaitez-vous les activer ?', en: 'This app uses optional cookies to save your settings and preferences. Would you like to enable them?', sw: 'Programu hii hutumia vidakuzi vya hiari kuhifadhi mipangilio na mapendeleo yako. Je, ungependa kuviwasha?' },
6
- 'Onboarding.COOKIE_CONSENT_ACCEPT': { de: 'Akzeptieren', fr: 'Accepter', en: 'Accept', sw: 'Kubali' },
7
- 'Onboarding.COOKIE_CONSENT_ERROR': { de: 'Die Cookie-Einstellung konnte nicht gespeichert werden. Bitte versuche es erneut.', fr: 'Impossible d’enregistrer votre préférence de cookies. Veuillez réessayer.', en: 'Your cookie preference could not be saved. Please try again.', sw: 'Upendeleo wako wa vidakuzi haukuweza kuhifadhiwa. Tafadhali jaribu tena.' },
4
+ 'Onboarding.PRIVACY_COOKIES_TITLE': { de: 'Datenschutz & Cookies', fr: 'Confidentialité et cookies', en: 'Privacy & cookies', sw: 'Faragha na vidakuzi' },
5
+ 'Onboarding.PRIVACY_COOKIES_BODY': { de: 'Bitte lies unsere Datenschutzerklärung und stimme ihr zu, um fortzufahren. Optionale Cookies helfen uns, deine Einstellungen und Präferenzen zu speichern.', fr: 'Veuillez lire et accepter notre politique de confidentialité pour continuer. Les cookies facultatifs nous aident à enregistrer vos réglages et préférences.', en: 'Please read and agree to our privacy statement to continue. Optional cookies help us save your settings and preferences.', sw: 'Tafadhali soma na ukubali taarifa yetu ya faragha ili kuendelea. Vidakuzi vya hiari hutusaidia kuhifadhi mipangilio na mapendeleo yako.' },
6
+ 'Onboarding.PRIVACY_AGREEMENT_LABEL': { de: 'Ich habe die Datenschutzerklärung gelesen und stimme ihr zu.', fr: 'J’ai lu et j’accepte la politique de confidentialité.', en: 'I have read and agree to the privacy statement.', sw: 'Nimesoma na ninakubali taarifa ya faragha.' },
7
+ 'Onboarding.COOKIES_OPT_IN_LABEL': { de: 'Optionale Cookies aktivieren, um meine Einstellungen und Präferenzen zu speichern.', fr: 'Activer les cookies facultatifs pour enregistrer mes réglages et préférences.', en: 'Enable optional cookies to save my settings and preferences.', sw: 'Washa vidakuzi vya hiari kuhifadhi mipangilio na mapendeleo yangu.' },
8
+ 'Onboarding.VIEW_STATEMENT': { de: 'Text anzeigen', fr: 'Afficher le texte', en: 'View text', sw: 'Angalia maandishi' },
9
+ 'Onboarding.CONTINUE': { de: 'Weiter', fr: 'Continuer', en: 'Continue', sw: 'Endelea' },
10
+ 'Onboarding.PRIVACY_COOKIES_ERROR': { de: 'Deine Angaben konnten nicht gespeichert werden. Bitte versuche es erneut.', fr: 'Vos préférences n’ont pas pu être enregistrées. Veuillez réessayer.', en: 'Your preferences could not be saved. Please try again.', sw: 'Mapendeleo yako hayakuweza kuhifadhiwa. Tafadhali jaribu tena.' },
8
11
  'Onboarding.COMPLETE_NAME_TITLE': { de: 'Dein Name', fr: 'Votre nom', en: 'Your name', sw: 'Jina lako' },
9
12
  'Onboarding.COMPLETE_NAME_BODY': { de: 'Bitte gib deinen Vor- und Nachnamen an, damit andere dich erkennen.', fr: 'Veuillez indiquer votre prénom et votre nom afin que les autres puissent vous reconnaître.', en: 'Please provide your first and last name so others can recognise you.', sw: 'Tafadhali weka jina lako la kwanza na la mwisho ili wengine wakutambue.' },
10
13
  'Onboarding.FIRST_NAME_LABEL': { de: 'Vorname', fr: 'Prénom', en: 'First name', sw: 'Jina la kwanza' },
@@ -9,11 +9,11 @@ import BrowserPushStep from './steps/BrowserPushStep';
9
9
  export const UNIVERSAL_STEP_DESCRIPTORS = [
10
10
  {
11
11
  id: 'cookie_consent',
12
- condition: (ctx) => Boolean(ctx.user && !ctx.user.accepted_convenience_cookies),
12
+ condition: (ctx) => Boolean(ctx.user && !ctx.user.accepted_privacy_statement),
13
13
  blocking: true,
14
14
  skipable: false,
15
15
  persistDismissed: false,
16
- titleKey: 'Onboarding.COOKIE_CONSENT_TITLE',
16
+ titleKey: 'Onboarding.PRIVACY_COOKIES_TITLE',
17
17
  Component: CookieConsentStep,
18
18
  },
19
19
  {
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useEffect, useRef, useState } from 'react';
2
+ import React, { useContext, useEffect, useRef, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import Box from '@mui/material/Box';
5
5
  import Dialog from '@mui/material/Dialog';
@@ -7,10 +7,12 @@ import DialogContent from '@mui/material/DialogContent';
7
7
  import DialogTitle from '@mui/material/DialogTitle';
8
8
  import LinearProgress from '@mui/material/LinearProgress';
9
9
  import Typography from '@mui/material/Typography';
10
+ import { AuthContext } from '../auth/AuthContext';
10
11
  import { useOnboarding } from './OnboardingProvider';
11
12
  export function OnboardingWizard() {
12
13
  const { t } = useTranslation();
13
14
  const onboarding = useOnboarding();
15
+ const authContext = useContext(AuthContext);
14
16
  const [sessionDismissed, setSessionDismissed] = useState(() => new Set());
15
17
  const totalRef = useRef(null);
16
18
  const [completed, setCompleted] = useState(0);
@@ -34,8 +36,12 @@ export function OnboardingWizard() {
34
36
  const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
35
37
  const StepComponent = currentStep.Component;
36
38
  const completeCurrentStep = () => {
39
+ var _a, _b;
37
40
  setSessionDismissed((previous) => new Set([...previous, currentStep.id]));
38
41
  setCompleted((count) => count + 1);
42
+ (_b = (_a = authContext === null || authContext === void 0 ? void 0 : authContext.refreshUser) === null || _a === void 0 ? void 0 : _a.call(authContext)) === null || _b === void 0 ? void 0 : _b.catch(() => {
43
+ // Best-effort refresh; the step itself already persisted successfully.
44
+ });
39
45
  };
40
46
  const dismissCurrentStep = () => {
41
47
  if (currentStep.persistDismissed)
@@ -4,28 +4,65 @@ import { useTranslation } from 'react-i18next';
4
4
  import Alert from '@mui/material/Alert';
5
5
  import Box from '@mui/material/Box';
6
6
  import Button from '@mui/material/Button';
7
+ import Checkbox from '@mui/material/Checkbox';
7
8
  import CircularProgress from '@mui/material/CircularProgress';
9
+ import FormControlLabel from '@mui/material/FormControlLabel';
10
+ import Link from '@mui/material/Link';
8
11
  import Typography from '@mui/material/Typography';
9
- import CookieIcon from '@mui/icons-material/Cookie';
10
- import { updateUserProfile } from '../../auth/authApi';
12
+ import PrivacyTipIcon from '@mui/icons-material/PrivacyTip';
13
+ import { fetchCookieStatement, fetchPrivacyStatement, updateUserProfile } from '../../auth/authApi';
11
14
  export function CookieConsentStep({ onComplete }) {
12
15
  const { t } = useTranslation();
16
+ const [privacyAccepted, setPrivacyAccepted] = useState(false);
17
+ const [cookiesAccepted, setCookiesAccepted] = useState(false);
18
+ const [privacyText, setPrivacyText] = useState('');
19
+ const [cookieText, setCookieText] = useState('');
20
+ const [showPrivacyText, setShowPrivacyText] = useState(false);
21
+ const [showCookieText, setShowCookieText] = useState(false);
13
22
  const [loading, setLoading] = useState(false);
14
23
  const [error, setError] = useState('');
15
- const accept = async () => {
24
+ const togglePrivacyText = async () => {
25
+ if (!showPrivacyText && !privacyText) {
26
+ try {
27
+ setPrivacyText(await fetchPrivacyStatement());
28
+ }
29
+ catch (_a) {
30
+ // Non-fatal: the link simply won't reveal inline text this time.
31
+ }
32
+ }
33
+ setShowPrivacyText((previous) => !previous);
34
+ };
35
+ const toggleCookieText = async () => {
36
+ if (!showCookieText && !cookieText) {
37
+ try {
38
+ setCookieText(await fetchCookieStatement());
39
+ }
40
+ catch (_a) {
41
+ // Non-fatal: the link simply won't reveal inline text this time.
42
+ }
43
+ }
44
+ setShowCookieText((previous) => !previous);
45
+ };
46
+ const submit = async (event) => {
47
+ event.preventDefault();
48
+ if (!privacyAccepted)
49
+ return;
16
50
  setLoading(true);
17
51
  setError('');
18
52
  try {
19
- await updateUserProfile({ accepted_convenience_cookies: true });
53
+ await updateUserProfile({
54
+ accepted_privacy_statement: true,
55
+ accepted_convenience_cookies: cookiesAccepted,
56
+ });
20
57
  onComplete();
21
58
  }
22
59
  catch (_a) {
23
- setError(t('Onboarding.COOKIE_CONSENT_ERROR'));
60
+ setError(t('Onboarding.PRIVACY_COOKIES_ERROR'));
24
61
  }
25
62
  finally {
26
63
  setLoading(false);
27
64
  }
28
65
  };
29
- return (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(CookieIcon, { color: "primary" }), _jsx(Typography, { variant: "h6", children: t('Onboarding.COOKIE_CONSENT_TITLE') })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Onboarding.COOKIE_CONSENT_BODY') }), error && _jsx(Alert, { severity: "error", onClose: () => setError(''), children: error }), _jsx(Box, { sx: { display: 'flex', justifyContent: 'flex-end' }, children: _jsx(Button, { variant: "contained", onClick: accept, disabled: loading, startIcon: loading ? _jsx(CircularProgress, { size: 16 }) : undefined, children: t('Onboarding.COOKIE_CONSENT_ACCEPT') }) })] }));
66
+ return (_jsxs(Box, { component: "form", onSubmit: submit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(PrivacyTipIcon, { color: "primary" }), _jsx(Typography, { variant: "h6", children: t('Onboarding.PRIVACY_COOKIES_TITLE') })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Onboarding.PRIVACY_COOKIES_BODY') }), error && _jsx(Alert, { severity: "error", onClose: () => setError(''), children: error }), _jsxs(Box, { children: [_jsx(FormControlLabel, { control: (_jsx(Checkbox, { checked: privacyAccepted, onChange: (event) => setPrivacyAccepted(event.target.checked), required: true })), label: t('Onboarding.PRIVACY_AGREEMENT_LABEL') }), _jsxs(Box, { children: [_jsx(Link, { component: "button", type: "button", variant: "body2", onClick: togglePrivacyText, children: t('Onboarding.VIEW_STATEMENT') }), showPrivacyText && privacyText && (_jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mt: 1, whiteSpace: 'pre-wrap' }, children: privacyText }))] })] }), _jsxs(Box, { children: [_jsx(FormControlLabel, { control: (_jsx(Checkbox, { checked: cookiesAccepted, onChange: (event) => setCookiesAccepted(event.target.checked) })), label: t('Onboarding.COOKIES_OPT_IN_LABEL') }), _jsxs(Box, { children: [_jsx(Link, { component: "button", type: "button", variant: "body2", onClick: toggleCookieText, children: t('Onboarding.VIEW_STATEMENT') }), showCookieText && cookieText && (_jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mt: 1, whiteSpace: 'pre-wrap' }, children: cookieText }))] })] }), _jsx(Box, { sx: { display: 'flex', justifyContent: 'flex-end' }, children: _jsx(Button, { variant: "contained", type: "submit", disabled: loading || !privacyAccepted, startIcon: loading ? _jsx(CircularProgress, { size: 16 }) : undefined, children: t('Onboarding.CONTINUE') }) })] }));
30
67
  }
31
68
  export default CookieConsentStep;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.6.1",
3
+ "version": "2.7.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -115,6 +115,11 @@ export const AuthProvider = ({ children }) => {
115
115
  }));
116
116
  };
117
117
 
118
+ const refreshUser = async () => {
119
+ const data = await fetchCurrentUser();
120
+ if (data) setUser(mapUserFromApi(data));
121
+ };
122
+
118
123
  const logout = async () => {
119
124
  try {
120
125
  await logoutSession();
@@ -134,6 +139,7 @@ export const AuthProvider = ({ children }) => {
134
139
  loading,
135
140
  login,
136
141
  logout,
142
+ refreshUser,
137
143
  }}
138
144
  >
139
145
  {children}
@@ -1,10 +1,13 @@
1
1
  export const onboardingTranslations = {
2
2
  'Onboarding.SETUP': { de: 'Einrichtung', fr: 'Configuration', en: 'Setup', sw: 'Usanidi' },
3
3
  'Onboarding.STEP_COUNTER': { de: 'Schritt {{current}} von {{total}}', fr: 'Étape {{current}} sur {{total}}', en: 'Step {{current}} of {{total}}', sw: 'Hatua ya {{current}} kati ya {{total}}' },
4
- 'Onboarding.COOKIE_CONSENT_TITLE': { de: 'Cookie-Einstellungen', fr: 'Préférences des cookies', en: 'Cookie preferences', sw: 'Mapendeleo ya vidakuzi' },
5
- 'Onboarding.COOKIE_CONSENT_BODY': { de: 'Diese App verwendet optionale Cookies, um deine Einstellungen und Präferenzen zu speichern. Möchtest du diese aktivieren?', fr: 'Cette application utilise des cookies facultatifs pour enregistrer vos réglages et préférences. Souhaitez-vous les activer ?', en: 'This app uses optional cookies to save your settings and preferences. Would you like to enable them?', sw: 'Programu hii hutumia vidakuzi vya hiari kuhifadhi mipangilio na mapendeleo yako. Je, ungependa kuviwasha?' },
6
- 'Onboarding.COOKIE_CONSENT_ACCEPT': { de: 'Akzeptieren', fr: 'Accepter', en: 'Accept', sw: 'Kubali' },
7
- 'Onboarding.COOKIE_CONSENT_ERROR': { de: 'Die Cookie-Einstellung konnte nicht gespeichert werden. Bitte versuche es erneut.', fr: 'Impossible d’enregistrer votre préférence de cookies. Veuillez réessayer.', en: 'Your cookie preference could not be saved. Please try again.', sw: 'Upendeleo wako wa vidakuzi haukuweza kuhifadhiwa. Tafadhali jaribu tena.' },
4
+ 'Onboarding.PRIVACY_COOKIES_TITLE': { de: 'Datenschutz & Cookies', fr: 'Confidentialité et cookies', en: 'Privacy & cookies', sw: 'Faragha na vidakuzi' },
5
+ 'Onboarding.PRIVACY_COOKIES_BODY': { de: 'Bitte lies unsere Datenschutzerklärung und stimme ihr zu, um fortzufahren. Optionale Cookies helfen uns, deine Einstellungen und Präferenzen zu speichern.', fr: 'Veuillez lire et accepter notre politique de confidentialité pour continuer. Les cookies facultatifs nous aident à enregistrer vos réglages et préférences.', en: 'Please read and agree to our privacy statement to continue. Optional cookies help us save your settings and preferences.', sw: 'Tafadhali soma na ukubali taarifa yetu ya faragha ili kuendelea. Vidakuzi vya hiari hutusaidia kuhifadhi mipangilio na mapendeleo yako.' },
6
+ 'Onboarding.PRIVACY_AGREEMENT_LABEL': { de: 'Ich habe die Datenschutzerklärung gelesen und stimme ihr zu.', fr: 'J’ai lu et j’accepte la politique de confidentialité.', en: 'I have read and agree to the privacy statement.', sw: 'Nimesoma na ninakubali taarifa ya faragha.' },
7
+ 'Onboarding.COOKIES_OPT_IN_LABEL': { de: 'Optionale Cookies aktivieren, um meine Einstellungen und Präferenzen zu speichern.', fr: 'Activer les cookies facultatifs pour enregistrer mes réglages et préférences.', en: 'Enable optional cookies to save my settings and preferences.', sw: 'Washa vidakuzi vya hiari kuhifadhi mipangilio na mapendeleo yangu.' },
8
+ 'Onboarding.VIEW_STATEMENT': { de: 'Text anzeigen', fr: 'Afficher le texte', en: 'View text', sw: 'Angalia maandishi' },
9
+ 'Onboarding.CONTINUE': { de: 'Weiter', fr: 'Continuer', en: 'Continue', sw: 'Endelea' },
10
+ 'Onboarding.PRIVACY_COOKIES_ERROR': { de: 'Deine Angaben konnten nicht gespeichert werden. Bitte versuche es erneut.', fr: 'Vos préférences n’ont pas pu être enregistrées. Veuillez réessayer.', en: 'Your preferences could not be saved. Please try again.', sw: 'Mapendeleo yako hayakuweza kuhifadhiwa. Tafadhali jaribu tena.' },
8
11
  'Onboarding.COMPLETE_NAME_TITLE': { de: 'Dein Name', fr: 'Votre nom', en: 'Your name', sw: 'Jina lako' },
9
12
  'Onboarding.COMPLETE_NAME_BODY': { de: 'Bitte gib deinen Vor- und Nachnamen an, damit andere dich erkennen.', fr: 'Veuillez indiquer votre prénom et votre nom afin que les autres puissent vous reconnaître.', en: 'Please provide your first and last name so others can recognise you.', sw: 'Tafadhali weka jina lako la kwanza na la mwisho ili wengine wakutambue.' },
10
13
  'Onboarding.FIRST_NAME_LABEL': { de: 'Vorname', fr: 'Prénom', en: 'First name', sw: 'Jina la kwanza' },
@@ -16,11 +16,11 @@ import BrowserPushStep from './steps/BrowserPushStep';
16
16
  export const UNIVERSAL_STEP_DESCRIPTORS = [
17
17
  {
18
18
  id: 'cookie_consent',
19
- condition: (ctx) => Boolean(ctx.user && !ctx.user.accepted_convenience_cookies),
19
+ condition: (ctx) => Boolean(ctx.user && !ctx.user.accepted_privacy_statement),
20
20
  blocking: true,
21
21
  skipable: false,
22
22
  persistDismissed: false,
23
- titleKey: 'Onboarding.COOKIE_CONSENT_TITLE',
23
+ titleKey: 'Onboarding.PRIVACY_COOKIES_TITLE',
24
24
  Component: CookieConsentStep,
25
25
  },
26
26
  {
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useRef, useState } from 'react';
1
+ import React, { useContext, useEffect, useRef, useState } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import Box from '@mui/material/Box';
4
4
  import Dialog from '@mui/material/Dialog';
@@ -6,11 +6,13 @@ import DialogContent from '@mui/material/DialogContent';
6
6
  import DialogTitle from '@mui/material/DialogTitle';
7
7
  import LinearProgress from '@mui/material/LinearProgress';
8
8
  import Typography from '@mui/material/Typography';
9
+ import { AuthContext } from '../auth/AuthContext';
9
10
  import { useOnboarding } from './OnboardingProvider';
10
11
 
11
12
  export function OnboardingWizard() {
12
13
  const { t } = useTranslation();
13
14
  const onboarding = useOnboarding();
15
+ const authContext = useContext(AuthContext);
14
16
  const [sessionDismissed, setSessionDismissed] = useState(() => new Set());
15
17
  const totalRef = useRef(null);
16
18
  const [completed, setCompleted] = useState(0);
@@ -39,6 +41,9 @@ export function OnboardingWizard() {
39
41
  const completeCurrentStep = () => {
40
42
  setSessionDismissed((previous) => new Set([...previous, currentStep.id]));
41
43
  setCompleted((count) => count + 1);
44
+ authContext?.refreshUser?.()?.catch(() => {
45
+ // Best-effort refresh; the step itself already persisted successfully.
46
+ });
42
47
  };
43
48
 
44
49
  const dismissCurrentStep = () => {
@@ -3,35 +3,129 @@ import { useTranslation } from 'react-i18next';
3
3
  import Alert from '@mui/material/Alert';
4
4
  import Box from '@mui/material/Box';
5
5
  import Button from '@mui/material/Button';
6
+ import Checkbox from '@mui/material/Checkbox';
6
7
  import CircularProgress from '@mui/material/CircularProgress';
8
+ import FormControlLabel from '@mui/material/FormControlLabel';
9
+ import Link from '@mui/material/Link';
7
10
  import Typography from '@mui/material/Typography';
8
- import CookieIcon from '@mui/icons-material/Cookie';
9
- import { updateUserProfile } from '../../auth/authApi';
11
+ import PrivacyTipIcon from '@mui/icons-material/PrivacyTip';
12
+ import { fetchCookieStatement, fetchPrivacyStatement, updateUserProfile } from '../../auth/authApi';
10
13
 
11
14
  export function CookieConsentStep({ onComplete }) {
12
15
  const { t } = useTranslation();
16
+ const [privacyAccepted, setPrivacyAccepted] = useState(false);
17
+ const [cookiesAccepted, setCookiesAccepted] = useState(false);
18
+ const [privacyText, setPrivacyText] = useState('');
19
+ const [cookieText, setCookieText] = useState('');
20
+ const [showPrivacyText, setShowPrivacyText] = useState(false);
21
+ const [showCookieText, setShowCookieText] = useState(false);
13
22
  const [loading, setLoading] = useState(false);
14
23
  const [error, setError] = useState('');
15
24
 
16
- const accept = async () => {
25
+ const togglePrivacyText = async () => {
26
+ if (!showPrivacyText && !privacyText) {
27
+ try {
28
+ setPrivacyText(await fetchPrivacyStatement());
29
+ } catch {
30
+ // Non-fatal: the link simply won't reveal inline text this time.
31
+ }
32
+ }
33
+ setShowPrivacyText((previous) => !previous);
34
+ };
35
+
36
+ const toggleCookieText = async () => {
37
+ if (!showCookieText && !cookieText) {
38
+ try {
39
+ setCookieText(await fetchCookieStatement());
40
+ } catch {
41
+ // Non-fatal: the link simply won't reveal inline text this time.
42
+ }
43
+ }
44
+ setShowCookieText((previous) => !previous);
45
+ };
46
+
47
+ const submit = async (event) => {
48
+ event.preventDefault();
49
+ if (!privacyAccepted) return;
17
50
  setLoading(true);
18
51
  setError('');
19
52
  try {
20
- await updateUserProfile({ accepted_convenience_cookies: true });
53
+ await updateUserProfile({
54
+ accepted_privacy_statement: true,
55
+ accepted_convenience_cookies: cookiesAccepted,
56
+ });
21
57
  onComplete();
22
58
  } catch {
23
- setError(t('Onboarding.COOKIE_CONSENT_ERROR'));
59
+ setError(t('Onboarding.PRIVACY_COOKIES_ERROR'));
24
60
  } finally {
25
61
  setLoading(false);
26
62
  }
27
63
  };
28
64
 
29
65
  return (
30
- <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
31
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><CookieIcon color="primary" /><Typography variant="h6">{t('Onboarding.COOKIE_CONSENT_TITLE')}</Typography></Box>
32
- <Typography variant="body2" color="text.secondary">{t('Onboarding.COOKIE_CONSENT_BODY')}</Typography>
66
+ <Box component="form" onSubmit={submit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
67
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
68
+ <PrivacyTipIcon color="primary" />
69
+ <Typography variant="h6">{t('Onboarding.PRIVACY_COOKIES_TITLE')}</Typography>
70
+ </Box>
71
+ <Typography variant="body2" color="text.secondary">{t('Onboarding.PRIVACY_COOKIES_BODY')}</Typography>
33
72
  {error && <Alert severity="error" onClose={() => setError('')}>{error}</Alert>}
34
- <Box sx={{ display: 'flex', justifyContent: 'flex-end' }}><Button variant="contained" onClick={accept} disabled={loading} startIcon={loading ? <CircularProgress size={16} /> : undefined}>{t('Onboarding.COOKIE_CONSENT_ACCEPT')}</Button></Box>
73
+
74
+ <Box>
75
+ <FormControlLabel
76
+ control={(
77
+ <Checkbox
78
+ checked={privacyAccepted}
79
+ onChange={(event) => setPrivacyAccepted(event.target.checked)}
80
+ required
81
+ />
82
+ )}
83
+ label={t('Onboarding.PRIVACY_AGREEMENT_LABEL')}
84
+ />
85
+ <Box>
86
+ <Link component="button" type="button" variant="body2" onClick={togglePrivacyText}>
87
+ {t('Onboarding.VIEW_STATEMENT')}
88
+ </Link>
89
+ {showPrivacyText && privacyText && (
90
+ <Typography variant="body2" color="text.secondary" sx={{ mt: 1, whiteSpace: 'pre-wrap' }}>
91
+ {privacyText}
92
+ </Typography>
93
+ )}
94
+ </Box>
95
+ </Box>
96
+
97
+ <Box>
98
+ <FormControlLabel
99
+ control={(
100
+ <Checkbox
101
+ checked={cookiesAccepted}
102
+ onChange={(event) => setCookiesAccepted(event.target.checked)}
103
+ />
104
+ )}
105
+ label={t('Onboarding.COOKIES_OPT_IN_LABEL')}
106
+ />
107
+ <Box>
108
+ <Link component="button" type="button" variant="body2" onClick={toggleCookieText}>
109
+ {t('Onboarding.VIEW_STATEMENT')}
110
+ </Link>
111
+ {showCookieText && cookieText && (
112
+ <Typography variant="body2" color="text.secondary" sx={{ mt: 1, whiteSpace: 'pre-wrap' }}>
113
+ {cookieText}
114
+ </Typography>
115
+ )}
116
+ </Box>
117
+ </Box>
118
+
119
+ <Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
120
+ <Button
121
+ variant="contained"
122
+ type="submit"
123
+ disabled={loading || !privacyAccepted}
124
+ startIcon={loading ? <CircularProgress size={16} /> : undefined}
125
+ >
126
+ {t('Onboarding.CONTINUE')}
127
+ </Button>
128
+ </Box>
35
129
  </Box>
36
130
  );
37
131
  }
@@ -0,0 +1,50 @@
1
+ // @vitest-environment jsdom
2
+ import React, { useContext } from 'react';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { render, screen, waitFor } from '@testing-library/react';
5
+
6
+ const apiClient = vi.hoisted(() => ({
7
+ ensureCsrfToken: vi.fn().mockResolvedValue(undefined),
8
+ }));
9
+ const authApi = vi.hoisted(() => ({
10
+ fetchAuthMethods: vi.fn().mockResolvedValue({}),
11
+ fetchCurrentUser: vi.fn(),
12
+ logoutSession: vi.fn(),
13
+ }));
14
+
15
+ vi.mock('../src/auth/apiClient', () => apiClient);
16
+ vi.mock('../src/auth/authApi', () => authApi);
17
+ vi.mock('../src/auth/ReauthModal', () => ({ ReauthModal: () => null }));
18
+
19
+ import { AuthContext, AuthProvider } from '../src/auth/AuthContext';
20
+
21
+ function Probe() {
22
+ const { user, refreshUser } = useContext(AuthContext);
23
+ return (
24
+ <div>
25
+ <span data-testid="first-name">{user?.first_name || ''}</span>
26
+ <button type="button" onClick={() => refreshUser()}>refresh</button>
27
+ </div>
28
+ );
29
+ }
30
+
31
+ describe('AuthContext refreshUser', () => {
32
+ beforeEach(() => {
33
+ vi.clearAllMocks();
34
+ });
35
+
36
+ it('re-fetches the current user and updates context state', async () => {
37
+ authApi.fetchCurrentUser
38
+ .mockResolvedValueOnce({ id: 1, first_name: 'Ada' })
39
+ .mockResolvedValueOnce({ id: 1, first_name: 'Grace' });
40
+
41
+ render(<AuthProvider><Probe /></AuthProvider>);
42
+
43
+ await waitFor(() => expect(screen.getByTestId('first-name').textContent).toBe('Ada'));
44
+
45
+ screen.getByRole('button', { name: 'refresh' }).click();
46
+
47
+ await waitFor(() => expect(screen.getByTestId('first-name').textContent).toBe('Grace'));
48
+ expect(authApi.fetchCurrentUser).toHaveBeenCalledTimes(2);
49
+ });
50
+ });
@@ -0,0 +1,69 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ const authApi = vi.hoisted(() => ({
7
+ fetchCookieStatement: vi.fn(),
8
+ fetchPrivacyStatement: vi.fn(),
9
+ updateUserProfile: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('../src/auth/authApi', () => authApi);
13
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) }));
14
+
15
+ import { CookieConsentStep } from '../src/onboarding/steps/CookieConsentStep';
16
+
17
+ describe('CookieConsentStep (combined privacy + cookies)', () => {
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ authApi.fetchPrivacyStatement.mockResolvedValue('privacy text');
21
+ authApi.fetchCookieStatement.mockResolvedValue('cookie text');
22
+ authApi.updateUserProfile.mockResolvedValue({});
23
+ });
24
+
25
+ afterEach(() => {
26
+ cleanup();
27
+ });
28
+
29
+ it('does not submit while the privacy checkbox is unchecked', async () => {
30
+ const onComplete = vi.fn();
31
+ render(<CookieConsentStep onComplete={onComplete} />);
32
+
33
+ const button = screen.getByRole('button', { name: 'Onboarding.CONTINUE' });
34
+ expect(button.disabled).toBe(true);
35
+ fireEvent.click(button);
36
+
37
+ await waitFor(() => expect(authApi.updateUserProfile).not.toHaveBeenCalled());
38
+ expect(onComplete).not.toHaveBeenCalled();
39
+ });
40
+
41
+ it('submits both fields with cookies unchecked (privacy only)', async () => {
42
+ const onComplete = vi.fn();
43
+ render(<CookieConsentStep onComplete={onComplete} />);
44
+
45
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Onboarding.PRIVACY_AGREEMENT_LABEL' }));
46
+ fireEvent.click(screen.getByRole('button', { name: 'Onboarding.CONTINUE' }));
47
+
48
+ await waitFor(() => expect(authApi.updateUserProfile).toHaveBeenCalledWith({
49
+ accepted_privacy_statement: true,
50
+ accepted_convenience_cookies: false,
51
+ }));
52
+ expect(onComplete).toHaveBeenCalled();
53
+ });
54
+
55
+ it('submits both fields with cookies checked', async () => {
56
+ const onComplete = vi.fn();
57
+ render(<CookieConsentStep onComplete={onComplete} />);
58
+
59
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Onboarding.PRIVACY_AGREEMENT_LABEL' }));
60
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Onboarding.COOKIES_OPT_IN_LABEL' }));
61
+ fireEvent.click(screen.getByRole('button', { name: 'Onboarding.CONTINUE' }));
62
+
63
+ await waitFor(() => expect(authApi.updateUserProfile).toHaveBeenCalledWith({
64
+ accepted_privacy_statement: true,
65
+ accepted_convenience_cookies: true,
66
+ }));
67
+ expect(onComplete).toHaveBeenCalled();
68
+ });
69
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { UNIVERSAL_STEP_DESCRIPTORS } from '../src/onboarding/OnboardingProvider';
3
+
4
+ const cookieConsentDescriptor = UNIVERSAL_STEP_DESCRIPTORS.find((step) => step.id === 'cookie_consent');
5
+
6
+ describe('cookie_consent descriptor condition', () => {
7
+ it('is active when the user has not accepted the privacy statement, regardless of cookies', () => {
8
+ expect(cookieConsentDescriptor.condition({
9
+ user: { accepted_privacy_statement: false, accepted_convenience_cookies: false },
10
+ })).toBe(true);
11
+ expect(cookieConsentDescriptor.condition({
12
+ user: { accepted_privacy_statement: false, accepted_convenience_cookies: true },
13
+ })).toBe(true);
14
+ });
15
+
16
+ it('is inactive once the privacy statement is accepted, even if cookies were declined', () => {
17
+ expect(cookieConsentDescriptor.condition({
18
+ user: { accepted_privacy_statement: true, accepted_convenience_cookies: false },
19
+ })).toBe(false);
20
+ });
21
+
22
+ it('is inactive with no user', () => {
23
+ expect(cookieConsentDescriptor.condition({ user: null })).toBe(false);
24
+ });
25
+ });