diff --git a/cypress/e2e/changepassword.cy.ts b/cypress/e2e/changepassword.cy.ts index 1b804bca..334f7223 100644 --- a/cypress/e2e/changepassword.cy.ts +++ b/cypress/e2e/changepassword.cy.ts @@ -8,8 +8,7 @@ describe('Change Password Screen', () => { cy.get('[data-testid="home-title"]').should('exist'); cy.createNewUserAndSignIn(email, currentPassword); cy.get('[data-cy="accountHeader"]').should('exist').click(); // assures that the user is signed in - cy.get('[data-cy="accountDetailsHeader"]').should('exist').click(); - cy.get('[data-cy="changePasswordButton"]').should('exist').click(); + cy.get('[data-cy="changePasswordButtonAccount"]').should('exist').click(); }); it('should render components', () => { @@ -51,7 +50,8 @@ describe('Change Password Screen', () => { cy.location('pathname').should('eq', '/account'); // logout - cy.get('[data-cy="signOutButton"]').click(); + cy.get('[data-cy="mobileNavTrigger"]').click(); + cy.get('[data-cy="mobile-signOutButton"]').click(); cy.get('[data-cy="confirmSignOutButton"]').should('exist').should('not.be.disabled').click(); cy.visit('/sign-in'); cy.get('[data-cy="signInEmailInput"]').type(email); 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/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', diff --git a/src/app/[locale]/account/Account.tsx b/src/app/[locale]/account/Account.tsx deleted file mode 100644 index 97e4d8a1..00000000 --- a/src/app/[locale]/account/Account.tsx +++ /dev/null @@ -1,775 +0,0 @@ -'use client'; - -import * as React from 'react'; -import { useRouter } from 'next/navigation'; -import { useTheme } from '@mui/material/styles'; -import { - Typography, - Container, - IconButton, - Button, - Link, - Paper, - Box, - Tooltip, - Alert, - CircularProgress, - Snackbar, - Chip, -} from '@mui/material'; -import { - AccountCircleOutlined, - Check, - ContentCopyOutlined, - ExitToAppOutlined, - RefreshOutlined, - VisibilityOffOutlined, - VisibilityOutlined, - WarningAmberOutlined, -} from '@mui/icons-material'; -import { useSelector } from 'react-redux'; -import { - selectIsRefreshingAccessToken, - selectRefreshingAccessTokenError, - selectUserProfile, - selectSignedInWithProvider, - selectIsTokenRefreshed, -} from '../../store/selectors'; -import LogoutConfirmModal from '../../components/LogoutConfirmModal'; -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'; - -interface APIAccountState { - showRefreshToken: boolean; - showAccessToken: boolean; - codeBlockTooltip: string; - tokenExpired: boolean; -} - -enum TokenTypes { - Access = 'accessToken', - Refresh = 'refreshToken', -} - -export default function APIAccount(): 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 router = useRouter(); - - 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 signedInWithProvider = useSelector(selectSignedInWithProvider); - - 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 [openDialog, setOpenDialog] = React.useState(false); - 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) { - 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); - } - }); - }; - - function handleSignOutClick(): void { - setOpenDialog(true); - } - - function handleChangePasswordClick(): void { - router.push('/change-password'); - } - - const refreshAccessTokenButtonText = isRefreshingAccessToken - ? t('accessToken.refreshing') - : t('accessToken.refresh'); - - return ( - - { - setShowAccessTokenSnackbar(false); - }} - > - { - setShowAccessTokenSnackbar(false); - }} - > - {refreshingAccessTokenError?.message ?? ''} - - - { - setRefreshTokenSuccess(false); - }} - > - { - setRefreshTokenSuccess(false); - }} - > - {t('accessToken.refreshSuccess')} - - - - {t('title')} - - - - - - {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 && ( - - )} - - - - - - {t('description')} - - {t('refreshToken.title')} - - - {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.title')} - - - {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.title')} - - - {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 new file mode 100644 index 00000000..bc233090 --- /dev/null +++ b/src/app/[locale]/account/AccountGeneral.tsx @@ -0,0 +1,113 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from '../../../i18n/navigation'; +import { Box, Button, Chip, Link, TextField, Typography } from '@mui/material'; +import { Check } from '@mui/icons-material'; +import { useSelector } from 'react-redux'; +import { + selectSignedInWithProvider, + selectUserProfile, +} from '../../store/selectors'; +import { useTranslations } from 'next-intl'; +import { AccountSectionContainer } from './AccountSectionContainer'; + +export default function AccountGeneral(): React.ReactElement { + const t = useTranslations('account'); + const tCommon = useTranslations('common'); + const user = useSelector(selectUserProfile); + const router = useRouter(); + const signedInWithProvider = useSelector(selectSignedInWithProvider); + + return ( + <> + {/* 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..3b9bb975 --- /dev/null +++ b/src/app/[locale]/account/api-access/AccountApiAccess.tsx @@ -0,0 +1,593 @@ +'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) { + 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..705b062d --- /dev/null +++ b/src/app/[locale]/account/layout.tsx @@ -0,0 +1,244 @@ +'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 = ( + key: string, + 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('desktop')} + + + {/* 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('mobile', () => { + 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..499a05c6 --- /dev/null +++ b/src/app/[locale]/account/notifications/AccountNotifications.tsx @@ -0,0 +1,398 @@ +'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 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, +} 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 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', + 'breaking', + 'suspicious', +]; + +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 [infoPopover, setInfoPopover] = React.useState<{ + anchor: HTMLElement; + type: string; + } | null>(null); + + 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 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 + + + Last Sent + + + + + + + + {notifications.map((n) => ( + + {n.title} + + + + + {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 ? 'Resume Notifications' : 'Pause Notifications'} + + + Unsubscribe + + + + { + setSettingsDialogOpen(false); + }} + onSave={handleSaveSettings} + initialSettings={settingsInitial} + /> +
+ )} + + {/* ── Settings tab ──────────────────────────────────────────── */} + {tab === 1 && ( + + + Global Notification Preferences + + + These settings will apply to all feed subscriptions you create + + + + + Notification Frequency + + + + + + + Notify Me About + + + {CHANGE_TYPE_OPTIONS.map((opt) => ( + { + handleDefaultChangeTypeToggle(opt.value); + }} + /> + } + 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 + ) + } + /> + ))} + + + + + + )} + + {infoPopover != null && ( + { + setInfoPopover(null); + }} + /> + )} +
+ ); +} 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 ; } 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/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/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({ +

+
); 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, }; /** 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 + + + View All Notifications + + + 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..637e8f44 --- /dev/null +++ b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx @@ -0,0 +1,209 @@ +'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 IconButton from '@mui/material/IconButton'; +import MenuItem from '@mui/material/MenuItem'; +import Select from '@mui/material/Select'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import ChangeTypeInfoPopover, { + CHANGE_TYPE_INFO, +} from '../../../components/ChangeTypeInfoPopover'; + +export interface NotificationSettings { + frequency: 'onChange' | 'weekly' | 'monthly' | 'quarterly'; + changeTypes: string[]; +} + +export const defaultNotificationSettings: NotificationSettings = { + frequency: 'onChange', + changeTypes: [ + 'any', + 'features', + 'expiry', + 'validation', + 'breaking', + 'suspicious', + ], +}; + +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' }, + { value: 'breaking', label: 'Breaking Changes' }, + { value: 'suspicious', label: 'Suspicious Changes' }, +] as const; + +const SPECIFIC_TYPES = [ + 'features', + 'expiry', + 'validation', + 'breaking', + 'suspicious', +]; + +interface Props { + open: boolean; + onClose: () => void; + onSave: (settings: NotificationSettings) => void; + initialSettings: NotificationSettings; +} + +export default function NotificationSettingsDialog({ + open, + onClose, + onSave, + initialSettings, +}: Props): React.ReactElement { + 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) { + setFrequency(initialSettings.frequency); + setChangeTypes(initialSettings.changeTypes); + setInfoPopover(null); + } + }, [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 ( + + Global Notification Settings + + + + {/* Frequency */} + + + Frequency of Notification + + + + + {/* Type of changes */} + + + Type of Changes + + + {CHANGE_TYPE_OPTIONS.map(({ value, label }) => ( + { + handleChangeTypeToggle(value); + }} + /> + } + 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 + ) + } + /> + ))} + + + + + + {infoPopover != null && ( + { + setInfoPopover(null); + }} + /> + )} + + + + + + + ); +}