From c190e31ab9d9fc1f0e8c60edc2d55062a8b4dfb1 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 28 May 2026 08:37:16 -0400 Subject: [PATCH 1/8] updated theme for cleaner consistent look --- src/app/Theme.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/app/Theme.ts b/src/app/Theme.ts index c080ab2a..049d8247 100644 --- a/src/app/Theme.ts +++ b/src/app/Theme.ts @@ -225,17 +225,10 @@ export const theme = createTheme({ border: '2px solid var(--mui-palette-primary-main)', color: 'var(--mui-palette-primary-main)', }, - '&.MuiButton-outlinedPrimary': { - border: '2px solid var(--mui-palette-primary-main)', + '&.MuiButton-outlined': { + borderWidth: '2px', padding: '6px 16px', }, - '&.MuiButton-outlinedPrimary:hover': { - backgroundColor: 'var(--mui-palette-primary-main)', - color: 'var(--mui-palette-primary-contrastText)', - ...theme.applyStyles('dark', { - color: 'var(--mui-palette-background-default)', - }), - }, '&.MuiButton-text.inline': { fontFamily: fontFamily.primary, fontSize: 'inherit', From d158c269cde6b95efca17ab111f35a85c8fa22f5 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 28 May 2026 09:26:46 -0400 Subject: [PATCH 2/8] Subscription button on feed detail page --- src/app/[locale]/sign-in/SignIn.tsx | 9 +- src/app/screens/Feed/FeedView.tsx | 3 +- .../components/ClientSubscribeControls.tsx | 178 +++++++++++++++++ .../components/NotificationSettingsDialog.tsx | 189 ++++++++++++++++++ 4 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 src/app/screens/Feed/components/ClientSubscribeControls.tsx create mode 100644 src/app/screens/Feed/components/NotificationSettingsDialog.tsx diff --git a/src/app/[locale]/sign-in/SignIn.tsx b/src/app/[locale]/sign-in/SignIn.tsx index 59766467..5f285343 100644 --- a/src/app/[locale]/sign-in/SignIn.tsx +++ b/src/app/[locale]/sign-in/SignIn.tsx @@ -105,7 +105,14 @@ export default function SignIn(): React.ReactElement { React.useEffect(() => { if (userProfileStatus === 'registered') { - if (searchParams.has('add_feed')) { + const redirectTo = searchParams.get('redirect_to'); + if ( + redirectTo != null && + redirectTo.startsWith('/') && + !redirectTo.startsWith('//') + ) { + router.push(redirectTo); + } else if (searchParams.has('add_feed')) { router.push(ADD_FEED_TARGET); } else { router.push(ACCOUNT_TARGET); diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index e653de0d..202d66d8 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -27,6 +27,7 @@ import ClientDownloadButton from './components/ClientDownloadButton'; import RevalidateCacheButton from './components/RevalidateCacheButton'; import { type components } from '../../services/feeds/types'; import ClientQualityReportButton from './components/ClientQualityReportButton'; +import ClientSubscribeControls from './components/ClientSubscribeControls'; import { getBoundingBox } from './Feed.functions'; import dynamic from 'next/dynamic'; import { ContentBox } from '../../components/ContentBox'; @@ -114,7 +115,6 @@ export default async function FeedView({ + + + {/* Unauthenticated sign-in nudge */} + { + setPopoverAnchor(null); + }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + transformOrigin={{ vertical: 'top', horizontal: 'left' }} + > + + + Want to be notified of changes? + + + Sign in to subscribe to this feed. + + + + + + { + setMenuAnchor(null); + }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + transformOrigin={{ vertical: 'top', horizontal: 'left' }} + > + { + setMenuAnchor(null); + setSettingsOpen(true); + }} + > + Notification Settings + + + Unsubscribe + + + + { + setSettingsOpen(false); + }} + onSave={(newSettings) => { + setSettings(newSettings); + setSettingsOpen(false); + }} + initialSettings={settings} + /> + + { + setSnackbarMessage(''); + }} + > + { + setSnackbarMessage(''); + }} + severity={isSubscribed ? 'success' : 'info'} + sx={{ width: '100%' }} + > + {snackbarMessage} + + + + ); +} diff --git a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx new file mode 100644 index 00000000..5cb42024 --- /dev/null +++ b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx @@ -0,0 +1,189 @@ +'use client'; + +// This component is subject to change based on the actual notification settings we want to offer and the APIs available to save them. For now it's a mockup of what the UI could look like. + +import { useState, useEffect } from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import FormLabel from '@mui/material/FormLabel'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import Slider from '@mui/material/Slider'; +import Typography from '@mui/material/Typography'; + +export interface NotificationSettings { + threshold: number; + frequency: 'onChange' | 'weekly' | 'monthly' | 'quarterly'; + changeTypes: string[]; +} + +export const defaultNotificationSettings: NotificationSettings = { + threshold: 55, + frequency: 'onChange', + changeTypes: ['any', 'features', 'expiry', 'validation'], +}; + +const CHANGE_TYPE_OPTIONS = [ + { value: 'any', label: 'All Changes' }, + { value: 'features', label: 'Features Changes' }, + { value: 'expiry', label: '7 Days Before Expiry' }, + { value: 'validation', label: 'New Validation Errors' }, +] as const; + +const SPECIFIC_TYPES = ['features', 'expiry', 'validation']; + +interface Props { + open: boolean; + onClose: () => void; + onSave: (settings: NotificationSettings) => void; + initialSettings: NotificationSettings; +} + +export default function NotificationSettingsDialog({ + open, + onClose, + onSave, + initialSettings, +}: Props): React.ReactElement { + const [threshold, setThreshold] = useState(initialSettings.threshold); + const [frequency, setFrequency] = useState(initialSettings.frequency); + const [changeTypes, setChangeTypes] = useState( + initialSettings.changeTypes, + ); + + // Reset to saved settings each time the dialog opens + useEffect(() => { + if (open) { + setThreshold(initialSettings.threshold); + setFrequency(initialSettings.frequency); + setChangeTypes(initialSettings.changeTypes); + } + }, [open, initialSettings]); + + const handleChangeTypeToggle = (value: string): void => { + if (value === 'any') { + setChangeTypes( + changeTypes.includes('any') ? [] : ['any', ...SPECIFIC_TYPES], + ); + } else { + if (changeTypes.includes(value)) { + // Remove the type and "any" (partial selection invalidates "any") + setChangeTypes(changeTypes.filter((t) => t !== value && t !== 'any')); + } else { + const withNew = changeTypes.filter((t) => t !== 'any').concat(value); + // Auto-select "any" when all specific types are checked + const allSpecificSelected = SPECIFIC_TYPES.every((t) => + withNew.includes(t), + ); + setChangeTypes(allSpecificSelected ? ['any', ...withNew] : withNew); + } + } + }; + + return ( + + Notification Settings + + + + {/* Difference threshold */} + + + Difference Threshold: {threshold}% + + + Notifications trigger based on how much the feed has changed. 1% + means any single change, 100% means the entire feed would need to + change. + + { + setThreshold(value as number); + }} + min={1} + max={100} + valueLabelDisplay='auto' + valueLabelFormat={(v) => `${v}%`} + /> + + + Any small change + + + Major changes only + + + + + {/* Frequency */} + + + Frequency of Notification + + + + + {/* Type of changes */} + + + Type of Changes + + + {CHANGE_TYPE_OPTIONS.map(({ value, label }) => ( + { + handleChangeTypeToggle(value); + }} + /> + } + label={label} + /> + ))} + + + + + + + + + + + ); +} From 9192b486f19061b2e757e9c15626ce7cfa1629c0 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 28 May 2026 09:27:26 -0400 Subject: [PATCH 3/8] notification feature flag --- src/app/interface/RemoteConfig.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/interface/RemoteConfig.ts b/src/app/interface/RemoteConfig.ts index a9b8631c..36d4ea87 100644 --- a/src/app/interface/RemoteConfig.ts +++ b/src/app/interface/RemoteConfig.ts @@ -28,6 +28,8 @@ export interface RemoteConfigValues { enableDetailedCoveredArea: boolean; gbfsValidator: boolean; gtfsFeatureTracker: boolean; + /** Enable feed subscription / notifications UI */ + isNotificationsEnabled: boolean; } const gbfsVersionsDefault: GbfsVersionConfig = []; @@ -48,6 +50,7 @@ export const defaultRemoteConfigValues: RemoteConfigValues = { enableDetailedCoveredArea: false, gbfsValidator: false, gtfsFeatureTracker: false, + isNotificationsEnabled: false, }; /** From dfa64d45e0c563e7ade482992ef6b9080b62b68c Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 28 May 2026 09:28:27 -0400 Subject: [PATCH 4/8] header account menu items to include account sections --- src/app/components/Header.style.ts | 1 + src/app/components/Header.tsx | 52 ++++++++++++----------- src/app/components/HeaderMobileDrawer.tsx | 23 ++++++++-- 3 files changed, 47 insertions(+), 29 deletions(-) diff --git a/src/app/components/Header.style.ts b/src/app/components/Header.style.ts index 443925a5..2de2448d 100644 --- a/src/app/components/Header.style.ts +++ b/src/app/components/Header.style.ts @@ -77,6 +77,7 @@ export const HeaderMenuItem = styled(MenuItem)<{ fontFamily: fontFamily.secondary, opacity: 0.8, fontWeight: 500, + fontSize: '0.875rem', '&:hover': { opacity: 1, }, diff --git a/src/app/components/Header.tsx b/src/app/components/Header.tsx index eac8e56c..9062ed56 100644 --- a/src/app/components/Header.tsx +++ b/src/app/components/Header.tsx @@ -373,12 +373,7 @@ export default function DrawerAppBar(): React.ReactElement { {config.gbfsValidator ? ( - { - setToolsAnchorEl(null); - handleNavigation('/gbfs-validator'); - }} - > + {tCommon('gbfsValidator')} ) : ( @@ -411,7 +406,7 @@ export default function DrawerAppBar(): React.ReactElement { {tCommon('gtfsFeatureTracker')} @@ -442,11 +437,8 @@ export default function DrawerAppBar(): React.ReactElement { {gtfsMetricsNavItems.map((item) => ( { - setToolsAnchorEl(null); - }} > {item.title} @@ -457,11 +449,8 @@ export default function DrawerAppBar(): React.ReactElement { {gbfsMetricsNavItems.map((item) => ( { - setToolsAnchorEl(null); - }} > {item.title} @@ -512,7 +501,10 @@ export default function DrawerAppBar(): React.ReactElement { }} disableScrollLock disableRestoreFocus - sx={{ pointerEvents: 'none' }} + sx={{ + pointerEvents: 'none', + fontFamily: fontFamily.secondary, + }} slotProps={{ paper: { onMouseEnter: () => { @@ -523,23 +515,33 @@ export default function DrawerAppBar(): React.ReactElement { }, }} > - { - setAccountAnchorEl(null); - handleNavigation(navigationAccountItem); - }} + component={Link} + href='/account' > {tCommon('accountDetails')} - - + + API Access + + {config.isNotificationsEnabled && ( + + Notifications + + )} + { setAccountAnchorEl(null); handleLogoutClick(); }} > - {tCommon('signOut')} - + Sign Out + ) : ( diff --git a/src/app/components/HeaderMobileDrawer.tsx b/src/app/components/HeaderMobileDrawer.tsx index c2f06031..81b700a6 100644 --- a/src/app/components/HeaderMobileDrawer.tsx +++ b/src/app/components/HeaderMobileDrawer.tsx @@ -139,7 +139,7 @@ export default function DrawerContent({ - + {navigationItems.map((item) => ( + {config.isNotificationsEnabled && ( + + )} @@ -359,6 +370,10 @@ export default function DrawerContent({ +

+
); From 8e86c67a15c02324df2800f3ac2dd80237ae8904 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 28 May 2026 09:36:39 -0400 Subject: [PATCH 5/8] New account page including notification settings --- messages/en.json | 6 +- messages/fr.json | 6 +- src/app/[locale]/account/Account.tsx | 829 ++++++++++-------- src/app/[locale]/account/AccountGeneral.tsx | 113 +++ .../account/AccountSectionContainer.tsx | 62 ++ .../account/api-access/AccountApiAccess.tsx | 582 ++++++++++++ src/app/[locale]/account/api-access/page.tsx | 6 + src/app/[locale]/account/layout.tsx | 237 +++++ .../notifications/AccountNotifications.tsx | 364 ++++++++ .../[locale]/account/notifications/page.tsx | 6 + src/app/[locale]/account/page.tsx | 12 +- 11 files changed, 1834 insertions(+), 389 deletions(-) create mode 100644 src/app/[locale]/account/AccountGeneral.tsx create mode 100644 src/app/[locale]/account/AccountSectionContainer.tsx create mode 100644 src/app/[locale]/account/api-access/AccountApiAccess.tsx create mode 100644 src/app/[locale]/account/api-access/page.tsx create mode 100644 src/app/[locale]/account/layout.tsx create mode 100644 src/app/[locale]/account/notifications/AccountNotifications.tsx create mode 100644 src/app/[locale]/account/notifications/page.tsx diff --git a/messages/en.json b/messages/en.json index 2c759f02..c39750ba 100644 --- a/messages/en.json +++ b/messages/en.json @@ -248,6 +248,10 @@ "officialFeedTooltipShort": "Verified feed: Confirmed by the transit provider or the Mobility Database team for rider use.", "seeDetailPageProviders": "See detail page to view {providersCount} others", "openFullQualityReport": "Open Full Quality Report", + "subscribe": "Subscribe to get feed update notifications", + "unsubscribe": "Unsubscribe to stop receiving feed update notifications", + "subscribedToFeed": "You'll receive updates for this feed", + "unsubscribedFromFeed": "You've been unsubscribed and will no longer receive updates for this feed.", "qualityReportUpdated": "Quality report updated", "officialFeedUpdated": "Official verification updated", "serviceDateRange": "Service Date Range", @@ -411,7 +415,7 @@ } }, "account": { - "title": "Your API Account", + "title": "Your Account", "userDetails": "User Details", "description": "The Mobility Database API uses OAuth2 authentication. To initiate a successful API request, an access token must be included as a bearer token in the HTTP header. Access tokens are valid for one hour. To obtain an access token, you'll first need a refresh token, which is long-lived and does not expire.", "support": "If you need a reissued refresh token or want your account removed, send us an email at", diff --git a/messages/fr.json b/messages/fr.json index 231e3ba5..c6ea5b69 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -248,6 +248,10 @@ "officialFeedTooltipShort": "Verified feed: Confirmed by the transit provider or the Mobility Database team for rider use.", "seeDetailPageProviders": "See detail page to view {providersCount} others", "openFullQualityReport": "Open Full Quality Report", + "subscribe": "S'abonner", + "unsubscribe": "Se désabonner", + "subscribedToFeed": "Vous êtes abonné à ce flux", + "unsubscribedFromFeed": "Vous vous êtes désabonné de ce flux", "qualityReportUpdated": "Quality report updated", "officialFeedUpdated": "Official verification updated", "serviceDateRange": "Service Date Range", @@ -411,7 +415,7 @@ } }, "account": { - "title": "Votre compte API", + "title": "Votre compte", "userDetails": "Détails de l'utilisateur", "description": "L'API de la base de données de mobilité utilise l'authentification OAuth2. Pour initier une demande API réussie, un jeton d'accès doit être inclus en tant que jeton porteur dans l'en-tête HTTP. Les jetons d'accès sont valides pendant une heure. Pour obtenir un jeton d'accès, vous aurez d'abord besoin d'un jeton de rafraîchissement, qui est de longue durée et n'expire pas.", "support": "Si vous avez besoin d'un jeton de rafraîchissement réémis ou si vous souhaitez que votre compte soit supprimé, envoyez-nous un courriel à", diff --git a/src/app/[locale]/account/Account.tsx b/src/app/[locale]/account/Account.tsx index 97e4d8a1..d72e5ce9 100644 --- a/src/app/[locale]/account/Account.tsx +++ b/src/app/[locale]/account/Account.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter } from '../../../i18n/navigation'; import { useTheme } from '@mui/material/styles'; import { Typography, @@ -16,9 +16,12 @@ import { CircularProgress, Snackbar, Chip, + List, + ListItemButton, + ListItemIcon, + ListItemText, } from '@mui/material'; import { - AccountCircleOutlined, Check, ContentCopyOutlined, ExitToAppOutlined, @@ -26,6 +29,9 @@ import { VisibilityOffOutlined, VisibilityOutlined, WarningAmberOutlined, + DashboardOutlined, + VpnKeyOutlined, + NotificationsOutlined, } from '@mui/icons-material'; import { useSelector } from 'react-redux'; import { @@ -49,6 +55,32 @@ import { tokenActionButtonsSx, tokenDisplayElementSx, } from './Account.styles'; +import AccountNotifications from './notifications/AccountNotifications'; +import { AccountSectionContainer } from './AccountSectionContainer'; + +type NavSection = 'general' | 'api-access' | 'notifications'; + +const NAV_ITEMS: Array<{ + id: NavSection; + label: string; + icon: React.ReactElement; +}> = [ + { + id: 'general', + label: 'General', + icon: , + }, + { + id: 'api-access', + label: 'API Access', + icon: , + }, + { + id: 'notifications', + label: 'Notifications', + icon: , + }, +]; interface APIAccountState { showRefreshToken: boolean; @@ -62,7 +94,17 @@ enum TokenTypes { Refresh = 'refreshToken', } -export default function APIAccount(): React.ReactElement { +const NAV_ROUTES: Record = { + general: '/account', + notifications: '/account/notifications', + 'api-access': '/account/api-access', +}; + +export default function APIAccount({ + section = 'general', +}: { + section?: NavSection; +}): React.ReactElement { const t = useTranslations('account'); const tCommon = useTranslations('common'); const apiURL = 'https://api.mobilitydatabase.org/v1'; @@ -71,6 +113,8 @@ export default function APIAccount(): React.ReactElement { const user = useSelector(selectUserProfile); const router = useRouter(); + const [selectedNav, setSelectedNav] = React.useState(section); + const texts = { accessTokenHidden: t('accessToken.hidden'), refreshTokenHidden: t('refreshToken.placeholder'), @@ -324,329 +368,150 @@ export default function APIAccount(): React.ReactElement { }} > - - - {t('userDetails')} - - - {tCommon('name')}: - {' ' + (user?.fullName ?? tCommon('unknown'))} - - - {user?.email !== undefined && user?.email !== '' ? ( - - {tCommon('email')}:{' '} - {' ' + (user?.email ?? tCommon('unknown'))} - - ) : null} - - {user?.organization !== undefined && ( - - {tCommon('organization')}: {' ' + user?.organization} - - )} - {user?.isRegisteredToReceiveAPIAnnouncements === true ? ( - } - /> - ) : null} - - {t('support') + ' '} - - api@mobilitydata.org - - . - - - {!signedInWithProvider && ( - - )} - - + {item.icon} + + + ))} +
- - - {t('description')} - - {t('refreshToken.title')} - - - {t('refreshToken.description')} - - - - {showRefreshTokenCopied - ? refreshTokenCopyResult - : accountState.showRefreshToken - ? user?.refreshToken !== undefined - ? user?.refreshToken - : texts.tokenUnavailable - : texts.refreshTokenHidden} + + {selectedNav === 'general' && ( + + + + {tCommon('name')}: + {' ' + (user?.fullName ?? tCommon('unknown'))} - - + {user?.email !== undefined && user?.email !== '' ? ( + + {tCommon('email')}:{' '} + {' ' + (user?.email ?? tCommon('unknown'))} + + ) : null} + + {user?.organization !== undefined && ( + + {tCommon('organization')}: {' ' + user?.organization} + + )} + {user?.isRegisteredToReceiveAPIAnnouncements === true ? ( + } + /> + ) : null} + + {t('support') + ' '} + - - { - if (user?.refreshToken != null) { - handleCopyTokenToClipboard( - user.refreshToken, - setRefreshTokenCopyResult, - setShowRefreshTokenCopied, - ); - } - }} - sx={{ - display: 'inline-block', - verticalAlign: 'middle', - }} - > - - - - - - { - handleClickShowToken(TokenTypes.Refresh); - }} - edge='end' - sx={{ display: 'inline-block', verticalAlign: 'middle' }} - > - {accountState.showRefreshToken ? ( - - ) : ( - - )} - - - - - - - - {t('accessToken.title')} - - - {t('accessToken.description.pt1') + ' '} - - {t('accessToken.description.cta')} - {' '} - {t('accessToken.description.pt2')} - - + api@mobilitydata.org + + . + - - - {t('accessToken.generate')} - - - {t('accessToken.copy')} - - - { - handleCopyCodeBlock(getCurlAccessTokenCommand()); - }} - sx={{ display: 'inline-block', verticalAlign: 'middle' }} + {!signedInWithProvider && ( + + )} + - )} + + )} + + {selectedNav === 'notifications' && } - {!showGenerateAccessTokenButton && ( - + {selectedNav === 'api-access' && ( + + + {t('description')} + + {t('refreshToken.description')} + - {accountState.showAccessToken - ? user?.accessToken !== undefined - ? user?.accessToken - : texts.tokenUnavailable - : texts.accessTokenHidden} + {showRefreshTokenCopied + ? refreshTokenCopyResult + : accountState.showRefreshToken + ? user?.refreshToken !== undefined + ? user?.refreshToken + : texts.tokenUnavailable + : texts.refreshTokenHidden} - - - - {isRefreshingAccessToken ? ( - - ) : ( - - )} - - - - { - if (user?.accessToken != null) { + if (user?.refreshToken != null) { handleCopyTokenToClipboard( - user.accessToken, - setAccessTokenCopyResult, - setShowAccessTokenCopiedTooltip, + user.refreshToken, + setRefreshTokenCopyResult, + setShowRefreshTokenCopied, ); } }} @@ -655,115 +520,325 @@ export default function APIAccount(): React.ReactElement { verticalAlign: 'middle', }} > - + - + { - handleClickShowToken(TokenTypes.Access); + handleClickShowToken(TokenTypes.Refresh); }} + edge='end' sx={{ display: 'inline-block', verticalAlign: 'middle', }} > - {accountState.showAccessToken ? ( - + {accountState.showRefreshToken ? ( + ) : ( - + )} - - - {accountState.tokenExpired - ? t('accessToken.expired') - : t('accessToken.willExpireIn', { - timeLeftForTokenExpiration, - })} - . - - - )} - - + - - - {t('accessToken.testing.api')} + + {t('accessToken.description.pt1') + ' '} + + {t('accessToken.description.cta')} + {' '} + {t('accessToken.description.pt2')} + + + + + + {t('accessToken.generate')} + + + {t('accessToken.copy')} + + + { + handleCopyCodeBlock(getCurlAccessTokenCommand()); + }} + sx={{ + display: 'inline-block', + verticalAlign: 'middle', + }} + > + + + + + + curl{' '} + --location '{apiURL}/tokens' + + \ + +
+ + --header + {' '} + 'Content-Type: application/json' + + \ + +
+ + --data '{ + + + {' '} + "refresh_token": "[ + {t('refreshToken.yourToken')}]" + + + {' '} + }' +
+
+
+ + + {t('accessToken.testing.description.pt1') + ' '} + + {t('accessToken.testing.description.cta')} + + {t('accessToken.testing.description.pt2')} + + {showGenerateAccessTokenButton && ( + + + + )} - {t('accessToken.testing.copyCli')} -
- - { - handleCopyCodeBlock(getCurlApiTestCommand()); + {!showGenerateAccessTokenButton && ( + + + + {accountState.showAccessToken + ? user?.accessToken !== undefined + ? user?.accessToken + : texts.tokenUnavailable + : texts.accessTokenHidden} + + + + + + {isRefreshingAccessToken ? ( + + ) : ( + + )} + + + + + + + { + if (user?.accessToken != null) { + handleCopyTokenToClipboard( + user.accessToken, + setAccessTokenCopyResult, + setShowAccessTokenCopiedTooltip, + ); + } + }} + sx={{ + display: 'inline-block', + verticalAlign: 'middle', + }} + > + + + + + + { + handleClickShowToken(TokenTypes.Access); + }} + sx={{ + display: 'inline-block', + verticalAlign: 'middle', + }} + > + {accountState.showAccessToken ? ( + + ) : ( + + )} + + + + + + + {accountState.tokenExpired + ? t('accessToken.expired') + : t('accessToken.willExpireIn', { + timeLeftForTokenExpiration, + })} + . + + + )} + + - - - -
- - - curl - {' '} - --location '{apiURL}/metadata' - - \ - -
- - --header - {' '} - 'Accept: application/json' - - \ - -
- - --header{' '} - - 'Authorization: Bearer [{t('accessToken.yourToken')}]' -
-
-
+ + + {t('accessToken.testing.api')} + + + + {t('accessToken.testing.copyCli')} + + + + { + handleCopyCodeBlock(getCurlApiTestCommand()); + }} + sx={{ + display: 'inline-block', + verticalAlign: 'middle', + }} + > + + + + + + + curl + {' '} + --location '{apiURL}/metadata' + + \ + +
+ + --header + {' '} + 'Accept: application/json' + + \ + +
+ + --header{' '} + + 'Authorization: Bearer [{t('accessToken.yourToken')} + ]' +
+ + + + )} + {/* Edit action to be enabled when we have the user profile API */} + + // Edit + // + // } + > + + + + {user?.email !== undefined && user?.email !== '' ? ( + + ) : null} + {user?.organization !== undefined ? ( + + ) : null} + {user?.isRegisteredToReceiveAPIAnnouncements === true ? ( + } + /> + ) : null} + + + + + {t('support') + ' '} + + api@mobilitydata.org + + . + + + {!signedInWithProvider && ( + + )} + + + + ); +} diff --git a/src/app/[locale]/account/AccountSectionContainer.tsx b/src/app/[locale]/account/AccountSectionContainer.tsx new file mode 100644 index 00000000..6620f7a2 --- /dev/null +++ b/src/app/[locale]/account/AccountSectionContainer.tsx @@ -0,0 +1,62 @@ +import { Box, type SxProps, Typography } from '@mui/material'; + +export interface AssociatedFeedsProps { + title?: string; + subtitle?: string; + sx?: SxProps; +} + +export function AccountSectionContainer({ + title, + subtitle, + action, + children, + sx, +}: { + title?: string; + subtitle?: string; + action?: React.ReactNode; + children: React.ReactNode; + sx?: SxProps; +}): React.ReactElement { + return ( + + {(title != null || action != null) && ( + + + {title != null && ( + + {title} + + )} + {subtitle != null && ( + + {subtitle} + + )} + + {action != null && {action}} + + )} + {children} + + ); +} diff --git a/src/app/[locale]/account/api-access/AccountApiAccess.tsx b/src/app/[locale]/account/api-access/AccountApiAccess.tsx new file mode 100644 index 00000000..231dad3b --- /dev/null +++ b/src/app/[locale]/account/api-access/AccountApiAccess.tsx @@ -0,0 +1,582 @@ +'use client'; + +import * as React from 'react'; +import { useTheme } from '@mui/material/styles'; +import { + Alert, + Box, + Button, + CircularProgress, + IconButton, + Link, + Paper, + Snackbar, + Tooltip, + Typography, +} from '@mui/material'; +import { + ContentCopyOutlined, + RefreshOutlined, + VisibilityOffOutlined, + VisibilityOutlined, + WarningAmberOutlined, +} from '@mui/icons-material'; +import { useSelector } from 'react-redux'; +import { + selectIsRefreshingAccessToken, + selectIsTokenRefreshed, + selectRefreshingAccessTokenError, + selectUserProfile, +} from '../../../store/selectors'; +import { useAppDispatch } from '../../../hooks'; +import { requestRefreshAccessToken } from '../../../store/profile-reducer'; +import { + formatTokenExpiration, + getTimeLeftForTokenExpiration, +} from '../../../utils/date'; +import { useTranslations } from 'next-intl'; +import { + codeBlockContentSx, + codeBlockSx, + tokenActionButtonsSx, + tokenDisplayElementSx, +} from '../Account.styles'; +import { AccountSectionContainer } from '../AccountSectionContainer'; + +interface APIAccountState { + showRefreshToken: boolean; + showAccessToken: boolean; + codeBlockTooltip: string; + tokenExpired: boolean; +} + +enum TokenTypes { + Access = 'accessToken', + Refresh = 'refreshToken', +} + +export default function AccountApiAccess(): React.ReactElement { + const t = useTranslations('account'); + const tCommon = useTranslations('common'); + const apiURL = 'https://api.mobilitydatabase.org/v1'; + const dispatch = useAppDispatch(); + const theme = useTheme(); + const user = useSelector(selectUserProfile); + + const texts = { + accessTokenHidden: t('accessToken.hidden'), + refreshTokenHidden: t('refreshToken.placeholder'), + copyAccessToken: t('accessToken.copyToken'), + copyAccessTokenToClipboard: t('accessToken.copyTokenToClipboard'), + copyRefreshToken: t('refreshToken.copy'), + copyRefreshTokenToClipboard: t('refreshToken.copyToClipboard'), + copyToClipboard: tCommon('copyToClipboard'), + copied: tCommon('copied'), + tokenUnavailable: t('accessToken.unavailable'), + }; + + const refreshingAccessTokenError = useSelector( + selectRefreshingAccessTokenError, + ); + const isRefreshingAccessToken = useSelector(selectIsRefreshingAccessToken); + const isAccessTokenRefreshed = useSelector(selectIsTokenRefreshed); + + const [accountState, setAccountState] = React.useState({ + showRefreshToken: false, + showAccessToken: false, + codeBlockTooltip: texts.copyToClipboard, + tokenExpired: false, + }); + const [refreshTokenSuccess, setRefreshTokenSuccess] = + React.useState(false); + const [timeLeftForTokenExpiration, setTimeLeftForTokenExpiration] = + React.useState(''); + const [showAccessTokenCopiedTooltip, setShowAccessTokenCopiedTooltip] = + React.useState(false); + const [showAccessTokenSnackbar, setShowAccessTokenSnackbar] = + React.useState(false); + const [accessTokenCopyResult, setAccessTokenCopyResult] = + React.useState(''); + const [showRefreshTokenCopied, setShowRefreshTokenCopied] = + React.useState(false); + const [refreshTokenCopyResult, setRefreshTokenCopyResult] = + React.useState(''); + + const showGenerateAccessTokenButton = React.useMemo(() => { + return user?.accessToken == null; + }, [user?.accessToken]); + + React.useEffect(() => { + let interval: NodeJS.Timeout | null = null; + const accessTokenExpirationTime = user?.accessTokenExpirationTime; + if (accessTokenExpirationTime !== undefined) { + interval = setInterval(() => { + const expirationTime = getTimeLeftForTokenExpiration( + accessTokenExpirationTime, + ); + let formattedExpirationTime = ''; + if (!expirationTime.future) { + clearInterval(interval as NodeJS.Timeout); + setAccountState({ ...accountState, tokenExpired: true }); + } else { + formattedExpirationTime = formatTokenExpiration( + expirationTime.duration, + ); + } + setTimeLeftForTokenExpiration(formattedExpirationTime); + }, 250); + } + return () => { + if (interval !== null) { + clearInterval(interval); + } + }; + }, [user?.accessTokenExpirationTime]); + + React.useEffect(() => { + setShowAccessTokenSnackbar(refreshingAccessTokenError !== null); + }, [refreshingAccessTokenError]); + + React.useEffect(() => { + if (isAccessTokenRefreshed) { + console.log('Access token refreshed successfully', isAccessTokenRefreshed); + setRefreshTokenSuccess(true); + setTimeout(() => { + setRefreshTokenSuccess(false); + }, 1000); + } + }, [isAccessTokenRefreshed]); + + const handleClickShowToken = React.useCallback( + (tokenType: TokenTypes): void => { + switch (tokenType) { + case TokenTypes.Access: + setAccountState({ + ...accountState, + showAccessToken: !accountState.showAccessToken, + }); + break; + case TokenTypes.Refresh: + setAccountState({ + ...accountState, + showRefreshToken: !accountState.showRefreshToken, + }); + break; + } + }, + [accountState], + ); + + const handleCopyTokenToClipboard = React.useCallback( + ( + token: string, + setResult: (result: string) => void, + setShowTooltip: (showToolTip: boolean) => void, + ): void => { + navigator.clipboard + .writeText(token) + .then(() => { + setResult(texts.copied); + }) + .catch((error) => { + setResult(`Could not copy text: ${error}`); + }) + .finally(() => { + setShowTooltip(true); + setTimeout(() => { + setShowTooltip(false); + }, 1000); + }); + }, + [], + ); + + const handleGenerateAccessToken = (): void => { + setAccountState({ ...accountState, tokenExpired: false }); + dispatch(requestRefreshAccessToken()); + }; + + const getCurlAccessTokenCommand = (): string => { + const refreshToken = + user?.refreshToken !== undefined + ? user?.refreshToken + : `[${t('refreshToken.yourToken')}]`; + return `curl --location '${apiURL}/tokens' --header 'Content-Type: application/json' --data '{ "refresh_token": "${refreshToken}" }'`; + }; + + const getCurlApiTestCommand = (): string => { + const accessToken = + user?.accessToken !== undefined + ? user?.accessToken + : `[${t('accessToken.yourToken')}]`; + return `curl --location '${apiURL}/metadata' --header 'Accept: application/json' --header 'Authorization: Bearer ${accessToken}'`; + }; + + const handleCopyCodeBlock = (codeBlock: string): void => { + navigator.clipboard + .writeText(codeBlock) + .then(() => { + setAccountState({ ...accountState, codeBlockTooltip: texts.copied }); + setTimeout(() => { + setAccountState({ + ...accountState, + codeBlockTooltip: texts.copyToClipboard, + }); + }, 1000); + }) + .catch((error) => { + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.log('Could not copy text: ', error); + } + }); + }; + + const refreshAccessTokenButtonText = isRefreshingAccessToken + ? t('accessToken.refreshing') + : t('accessToken.refresh'); + + return ( + + { + setShowAccessTokenSnackbar(false); + }} + > + { + setShowAccessTokenSnackbar(false); + }} + > + {refreshingAccessTokenError?.message ?? ''} + + + { + setRefreshTokenSuccess(false); + }} + > + { + setRefreshTokenSuccess(false); + }} + > + {t('accessToken.refreshSuccess')} + + + + + {t('description')} + {t('refreshToken.description')} + + + {showRefreshTokenCopied + ? refreshTokenCopyResult + : accountState.showRefreshToken + ? user?.refreshToken !== undefined + ? user?.refreshToken + : texts.tokenUnavailable + : texts.refreshTokenHidden} + + + + + { + if (user?.refreshToken != null) { + handleCopyTokenToClipboard( + user.refreshToken, + setRefreshTokenCopyResult, + setShowRefreshTokenCopied, + ); + } + }} + sx={{ display: 'inline-block', verticalAlign: 'middle' }} + > + + + + + + { + handleClickShowToken(TokenTypes.Refresh); + }} + edge='end' + sx={{ display: 'inline-block', verticalAlign: 'middle' }} + > + {accountState.showRefreshToken ? ( + + ) : ( + + )} + + + + + + + + + {t('accessToken.description.pt1') + ' '} + + {t('accessToken.description.cta')} + {' '} + {t('accessToken.description.pt2')} + + + + + {t('accessToken.generate')} + {t('accessToken.copy')} + + + { + handleCopyCodeBlock(getCurlAccessTokenCommand()); + }} + sx={{ display: 'inline-block', verticalAlign: 'middle' }} + > + + + + + + curl{' '} + --location '{apiURL}/tokens' + \ +
+ + --header + {' '} + 'Content-Type: application/json' + \ +
+ + --data '{ + + + {' '} + "refresh_token": "[{t('refreshToken.yourToken')} + ]" + + + {' '} + }' + +
+
+
+ + + + {t('accessToken.testing.description.pt1') + ' '} + + {t('accessToken.testing.description.cta')} + + {t('accessToken.testing.description.pt2')} + + {showGenerateAccessTokenButton && ( + + + + )} + {!showGenerateAccessTokenButton && ( + + + + {accountState.showAccessToken + ? user?.accessToken !== undefined + ? user?.accessToken + : texts.tokenUnavailable + : texts.accessTokenHidden} + + + + + + {isRefreshingAccessToken ? ( + + ) : ( + + )} + + + + + + { + if (user?.accessToken != null) { + handleCopyTokenToClipboard( + user.accessToken, + setAccessTokenCopyResult, + setShowAccessTokenCopiedTooltip, + ); + } + }} + sx={{ display: 'inline-block', verticalAlign: 'middle' }} + > + + + + + + { + handleClickShowToken(TokenTypes.Access); + }} + sx={{ display: 'inline-block', verticalAlign: 'middle' }} + > + {accountState.showAccessToken ? ( + + ) : ( + + )} + + + + + + + {accountState.tokenExpired + ? t('accessToken.expired') + : t('accessToken.willExpireIn', { + timeLeftForTokenExpiration, + })} + . + + + )} + + + + + {t('accessToken.testing.api')} + + {t('accessToken.testing.copyCli')} + + + { + handleCopyCodeBlock(getCurlApiTestCommand()); + }} + sx={{ display: 'inline-block', verticalAlign: 'middle' }} + > + + + + + + curl{' '} + --location '{apiURL}/metadata' + \ +
+ + --header + {' '} + 'Accept: application/json' + \ +
+ + --header{' '} + + 'Authorization: Bearer [{t('accessToken.yourToken')}]' +
+
+
+
+ ); +} diff --git a/src/app/[locale]/account/api-access/page.tsx b/src/app/[locale]/account/api-access/page.tsx new file mode 100644 index 00000000..c9a28f23 --- /dev/null +++ b/src/app/[locale]/account/api-access/page.tsx @@ -0,0 +1,6 @@ +import { type ReactElement } from 'react'; +import AccountApiAccess from './AccountApiAccess'; + +export default function AccountApiAccessPage(): ReactElement { + return ; +} diff --git a/src/app/[locale]/account/layout.tsx b/src/app/[locale]/account/layout.tsx new file mode 100644 index 00000000..4bdbed1b --- /dev/null +++ b/src/app/[locale]/account/layout.tsx @@ -0,0 +1,237 @@ +'use client'; + +import * as React from 'react'; +import { usePathname, useRouter } from '../../../i18n/navigation'; +import { useTheme } from '@mui/material/styles'; +import { + Box, + Button, + Container, + Drawer, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Paper, + Typography, +} from '@mui/material'; +import { + DashboardOutlined, + ExitToAppOutlined, + MenuOutlined, + NotificationsOutlined, + VpnKeyOutlined, +} from '@mui/icons-material'; +import { useTranslations } from 'next-intl'; +import LogoutConfirmModal from '../../components/LogoutConfirmModal'; +import { useRemoteConfig } from '../../context/RemoteConfigProvider'; +import { ProtectedPageWrapper } from '../../components/ProtectedPageWrapper'; +import { ReduxGateWrapper } from '../../components/ReduxGateWrapper'; + +type NavSection = 'general' | 'api-access' | 'notifications'; + +const NAV_ITEMS: Array<{ + id: NavSection; + label: string; + icon: React.ReactElement; + path: string; +}> = [ + { + id: 'general', + label: 'General', + icon: , + path: '/account', + }, + { + id: 'api-access', + label: 'API Access', + icon: , + path: '/account/api-access', + }, + { + id: 'notifications', + label: 'Notifications', + icon: , + path: '/account/notifications', + }, +]; + +function AccountLayoutContent({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement { + const t = useTranslations('account'); + const tCommon = useTranslations('common'); + const { config } = useRemoteConfig(); + const pathname = usePathname(); + const router = useRouter(); + const theme = useTheme(); + const [openDialog, setOpenDialog] = React.useState(false); + const [drawerOpen, setDrawerOpen] = React.useState(false); + + let activeSection: NavSection = 'general'; + if (pathname.endsWith('/notifications')) { + activeSection = 'notifications'; + } else if (pathname.endsWith('/api-access')) { + activeSection = 'api-access'; + } + + const visibleItems = NAV_ITEMS.filter( + (item) => item.id !== 'notifications' || config.isNotificationsEnabled, + ); + + const activeItem = visibleItems.find((item) => item.id === activeSection); + + const navList = (onItemClick?: () => void): React.ReactElement => ( + <> + + {visibleItems.map((item) => ( + { + router.push(item.path); + onItemClick?.(); + }} + sx={{ borderRadius: 1 }} + > + {item.icon} + + + ))} + + + + ); + + return ( + + + {t('title')} + + + {/* Sidebar — lg and above */} + + {navList()} + + + {/* Mobile nav trigger — md and below */} + { + setDrawerOpen(true); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') setDrawerOpen(true); + }} + sx={{ + display: { xs: 'flex', lg: 'none' }, + alignItems: 'center', + gap: 1, + mb: 2, + p: 1, + bgcolor: theme.vars.palette.background.paper, + borderRadius: 1, + cursor: 'pointer', + userSelect: 'none', + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + {activeItem != null && ( + <> + {activeItem.icon} + + {activeItem.label} + + + )} + + + {/* Drawer — md and below */} + { + setDrawerOpen(false); + }} + sx={{ display: { lg: 'none' } }} + slotProps={{ paper: { sx: { width: 240, p: 2 } } }} + > + + {t('title')} + + {navList(() => { + setDrawerOpen(false); + })} + + + {children} + + + + ); +} + +export default function AccountLayout({ + children, +}: { + children: React.ReactNode; +}): React.ReactElement { + return ( + + + {children} + + + ); +} diff --git a/src/app/[locale]/account/notifications/AccountNotifications.tsx b/src/app/[locale]/account/notifications/AccountNotifications.tsx new file mode 100644 index 00000000..8c707416 --- /dev/null +++ b/src/app/[locale]/account/notifications/AccountNotifications.tsx @@ -0,0 +1,364 @@ +'use client'; + +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import Chip from '@mui/material/Chip'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormGroup from '@mui/material/FormGroup'; +import FormLabel from '@mui/material/FormLabel'; +import FormControl from '@mui/material/FormControl'; +import IconButton from '@mui/material/IconButton'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import Tab from '@mui/material/Tab'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import Tabs from '@mui/material/Tabs'; +import Typography from '@mui/material/Typography'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import NotificationSettingsDialog, { + defaultNotificationSettings, + type NotificationSettings, +} from '../../../screens/Feed/components/NotificationSettingsDialog'; +import { AccountSectionContainer } from '../AccountSectionContainer'; + +interface NotificationSubscription { + id: string; + title: string; + status: 'active' | 'paused'; + frequency: 'onChange' | 'weekly' | 'monthly' | 'quarterly'; + lastSent: string | null; +} + +const MOCK_NOTIFICATIONS: NotificationSubscription[] = [ + { + id: '1', + title: 'STM – Société de transport de Montréal', + status: 'active', + frequency: 'weekly', + lastSent: '2026-05-20', + }, + { + id: '2', + title: 'TTC – Toronto Transit Commission', + status: 'paused', + frequency: 'monthly', + lastSent: '2026-04-15', + }, + { + id: '3', + title: 'MTA New York City Transit', + status: 'active', + frequency: 'onChange', + lastSent: '2026-05-26', + }, + { + id: '4', + title: 'OC Transpo Ottawa', + status: 'active', + frequency: 'quarterly', + lastSent: null, + }, + { + id: '5', + title: 'TransLink – Metro Vancouver', + status: 'paused', + frequency: 'weekly', + lastSent: '2026-03-02', + }, +]; + +const FREQUENCY_LABELS: Record = + { + onChange: 'On Change', + weekly: 'Weekly', + monthly: 'Monthly', + quarterly: 'Quarterly', + }; + +const CHANGE_TYPE_OPTIONS = [ + { value: 'any', label: 'Any Change' }, + { value: 'features', label: 'Features Only' }, + { value: 'expiry', label: '7 Days Before Expiry' }, + { value: 'validation', label: 'New Validation Errors' }, +] as const; + +const SPECIFIC_TYPES = ['features', 'expiry', 'validation']; + +export default function AccountNotifications(): React.ReactElement { + const [tab, setTab] = React.useState(0); + const [notifications, setNotifications] = + React.useState(MOCK_NOTIFICATIONS); + const [menuState, setMenuState] = React.useState<{ + anchor: HTMLElement; + id: string; + } | null>(null); + const [settingsDialogOpen, setSettingsDialogOpen] = React.useState(false); + const [rowSettings, setRowSettings] = React.useState< + Record + >({}); + + // Default settings state for the Settings tab + const [defaultFrequency, setDefaultFrequency] = + React.useState('onChange'); + const [defaultChangeTypes, setDefaultChangeTypes] = React.useState( + [], + ); + + const selectedSubscription = + menuState !== null + ? notifications.find((n) => n.id === menuState.id) + : undefined; + const isPaused = selectedSubscription?.status === 'paused'; + + const handleMenuOpen = ( + event: React.MouseEvent, + id: string, + ): void => { + setMenuState({ anchor: event.currentTarget, id }); + }; + + const handleMenuClose = (): void => { + setMenuState(null); + }; + + const handleTogglePause = (): void => { + if (menuState !== null) { + const { id } = menuState; + setNotifications((prev) => + prev.map((n) => + n.id === id + ? { ...n, status: n.status === 'paused' ? 'active' : 'paused' } + : n, + ), + ); + handleMenuClose(); + } + }; + + const handleUnsubscribe = (): void => { + if (menuState !== null) { + const { id } = menuState; + setNotifications((prev) => prev.filter((n) => n.id !== id)); + handleMenuClose(); + } + }; + + const handleOpenSettings = (): void => { + handleMenuClose(); + setSettingsDialogOpen(true); + }; + + const handleSaveSettings = (settings: NotificationSettings): void => { + if (menuState !== null) { + setRowSettings((prev) => ({ ...prev, [menuState.id]: settings })); + } + setSettingsDialogOpen(false); + }; + + const handleDefaultChangeTypeToggle = (value: string): void => { + if (value === 'any') { + setDefaultChangeTypes((prev) => + prev.includes('any') ? [] : ['any', ...SPECIFIC_TYPES], + ); + } else { + setDefaultChangeTypes((prev) => { + if (prev.includes(value)) { + return prev.filter((t) => t !== value && t !== 'any'); + } + const withNew = prev.filter((t) => t !== 'any').concat(value); + const allSpecific = SPECIFIC_TYPES.every((t) => withNew.includes(t)); + return allSpecific ? ['any', ...withNew] : withNew; + }); + } + }; + + const selectedRowId = menuState?.id; + const settingsInitial = + selectedRowId !== undefined + ? (rowSettings[selectedRowId] ?? defaultNotificationSettings) + : defaultNotificationSettings; + + return ( + + { + setTab(v); + }} + sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }} + > + + + + + {/* ── Feeds tab ─────────────────────────────────────────────── */} + {tab === 0 && ( + + + + + + + Title + + + Status + + + Frequency + + + Last Sent + + + + + + + + {notifications.map((n) => ( + + {n.title} + + + + {FREQUENCY_LABELS[n.frequency]} + + {n.lastSent !== null + ? new Date(n.lastSent).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + : '—'} + + + { + handleMenuOpen(e, n.id); + }} + > + + + + + ))} + {notifications.length === 0 && ( + + + + No active subscriptions + + + + )} + +
+
+ + + + {isPaused === true ? 'Resume Notifications' : 'Pause Notifications'} + + + Unsubscribe + + + + { + setSettingsDialogOpen(false); + }} + onSave={handleSaveSettings} + initialSettings={settingsInitial} + /> +
+ )} + + {/* ── Settings tab ──────────────────────────────────────────── */} + {tab === 1 && ( + + + Default Notification Preferences + + + These settings will apply to any new feed subscriptions you create + + + + + Notification Frequency + + + + + + + Notify Me About + + + {CHANGE_TYPE_OPTIONS.map((opt) => ( + { + handleDefaultChangeTypeToggle(opt.value); + }} + /> + } + label={opt.label} + /> + ))} + + + + + + )} +
+ ); +} diff --git a/src/app/[locale]/account/notifications/page.tsx b/src/app/[locale]/account/notifications/page.tsx new file mode 100644 index 00000000..b3bb2cc5 --- /dev/null +++ b/src/app/[locale]/account/notifications/page.tsx @@ -0,0 +1,6 @@ +import { type ReactElement } from 'react'; +import AccountNotifications from './AccountNotifications'; + +export default function AccountNotificationsPage(): ReactElement { + return ; +} diff --git a/src/app/[locale]/account/page.tsx b/src/app/[locale]/account/page.tsx index e876a63f..9ddc6ee4 100644 --- a/src/app/[locale]/account/page.tsx +++ b/src/app/[locale]/account/page.tsx @@ -1,14 +1,6 @@ import { type ReactElement } from 'react'; -import Account from './Account'; -import { ReduxGateWrapper } from '../../components/ReduxGateWrapper'; -import { ProtectedPageWrapper } from '../../components/ProtectedPageWrapper'; +import AccountGeneral from './AccountGeneral'; export default function AccountPage(): ReactElement { - return ( - - - - - - ); + return ; } From ea7f3b476c5b3945a9a6208c19641173b1d964bd Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 28 May 2026 09:39:25 -0400 Subject: [PATCH 6/8] lint --- src/app/[locale]/account/Account.tsx | 15 +++++-------- .../account/api-access/AccountApiAccess.tsx | 21 ++++++++++++++----- src/app/[locale]/account/layout.tsx | 5 ++++- .../notifications/AccountNotifications.tsx | 15 ++++++------- .../components/ClientSubscribeControls.tsx | 2 +- .../components/NotificationSettingsDialog.tsx | 2 +- 6 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/app/[locale]/account/Account.tsx b/src/app/[locale]/account/Account.tsx index d72e5ce9..7bb1922b 100644 --- a/src/app/[locale]/account/Account.tsx +++ b/src/app/[locale]/account/Account.tsx @@ -113,8 +113,6 @@ export default function APIAccount({ const user = useSelector(selectUserProfile); const router = useRouter(); - const [selectedNav, setSelectedNav] = React.useState(section); - const texts = { accessTokenHidden: t('accessToken.hidden'), refreshTokenHidden: t('refreshToken.placeholder'), @@ -382,7 +380,7 @@ export default function APIAccount({ {NAV_ITEMS.map((item) => ( { router.push(NAV_ROUTES[item.id]); }} @@ -395,11 +393,8 @@ export default function APIAccount({
- {selectedNav === 'general' && ( - - + {section === 'general' && ( + {tCommon('name')}: {' ' + (user?.fullName ?? tCommon('unknown'))} @@ -469,9 +464,9 @@ export default function APIAccount({ )} - {selectedNav === 'notifications' && } + {section === 'notifications' && } - {selectedNav === 'api-access' && ( + {section === 'api-access' && ( {t('description')} diff --git a/src/app/[locale]/account/api-access/AccountApiAccess.tsx b/src/app/[locale]/account/api-access/AccountApiAccess.tsx index 231dad3b..3b9bb975 100644 --- a/src/app/[locale]/account/api-access/AccountApiAccess.tsx +++ b/src/app/[locale]/account/api-access/AccountApiAccess.tsx @@ -139,7 +139,6 @@ export default function AccountApiAccess(): React.ReactElement { React.useEffect(() => { if (isAccessTokenRefreshed) { - console.log('Access token refreshed successfully', isAccessTokenRefreshed); setRefreshTokenSuccess(true); setTimeout(() => { setRefreshTokenSuccess(false); @@ -457,7 +456,10 @@ export default function AccountApiAccess(): React.ReactElement { ) : ( )} @@ -489,7 +491,10 @@ export default function AccountApiAccess(): React.ReactElement { > @@ -507,12 +512,18 @@ export default function AccountApiAccess(): React.ReactElement { {accountState.showAccessToken ? ( ) : ( )} diff --git a/src/app/[locale]/account/layout.tsx b/src/app/[locale]/account/layout.tsx index 4bdbed1b..4529cb30 100644 --- a/src/app/[locale]/account/layout.tsx +++ b/src/app/[locale]/account/layout.tsx @@ -217,7 +217,10 @@ function AccountLayoutContent({ {children} - + ); } diff --git a/src/app/[locale]/account/notifications/AccountNotifications.tsx b/src/app/[locale]/account/notifications/AccountNotifications.tsx index 8c707416..0da8a992 100644 --- a/src/app/[locale]/account/notifications/AccountNotifications.tsx +++ b/src/app/[locale]/account/notifications/AccountNotifications.tsx @@ -151,11 +151,6 @@ export default function AccountNotifications(): React.ReactElement { } }; - const handleOpenSettings = (): void => { - handleMenuClose(); - setSettingsDialogOpen(true); - }; - const handleSaveSettings = (settings: NotificationSettings): void => { if (menuState !== null) { setRowSettings((prev) => ({ ...prev, [menuState.id]: settings })); @@ -195,14 +190,16 @@ export default function AccountNotifications(): React.ReactElement { }} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }} > - - + + {/* ── Feeds tab ─────────────────────────────────────────────── */} {tab === 0 && ( - + @@ -279,7 +276,7 @@ export default function AccountNotifications(): React.ReactElement { transformOrigin={{ vertical: 'top', horizontal: 'right' }} > - {isPaused === true ? 'Resume Notifications' : 'Pause Notifications'} + {isPaused ? 'Resume Notifications' : 'Pause Notifications'} Unsubscribe diff --git a/src/app/screens/Feed/components/ClientSubscribeControls.tsx b/src/app/screens/Feed/components/ClientSubscribeControls.tsx index 662e8374..099f29b2 100644 --- a/src/app/screens/Feed/components/ClientSubscribeControls.tsx +++ b/src/app/screens/Feed/components/ClientSubscribeControls.tsx @@ -1,6 +1,6 @@ 'use client'; -// This component is currently hardcoded +// This component is currently hardcoded // To implement actual data fetching / setting once backend APIs are in place import { useState } from 'react'; diff --git a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx index 5cb42024..abf0f9b5 100644 --- a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx +++ b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx @@ -107,7 +107,7 @@ export default function NotificationSettingsDialog({ { - setThreshold(value as number); + setThreshold(value); }} min={1} max={100} From d5c4ffb9cae0e082d354c2d98c25e24250723d02 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Thu, 28 May 2026 12:42:22 -0400 Subject: [PATCH 7/8] notification setting adjustment --- .../notifications/AccountNotifications.tsx | 69 ++++-- src/app/components/ChangeTypeInfoPopover.tsx | 200 ++++++++++++++++++ .../components/ClientSubscribeControls.tsx | 3 + .../components/NotificationSettingsDialog.tsx | 106 ++++++---- 4 files changed, 319 insertions(+), 59 deletions(-) create mode 100644 src/app/components/ChangeTypeInfoPopover.tsx diff --git a/src/app/[locale]/account/notifications/AccountNotifications.tsx b/src/app/[locale]/account/notifications/AccountNotifications.tsx index 0da8a992..499a05c6 100644 --- a/src/app/[locale]/account/notifications/AccountNotifications.tsx +++ b/src/app/[locale]/account/notifications/AccountNotifications.tsx @@ -22,7 +22,11 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Tabs from '@mui/material/Tabs'; import Typography from '@mui/material/Typography'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import MoreVertIcon from '@mui/icons-material/MoreVert'; +import ChangeTypeInfoPopover, { + CHANGE_TYPE_INFO, +} from '../../../components/ChangeTypeInfoPopover'; import NotificationSettingsDialog, { defaultNotificationSettings, type NotificationSettings, @@ -75,22 +79,22 @@ const MOCK_NOTIFICATIONS: NotificationSubscription[] = [ }, ]; -const FREQUENCY_LABELS: Record = - { - onChange: 'On Change', - weekly: 'Weekly', - monthly: 'Monthly', - quarterly: 'Quarterly', - }; - const CHANGE_TYPE_OPTIONS = [ { value: 'any', label: 'Any Change' }, { value: 'features', label: 'Features Only' }, { value: 'expiry', label: '7 Days Before Expiry' }, { value: 'validation', label: 'New Validation Errors' }, + { value: 'breaking', label: 'Breaking Changes' }, + { value: 'suspicious', label: 'Suspicious Changes' }, ] as const; -const SPECIFIC_TYPES = ['features', 'expiry', 'validation']; +const SPECIFIC_TYPES = [ + 'features', + 'expiry', + 'validation', + 'breaking', + 'suspicious', +]; export default function AccountNotifications(): React.ReactElement { const [tab, setTab] = React.useState(0); @@ -111,6 +115,10 @@ export default function AccountNotifications(): React.ReactElement { const [defaultChangeTypes, setDefaultChangeTypes] = React.useState( [], ); + const [infoPopover, setInfoPopover] = React.useState<{ + anchor: HTMLElement; + type: string; + } | null>(null); const selectedSubscription = menuState !== null @@ -209,9 +217,6 @@ export default function AccountNotifications(): React.ReactElement { Status - - Frequency - Last Sent @@ -232,7 +237,6 @@ export default function AccountNotifications(): React.ReactElement { variant='outlined' /> - {FREQUENCY_LABELS[n.frequency]} {n.lastSent !== null ? new Date(n.lastSent).toLocaleDateString(undefined, { @@ -298,10 +302,10 @@ export default function AccountNotifications(): React.ReactElement { {tab === 1 && ( - Default Notification Preferences + Global Notification Preferences - These settings will apply to any new feed subscriptions you create + These settings will apply to all feed subscriptions you create @@ -340,7 +344,30 @@ export default function AccountNotifications(): React.ReactElement { }} /> } - label={opt.label} + label={ + opt.value in CHANGE_TYPE_INFO ? ( + + {opt.label} + { + e.preventDefault(); + e.stopPropagation(); + setInfoPopover({ + anchor: e.currentTarget, + type: opt.value, + }); + }} + sx={{ ml: 0.5 }} + aria-label={`About ${opt.label}`} + > + + + + ) : ( + opt.label + ) + } /> ))} @@ -356,6 +383,16 @@ export default function AccountNotifications(): React.ReactElement { )} + + {infoPopover != null && ( + { + setInfoPopover(null); + }} + /> + )} ); } diff --git a/src/app/components/ChangeTypeInfoPopover.tsx b/src/app/components/ChangeTypeInfoPopover.tsx new file mode 100644 index 00000000..635553ec --- /dev/null +++ b/src/app/components/ChangeTypeInfoPopover.tsx @@ -0,0 +1,200 @@ +'use client'; + +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Popover from '@mui/material/Popover'; +import Typography from '@mui/material/Typography'; +import CloseIcon from '@mui/icons-material/Close'; + +export const CHANGE_TYPE_INFO: Record< + string, + { title: string; items: Array<{ term: string; description: string }> } +> = { + breaking: { + title: 'Breaking Changes', + items: [ + { + term: 'Removed required field', + description: + 'A field marked required in the spec is absent in the new feed.', + }, + { + term: 'Removed entity', + description: + 'A stop_id, route_id, trip_id, or agency_id that existed before is gone entirely.', + }, + { + term: 'Changed primary key / ID rename', + description: + 'An entity ID value changed (e.g. stop_id 123 renamed to STM-123), breaking all foreign key references.', + }, + { + term: 'Changed field type or format', + description: + 'e.g. a time field switching from HH:MM:SS to epoch seconds, or a numeric field becoming a string.', + }, + { + term: 'Removed file', + description: + 'An entire .txt file (e.g. calendar.txt) is absent with no functional replacement.', + }, + { + term: 'Referential integrity broken', + description: + 'A foreign key reference points to a non-existent entity (e.g. trips.txt references a shape_id missing from shapes.txt).', + }, + { + term: 'Enum value removed or changed', + description: + 'A previously valid enum value (e.g. route_type, pickup_type) is gone or renumbered.', + }, + { + term: 'Feed validity window shrunk to past', + description: + 'feed_info.feed_end_date is in the past \u2014 all trips are technically expired.', + }, + ], + }, + suspicious: { + title: 'Suspicious Changes', + items: [ + { + term: 'Large entity count delta', + description: + 'Row count changed by more than N% (e.g. >20%) for any file \u2014 sudden mass addition or deletion of stops/trips.', + }, + { + term: 'Geographic bounding box shift', + description: + 'The min/max lat/lon envelope of stops moved significantly \u2014 may indicate a coordinate system error or wrong feed published.', + }, + { + term: 'Stop coordinates moved far', + description: + 'Any individual stop moved more than X meters (e.g. >500 m) between versions.', + }, + { + term: 'Service coverage date gap', + description: + 'Gap between the previous feed end date and new feed start date \u2014 service days with no coverage.', + }, + { + term: 'Massive schedule change outside known dates', + description: + 'Trip count or departure time distribution changed drastically outside of a declared service change date.', + }, + { + term: 'Headsign or route name bulk change', + description: + 'A high percentage of trip_headsign or route_long_name values changed \u2014 possible encoding issue or bulk error.', + }, + { + term: 'Shape geometry distortion', + description: + 'Shape distances or point sequences changed in ways inconsistent with minor route edits.', + }, + { + term: 'Calendar / calendar_dates flip', + description: + 'Service switched from calendar.txt-based to calendar_dates.txt-only (or vice versa) without prior notice.', + }, + { + term: 'Agency timezone change', + description: + 'agency_timezone changed \u2014 affects all absolute time interpretation.', + }, + { + term: 'Feed publisher change', + description: + 'feed_publisher_name or feed_publisher_url changed \u2014 may indicate a feed source swap.', + }, + { + term: 'Duplicate IDs introduced', + description: 'IDs that were unique before now appear more than once.', + }, + { + term: 'Arrival/departure time ordering violations', + description: + 'Stop times within a trip are no longer monotonically increasing.', + }, + ], + }, +}; + +interface Props { + anchor: HTMLElement | null; + type: string | null; + onClose: () => void; +} + +export default function ChangeTypeInfoPopover({ + anchor, + type, + onClose, +}: Props): React.ReactElement | null { + if (anchor === null || type === null) return null; + const info = CHANGE_TYPE_INFO[type]; + if (info == null) return null; + + return ( + + + {/* Sticky header */} + + + {info.title} + + + + + + {/* Scrollable content */} + + {info.items.map(({ term, description }) => ( + + + {term} + + + {description} + + + ))} + + + + ); +} diff --git a/src/app/screens/Feed/components/ClientSubscribeControls.tsx b/src/app/screens/Feed/components/ClientSubscribeControls.tsx index 099f29b2..cf9dcca9 100644 --- a/src/app/screens/Feed/components/ClientSubscribeControls.tsx +++ b/src/app/screens/Feed/components/ClientSubscribeControls.tsx @@ -139,6 +139,9 @@ export default function ClientSubscribeControls(): React.ReactElement | null { > Notification Settings + + View All Notifications + Unsubscribe diff --git a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx index abf0f9b5..637e8f44 100644 --- a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx +++ b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx @@ -14,21 +14,29 @@ import FormControl from '@mui/material/FormControl'; import FormControlLabel from '@mui/material/FormControlLabel'; import FormGroup from '@mui/material/FormGroup'; import FormLabel from '@mui/material/FormLabel'; +import IconButton from '@mui/material/IconButton'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; -import Slider from '@mui/material/Slider'; -import Typography from '@mui/material/Typography'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import ChangeTypeInfoPopover, { + CHANGE_TYPE_INFO, +} from '../../../components/ChangeTypeInfoPopover'; export interface NotificationSettings { - threshold: number; frequency: 'onChange' | 'weekly' | 'monthly' | 'quarterly'; changeTypes: string[]; } export const defaultNotificationSettings: NotificationSettings = { - threshold: 55, frequency: 'onChange', - changeTypes: ['any', 'features', 'expiry', 'validation'], + changeTypes: [ + 'any', + 'features', + 'expiry', + 'validation', + 'breaking', + 'suspicious', + ], }; const CHANGE_TYPE_OPTIONS = [ @@ -36,9 +44,17 @@ const CHANGE_TYPE_OPTIONS = [ { value: 'features', label: 'Features Changes' }, { value: 'expiry', label: '7 Days Before Expiry' }, { value: 'validation', label: 'New Validation Errors' }, + { value: 'breaking', label: 'Breaking Changes' }, + { value: 'suspicious', label: 'Suspicious Changes' }, ] as const; -const SPECIFIC_TYPES = ['features', 'expiry', 'validation']; +const SPECIFIC_TYPES = [ + 'features', + 'expiry', + 'validation', + 'breaking', + 'suspicious', +]; interface Props { open: boolean; @@ -53,18 +69,21 @@ export default function NotificationSettingsDialog({ onSave, initialSettings, }: Props): React.ReactElement { - const [threshold, setThreshold] = useState(initialSettings.threshold); const [frequency, setFrequency] = useState(initialSettings.frequency); const [changeTypes, setChangeTypes] = useState( initialSettings.changeTypes, ); + const [infoPopover, setInfoPopover] = useState<{ + anchor: HTMLElement; + type: string; + } | null>(null); // Reset to saved settings each time the dialog opens useEffect(() => { if (open) { - setThreshold(initialSettings.threshold); setFrequency(initialSettings.frequency); setChangeTypes(initialSettings.changeTypes); + setInfoPopover(null); } }, [open, initialSettings]); @@ -90,42 +109,10 @@ export default function NotificationSettingsDialog({ return ( - Notification Settings + Global Notification Settings - {/* Difference threshold */} - - - Difference Threshold: {threshold}% - - - Notifications trigger based on how much the feed has changed. 1% - means any single change, 100% means the entire feed would need to - change. - - { - setThreshold(value); - }} - min={1} - max={100} - valueLabelDisplay='auto' - valueLabelFormat={(v) => `${v}%`} - /> - - - Any small change - - - Major changes only - - - - {/* Frequency */} @@ -164,7 +151,30 @@ export default function NotificationSettingsDialog({ }} /> } - label={label} + label={ + value in CHANGE_TYPE_INFO ? ( + + {label} + { + e.preventDefault(); + e.stopPropagation(); + setInfoPopover({ + anchor: e.currentTarget, + type: value, + }); + }} + sx={{ ml: 0.5 }} + aria-label={`About ${label}`} + > + + + + ) : ( + label + ) + } /> ))} @@ -172,13 +182,23 @@ export default function NotificationSettingsDialog({ + {infoPopover != null && ( + { + setInfoPopover(null); + }} + /> + )} + - )} - - - - )} - - {section === 'notifications' && } - - {section === 'api-access' && ( - - - {t('description')} - - {t('refreshToken.description')} - - - - {showRefreshTokenCopied - ? refreshTokenCopyResult - : accountState.showRefreshToken - ? user?.refreshToken !== undefined - ? user?.refreshToken - : texts.tokenUnavailable - : texts.refreshTokenHidden} - - - - - { - if (user?.refreshToken != null) { - handleCopyTokenToClipboard( - user.refreshToken, - setRefreshTokenCopyResult, - setShowRefreshTokenCopied, - ); - } - }} - sx={{ - display: 'inline-block', - verticalAlign: 'middle', - }} - > - - - - - - { - handleClickShowToken(TokenTypes.Refresh); - }} - edge='end' - sx={{ - display: 'inline-block', - verticalAlign: 'middle', - }} - > - {accountState.showRefreshToken ? ( - - ) : ( - - )} - - - - - - - - {t('accessToken.description.pt1') + ' '} - - {t('accessToken.description.cta')} - {' '} - {t('accessToken.description.pt2')} - - - - - - {t('accessToken.generate')} - - - {t('accessToken.copy')} - - - { - handleCopyCodeBlock(getCurlAccessTokenCommand()); - }} - sx={{ - display: 'inline-block', - verticalAlign: 'middle', - }} - > - - - - - - curl{' '} - --location '{apiURL}/tokens' - - \ - -
- - --header - {' '} - 'Content-Type: application/json' - - \ - -
- - --data '{ - - - {' '} - "refresh_token": "[ - {t('refreshToken.yourToken')}]" - - - {' '} - }' - -
-
-
- - - {t('accessToken.testing.description.pt1') + ' '} - - {t('accessToken.testing.description.cta')} - - {t('accessToken.testing.description.pt2')} - - {showGenerateAccessTokenButton && ( - - - - )} - - {!showGenerateAccessTokenButton && ( - - - - {accountState.showAccessToken - ? user?.accessToken !== undefined - ? user?.accessToken - : texts.tokenUnavailable - : texts.accessTokenHidden} - - - - - - {isRefreshingAccessToken ? ( - - ) : ( - - )} - - - - - - - { - if (user?.accessToken != null) { - handleCopyTokenToClipboard( - user.accessToken, - setAccessTokenCopyResult, - setShowAccessTokenCopiedTooltip, - ); - } - }} - sx={{ - display: 'inline-block', - verticalAlign: 'middle', - }} - > - - - - - - { - handleClickShowToken(TokenTypes.Access); - }} - sx={{ - display: 'inline-block', - verticalAlign: 'middle', - }} - > - {accountState.showAccessToken ? ( - - ) : ( - - )} - - - - - - - {accountState.tokenExpired - ? t('accessToken.expired') - : t('accessToken.willExpireIn', { - timeLeftForTokenExpiration, - })} - . - - - )} - - - - - {t('accessToken.testing.api')} - - - - {t('accessToken.testing.copyCli')} - - - - { - handleCopyCodeBlock(getCurlApiTestCommand()); - }} - sx={{ - display: 'inline-block', - verticalAlign: 'middle', - }} - > - - - - - - - curl - {' '} - --location '{apiURL}/metadata' - - \ - -
- - --header - {' '} - 'Accept: application/json' - - \ - -
- - --header{' '} - - 'Authorization: Bearer [{t('accessToken.yourToken')} - ]' -
-
-
-
- )} - - - - - ); -} diff --git a/src/app/[locale]/account/AccountGeneral.tsx b/src/app/[locale]/account/AccountGeneral.tsx index eab7e030..bc233090 100644 --- a/src/app/[locale]/account/AccountGeneral.tsx +++ b/src/app/[locale]/account/AccountGeneral.tsx @@ -95,7 +95,7 @@ export default function AccountGeneral(): React.ReactElement { > {!signedInWithProvider && (