diff --git a/.gitignore b/.gitignore index fb7a4a0..6d34c38 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ yarn-error.log* storybook-static .cache +.netlify diff --git a/AGENTS.md b/AGENTS.md index c70d3dd..b765fc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,2 +1,11 @@ Use space-y instead of mt-2 for better spacing between elements. Always use spacing in multiples of 2 unless you need to use odd spacing for a specific reason. This helps maintain visual consistency across the app. + +## Branch and deployment workflow + +- `main` is the GitHub default branch and Netlify production branch. +- `beta` is the permanent integration and beta-testing branch. +- Feature and fix PRs should target `beta`, not `main`. +- Production releases happen by opening or updating the deploy PR from `beta` into `main`. +- Use the manual GitHub Actions `Deploy` workflow to create or update the `beta` -> `main` release PR. +- PRs into `main` are guarded and should only come from `beta`. diff --git a/netlify/functions/notify-comp-token.js b/netlify/functions/notify-comp-token.js new file mode 100644 index 0000000..2dd207a --- /dev/null +++ b/netlify/functions/notify-comp-token.js @@ -0,0 +1,182 @@ +const crypto = require('crypto'); + +const headers = { + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', +}; + +const base64Url = (value) => Buffer.from(value).toString('base64url'); +const REMOTE_SCOPE = 'notifycomp.remote'; +const PUSH_SCOPE = 'assignment_notifications'; +const REMOTE_TOKEN_TTL_SECONDS = 12 * 60 * 60; +const PUSH_TOKEN_TTL_SECONDS = 10 * 60; + +const getCompetitionManagers = (competition) => { + const data = competition?.competition || competition; + return [...(data?.organizers || []), ...(data?.delegates || [])]; +}; + +const isListedCompetitionManager = (competition, userId) => + getCompetitionManagers(competition).some((user) => Number(user?.id) === Number(userId)); + +const getManagedCompetitionIds = (competitions, userId) => + competitions + .filter((competition) => isListedCompetitionManager(competition, userId)) + .map((competition) => competition.id) + .filter(Boolean); + +const signJwt = (claims, secret) => { + const encodedHeader = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const encodedPayload = base64Url(JSON.stringify(claims)); + const signature = crypto + .createHmac('sha256', secret) + .update(`${encodedHeader}.${encodedPayload}`) + .digest('base64url'); + + return `${encodedHeader}.${encodedPayload}.${signature}`; +}; + +exports.handler = async (event) => { + if (event.httpMethod === 'OPTIONS') { + return { statusCode: 204, headers }; + } + + if (event.httpMethod !== 'POST') { + return { + statusCode: 405, + headers, + body: JSON.stringify({ message: 'Method not allowed' }), + }; + } + + const secret = process.env.COMPETITION_GROUPS_JWT_SECRET; + if (!secret) { + return { + statusCode: 500, + headers, + body: JSON.stringify({ message: 'Notification token secret is not configured' }), + }; + } + + let body; + try { + body = JSON.parse(event.body || '{}'); + } catch { + return { + statusCode: 400, + headers, + body: JSON.stringify({ message: 'Invalid JSON body' }), + }; + } + + const { accessToken, competitionId, scope } = body; + const tokenScope = scope === REMOTE_SCOPE ? REMOTE_SCOPE : PUSH_SCOPE; + if (!accessToken) { + return { + statusCode: 400, + headers, + body: JSON.stringify({ message: 'Missing WCA access token' }), + }; + } + + const wcaOrigin = process.env.WCA_ORIGIN || 'https://www.worldcubeassociation.org'; + const meParams = + tokenScope === REMOTE_SCOPE ? '?upcoming_competitions=true&ongoing_competitions=true' : ''; + const meResponse = await fetch(`${wcaOrigin}/api/v0/me${meParams}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!meResponse.ok) { + return { + statusCode: 401, + headers, + body: JSON.stringify({ message: 'Invalid WCA access token' }), + }; + } + + const { me, ongoing_competitions = [], upcoming_competitions = [] } = await meResponse.json(); + if (!me?.id) { + return { + statusCode: 401, + headers, + body: JSON.stringify({ message: 'Unable to resolve WCA user' }), + }; + } + + const tokenTtlSeconds = + tokenScope === REMOTE_SCOPE ? REMOTE_TOKEN_TTL_SECONDS : PUSH_TOKEN_TTL_SECONDS; + if (tokenScope === REMOTE_SCOPE && !competitionId) { + return { + statusCode: 400, + headers, + body: JSON.stringify({ message: 'Missing competition ID for remote token' }), + }; + } + + let remoteCompetitionIds = []; + + if (tokenScope === REMOTE_SCOPE) { + const competitionResponse = await fetch(`${wcaOrigin}/api/v0/competitions/${competitionId}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!competitionResponse.ok) { + return { + statusCode: competitionResponse.status === 404 ? 404 : 502, + headers, + body: JSON.stringify({ message: 'Unable to verify competition remote access' }), + }; + } + + const competition = await competitionResponse.json(); + if (!isListedCompetitionManager(competition, me.id)) { + return { + statusCode: 403, + headers, + body: JSON.stringify({ + message: + 'Only listed competition delegates and organizers can use Live Activities Remote', + }), + }; + } + + // Remote tokens are scoped to competitions the WCA API says this user manages. The + // NotifyComp API must reject remote mutations for competition IDs outside this claim. + remoteCompetitionIds = [ + ...new Set([ + competitionId, + ...getManagedCompetitionIds([...ongoing_competitions, ...upcoming_competitions], me.id), + ]), + ]; + } + + const now = Math.floor(Date.now() / 1000); + const token = signJwt( + { + aud: process.env.COMPETITION_GROUPS_JWT_AUDIENCE || 'notifycomp', + competitionIds: tokenScope === REMOTE_SCOPE ? remoteCompetitionIds : undefined, + exp: now + tokenTtlSeconds, + iat: now, + iss: process.env.COMPETITION_GROUPS_JWT_ISSUER || 'competitiongroups.com', + name: me.name, + scope: tokenScope, + scopes: [tokenScope], + sub: `wca:${me.id}`, + wcaUserId: me.id, + wcaUserIds: [me.id], + }, + secret, + ); + + return { + statusCode: 200, + headers, + body: JSON.stringify({ token }), + }; +}; diff --git a/public/notification-sw.js b/public/notification-sw.js new file mode 100644 index 0000000..e0e0b11 --- /dev/null +++ b/public/notification-sw.js @@ -0,0 +1,34 @@ +self.addEventListener('push', (event) => { + if (!event.data) { + return; + } + + const payload = event.data.json(); + const title = payload.title || 'Assignment update'; + const options = { + body: payload.body, + data: payload, + tag: payload.dedupeKey || 'assignment-change', + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + const targetUrl = event.notification.data?.url || '/settings'; + const url = new URL(targetUrl, self.location.origin).href; + + event.waitUntil( + self.clients.matchAll({ includeUncontrolled: true, type: 'window' }).then((clients) => { + const existingClient = clients.find((client) => client.url === url); + + if (existingClient) { + return existingClient.focus(); + } + + return self.clients.openWindow(url); + }), + ); +}); diff --git a/src/App.tsx b/src/App.tsx index 882b13e..648f747 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,9 @@ import client from './apolloClient'; import { usePageTracking } from './hooks/usePageTracking'; import { CompetitionLayout } from './layouts/CompetitionLayout'; import { RootLayout } from './layouts/RootLayout'; +import { FEATURE_FLAGS } from './lib/featureFlags'; import About from './pages/About'; +import CompetitionAdmin from './pages/Competition/Admin'; import CompetitionEvents from './pages/Competition/ByGroup/Events'; import CompetitionGroup from './pages/Competition/ByGroup/Group'; import CompetitionGroupList from './pages/Competition/ByGroup/GroupList'; @@ -18,6 +20,8 @@ import CompetitionLive from './pages/Competition/Live'; import CompetitionPerson from './pages/Competition/Person'; import CompetitionPersonalBests from './pages/Competition/Person/PersonalBests'; import { PsychSheetEvent } from './pages/Competition/PsychSheet/PsychSheetEvent'; +import CompetitionRemote from './pages/Competition/Remote'; +import CompetitionResults from './pages/Competition/Results'; import { CompetitionActivity, CompetitionRoom, @@ -27,13 +31,18 @@ import { import CompetitionScramblerSchedule from './pages/Competition/ScramblerSchedule'; import CompetitionStats from './pages/Competition/Stats'; import CompetitionStreamSchedule from './pages/Competition/StreamSchedule'; +import CompetitionSumOfRanks from './pages/Competition/SumOfRanks'; import Home from './pages/Home'; +import LiveActivitiesAbout from './pages/LiveActivities/About'; import Settings from './pages/Settings'; import Support from './pages/Support'; import Test from './pages/Test'; +import UserPage from './pages/User'; import UserLogin from './pages/UserLogin'; import { AppProvider } from './providers/AppProvider'; import { AuthProvider, useAuth } from './providers/AuthProvider'; +import { ConfirmProvider } from './providers/ConfirmProvider'; +import { NotifyCompRemoteAuthProvider } from './providers/NotifyCompRemoteAuthProvider'; import { QueryProvider } from './providers/QueryProvider/QueryProvider'; import { UserSettingsProvider } from './providers/UserSettingsProvider'; import { useWCIF } from './providers/WCIFProvider'; @@ -79,6 +88,30 @@ const PsychSheet = () => { return null; }; +const CompetitionPersonByWcaIdRedirect = ({ to }: { to: 'results' | 'records' }) => { + const { competitionId, wcaId } = useParams() as { competitionId: string; wcaId: string }; + const { wcif } = useWCIF(); + const person = wcif?.persons.find((p) => p.wcaId?.toUpperCase() === wcaId.toUpperCase()); + + if (!wcif) { + return null; + } + + if (!person) { + return ; + } + + return ( + + ); +}; + +const CompetitionRedirect = ({ to }: { to: string }) => { + const { competitionId } = useParams() as { competitionId: string }; + + return ; +}; + const Navigation = () => { usePageTracking(import.meta.env.VITE_GA_MEASUREMENT_ID); @@ -90,7 +123,15 @@ const Navigation = () => { }> } /> - } /> + } + /> + } + /> + } /> } /> } /> } /> @@ -106,8 +147,16 @@ const Navigation = () => { } /> } /> - - } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -116,11 +165,23 @@ const Navigation = () => { } /> } /> } /> - } /> + } /> + } /> Path not resolved

} />
} /> + {FEATURE_FLAGS.personalUserPage && ( + <> + } /> + } + /> + } /> + + )} } /> + } /> } /> } /> @@ -136,9 +197,13 @@ const App = () => ( - - - + + + + + + + diff --git a/src/apolloClient.ts b/src/apolloClient.ts index e5bdc47..4e33106 100644 --- a/src/apolloClient.ts +++ b/src/apolloClient.ts @@ -1,25 +1,58 @@ import { ApolloClient, createHttpLink, InMemoryCache, split } from '@apollo/client'; +import { setContext } from '@apollo/client/link/context'; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { getMainDefinition } from '@apollo/client/utilities'; import { createClient } from 'graphql-ws'; +import { getNotifyCompRemoteToken } from './lib/notifyCompRemoteAuth'; +import { setNotifyCompWebSocketStatus } from './lib/notifyCompWebSocketStatus'; +import { NOTIFYCOMP_GRAPHQL_ORIGIN, NOTIFYCOMP_WS_ORIGIN } from './lib/remoteConfig'; const httpLink = createHttpLink({ - uri: import.meta.env.VITE_NOTIFYCOMP_API_ORIGIN || 'https://admin.notifycomp.com/graphql', + uri: NOTIFYCOMP_GRAPHQL_ORIGIN, }); const wsLink = new GraphQLWsLink( createClient({ - url: import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || 'wss://admin.notifycomp.com/graphql', + url: NOTIFYCOMP_WS_ORIGIN, + on: { + connecting: () => { + setNotifyCompWebSocketStatus({ status: 'connecting' }); + }, + connected: () => { + setNotifyCompWebSocketStatus({ status: 'connected' }); + }, + closed: () => { + setNotifyCompWebSocketStatus({ status: 'disconnected' }); + }, + error: () => { + setNotifyCompWebSocketStatus({ + status: 'disconnected', + message: + 'Unable to connect to NotifyComp live updates. Activity changes may not update automatically.', + }); + }, + }, }), ); +const authLink = setContext((_, { headers }) => { + const token = getNotifyCompRemoteToken(); + + return { + headers: { + ...headers, + ...(token && { authorization: `Bearer ${token}` }), + }, + }; +}); + const splitLink = split( ({ query }) => { const definition = getMainDefinition(query); return definition.kind === 'OperationDefinition' && definition.operation === 'subscription'; }, wsLink, - httpLink, + authLink.concat(httpLink), ); const client = new ApolloClient({ diff --git a/src/components/CompetitionListItem/CompetitionListItem.tsx b/src/components/CompetitionListItem/CompetitionListItem.tsx index c2744dc..9edbc19 100644 --- a/src/components/CompetitionListItem/CompetitionListItem.tsx +++ b/src/components/CompetitionListItem/CompetitionListItem.tsx @@ -13,6 +13,7 @@ interface CompetitionListItemProps { city: string; isLive: boolean; isBookmarked?: boolean; + variant?: 'card' | 'dropdown'; } export const CompetitionListItem = ({ @@ -24,6 +25,7 @@ export const CompetitionListItem = ({ city, isLive, isBookmarked, + variant = 'card', }: CompetitionListItemProps) => { const endDate = new Date( new Date(end_date).getTime() + 1000 * 60 * new Date().getTimezoneOffset(), @@ -33,7 +35,9 @@ export const CompetitionListItem = ({