Skip to content

Commit b6eb27a

Browse files
authored
Merge pull request #73 from coder13/beta
Deploy beta to production
2 parents 6d8ccc6 + d19c7d6 commit b6eb27a

146 files changed

Lines changed: 11673 additions & 362 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ yarn-error.log*
2727
storybook-static
2828

2929
.cache
30+
.netlify

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,11 @@
11
Use space-y instead of mt-2 for better spacing between elements.
22
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.
3+
4+
## Branch and deployment workflow
5+
6+
- `main` is the GitHub default branch and Netlify production branch.
7+
- `beta` is the permanent integration and beta-testing branch.
8+
- Feature and fix PRs should target `beta`, not `main`.
9+
- Production releases happen by opening or updating the deploy PR from `beta` into `main`.
10+
- Use the manual GitHub Actions `Deploy` workflow to create or update the `beta` -> `main` release PR.
11+
- PRs into `main` are guarded and should only come from `beta`.
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
const crypto = require('crypto');
2+
3+
const headers = {
4+
'Access-Control-Allow-Headers': 'Content-Type',
5+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
6+
'Access-Control-Allow-Origin': '*',
7+
'Content-Type': 'application/json',
8+
};
9+
10+
const base64Url = (value) => Buffer.from(value).toString('base64url');
11+
const REMOTE_SCOPE = 'notifycomp.remote';
12+
const PUSH_SCOPE = 'assignment_notifications';
13+
const REMOTE_TOKEN_TTL_SECONDS = 12 * 60 * 60;
14+
const PUSH_TOKEN_TTL_SECONDS = 10 * 60;
15+
16+
const getCompetitionManagers = (competition) => {
17+
const data = competition?.competition || competition;
18+
return [...(data?.organizers || []), ...(data?.delegates || [])];
19+
};
20+
21+
const isListedCompetitionManager = (competition, userId) =>
22+
getCompetitionManagers(competition).some((user) => Number(user?.id) === Number(userId));
23+
24+
const getManagedCompetitionIds = (competitions, userId) =>
25+
competitions
26+
.filter((competition) => isListedCompetitionManager(competition, userId))
27+
.map((competition) => competition.id)
28+
.filter(Boolean);
29+
30+
const signJwt = (claims, secret) => {
31+
const encodedHeader = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
32+
const encodedPayload = base64Url(JSON.stringify(claims));
33+
const signature = crypto
34+
.createHmac('sha256', secret)
35+
.update(`${encodedHeader}.${encodedPayload}`)
36+
.digest('base64url');
37+
38+
return `${encodedHeader}.${encodedPayload}.${signature}`;
39+
};
40+
41+
exports.handler = async (event) => {
42+
if (event.httpMethod === 'OPTIONS') {
43+
return { statusCode: 204, headers };
44+
}
45+
46+
if (event.httpMethod !== 'POST') {
47+
return {
48+
statusCode: 405,
49+
headers,
50+
body: JSON.stringify({ message: 'Method not allowed' }),
51+
};
52+
}
53+
54+
const secret = process.env.COMPETITION_GROUPS_JWT_SECRET;
55+
if (!secret) {
56+
return {
57+
statusCode: 500,
58+
headers,
59+
body: JSON.stringify({ message: 'Notification token secret is not configured' }),
60+
};
61+
}
62+
63+
let body;
64+
try {
65+
body = JSON.parse(event.body || '{}');
66+
} catch {
67+
return {
68+
statusCode: 400,
69+
headers,
70+
body: JSON.stringify({ message: 'Invalid JSON body' }),
71+
};
72+
}
73+
74+
const { accessToken, competitionId, scope } = body;
75+
const tokenScope = scope === REMOTE_SCOPE ? REMOTE_SCOPE : PUSH_SCOPE;
76+
if (!accessToken) {
77+
return {
78+
statusCode: 400,
79+
headers,
80+
body: JSON.stringify({ message: 'Missing WCA access token' }),
81+
};
82+
}
83+
84+
const wcaOrigin = process.env.WCA_ORIGIN || 'https://www.worldcubeassociation.org';
85+
const meParams =
86+
tokenScope === REMOTE_SCOPE ? '?upcoming_competitions=true&ongoing_competitions=true' : '';
87+
const meResponse = await fetch(`${wcaOrigin}/api/v0/me${meParams}`, {
88+
headers: {
89+
Authorization: `Bearer ${accessToken}`,
90+
},
91+
});
92+
93+
if (!meResponse.ok) {
94+
return {
95+
statusCode: 401,
96+
headers,
97+
body: JSON.stringify({ message: 'Invalid WCA access token' }),
98+
};
99+
}
100+
101+
const { me, ongoing_competitions = [], upcoming_competitions = [] } = await meResponse.json();
102+
if (!me?.id) {
103+
return {
104+
statusCode: 401,
105+
headers,
106+
body: JSON.stringify({ message: 'Unable to resolve WCA user' }),
107+
};
108+
}
109+
110+
const tokenTtlSeconds =
111+
tokenScope === REMOTE_SCOPE ? REMOTE_TOKEN_TTL_SECONDS : PUSH_TOKEN_TTL_SECONDS;
112+
if (tokenScope === REMOTE_SCOPE && !competitionId) {
113+
return {
114+
statusCode: 400,
115+
headers,
116+
body: JSON.stringify({ message: 'Missing competition ID for remote token' }),
117+
};
118+
}
119+
120+
let remoteCompetitionIds = [];
121+
122+
if (tokenScope === REMOTE_SCOPE) {
123+
const competitionResponse = await fetch(`${wcaOrigin}/api/v0/competitions/${competitionId}`, {
124+
headers: {
125+
Authorization: `Bearer ${accessToken}`,
126+
},
127+
});
128+
129+
if (!competitionResponse.ok) {
130+
return {
131+
statusCode: competitionResponse.status === 404 ? 404 : 502,
132+
headers,
133+
body: JSON.stringify({ message: 'Unable to verify competition remote access' }),
134+
};
135+
}
136+
137+
const competition = await competitionResponse.json();
138+
if (!isListedCompetitionManager(competition, me.id)) {
139+
return {
140+
statusCode: 403,
141+
headers,
142+
body: JSON.stringify({
143+
message:
144+
'Only listed competition delegates and organizers can use Live Activities Remote',
145+
}),
146+
};
147+
}
148+
149+
// Remote tokens are scoped to competitions the WCA API says this user manages. The
150+
// NotifyComp API must reject remote mutations for competition IDs outside this claim.
151+
remoteCompetitionIds = [
152+
...new Set([
153+
competitionId,
154+
...getManagedCompetitionIds([...ongoing_competitions, ...upcoming_competitions], me.id),
155+
]),
156+
];
157+
}
158+
159+
const now = Math.floor(Date.now() / 1000);
160+
const token = signJwt(
161+
{
162+
aud: process.env.COMPETITION_GROUPS_JWT_AUDIENCE || 'notifycomp',
163+
competitionIds: tokenScope === REMOTE_SCOPE ? remoteCompetitionIds : undefined,
164+
exp: now + tokenTtlSeconds,
165+
iat: now,
166+
iss: process.env.COMPETITION_GROUPS_JWT_ISSUER || 'competitiongroups.com',
167+
name: me.name,
168+
scope: tokenScope,
169+
scopes: [tokenScope],
170+
sub: `wca:${me.id}`,
171+
wcaUserId: me.id,
172+
wcaUserIds: [me.id],
173+
},
174+
secret,
175+
);
176+
177+
return {
178+
statusCode: 200,
179+
headers,
180+
body: JSON.stringify({ token }),
181+
};
182+
};

public/notification-sw.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
self.addEventListener('push', (event) => {
2+
if (!event.data) {
3+
return;
4+
}
5+
6+
const payload = event.data.json();
7+
const title = payload.title || 'Assignment update';
8+
const options = {
9+
body: payload.body,
10+
data: payload,
11+
tag: payload.dedupeKey || 'assignment-change',
12+
};
13+
14+
event.waitUntil(self.registration.showNotification(title, options));
15+
});
16+
17+
self.addEventListener('notificationclick', (event) => {
18+
event.notification.close();
19+
20+
const targetUrl = event.notification.data?.url || '/settings';
21+
const url = new URL(targetUrl, self.location.origin).href;
22+
23+
event.waitUntil(
24+
self.clients.matchAll({ includeUncontrolled: true, type: 'window' }).then((clients) => {
25+
const existingClient = clients.find((client) => client.url === url);
26+
27+
if (existingClient) {
28+
return existingClient.focus();
29+
}
30+
31+
return self.clients.openWindow(url);
32+
}),
33+
);
34+
});

src/App.tsx

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import client from './apolloClient';
55
import { usePageTracking } from './hooks/usePageTracking';
66
import { CompetitionLayout } from './layouts/CompetitionLayout';
77
import { RootLayout } from './layouts/RootLayout';
8+
import { FEATURE_FLAGS } from './lib/featureFlags';
89
import About from './pages/About';
10+
import CompetitionAdmin from './pages/Competition/Admin';
911
import CompetitionEvents from './pages/Competition/ByGroup/Events';
1012
import CompetitionGroup from './pages/Competition/ByGroup/Group';
1113
import CompetitionGroupList from './pages/Competition/ByGroup/GroupList';
@@ -18,6 +20,8 @@ import CompetitionLive from './pages/Competition/Live';
1820
import CompetitionPerson from './pages/Competition/Person';
1921
import CompetitionPersonalBests from './pages/Competition/Person/PersonalBests';
2022
import { PsychSheetEvent } from './pages/Competition/PsychSheet/PsychSheetEvent';
23+
import CompetitionRemote from './pages/Competition/Remote';
24+
import CompetitionResults from './pages/Competition/Results';
2125
import {
2226
CompetitionActivity,
2327
CompetitionRoom,
@@ -27,13 +31,18 @@ import {
2731
import CompetitionScramblerSchedule from './pages/Competition/ScramblerSchedule';
2832
import CompetitionStats from './pages/Competition/Stats';
2933
import CompetitionStreamSchedule from './pages/Competition/StreamSchedule';
34+
import CompetitionSumOfRanks from './pages/Competition/SumOfRanks';
3035
import Home from './pages/Home';
36+
import LiveActivitiesAbout from './pages/LiveActivities/About';
3137
import Settings from './pages/Settings';
3238
import Support from './pages/Support';
3339
import Test from './pages/Test';
40+
import UserPage from './pages/User';
3441
import UserLogin from './pages/UserLogin';
3542
import { AppProvider } from './providers/AppProvider';
3643
import { AuthProvider, useAuth } from './providers/AuthProvider';
44+
import { ConfirmProvider } from './providers/ConfirmProvider';
45+
import { NotifyCompRemoteAuthProvider } from './providers/NotifyCompRemoteAuthProvider';
3746
import { QueryProvider } from './providers/QueryProvider/QueryProvider';
3847
import { UserSettingsProvider } from './providers/UserSettingsProvider';
3948
import { useWCIF } from './providers/WCIFProvider';
@@ -79,6 +88,30 @@ const PsychSheet = () => {
7988
return null;
8089
};
8190

91+
const CompetitionPersonByWcaIdRedirect = ({ to }: { to: 'results' | 'records' }) => {
92+
const { competitionId, wcaId } = useParams() as { competitionId: string; wcaId: string };
93+
const { wcif } = useWCIF();
94+
const person = wcif?.persons.find((p) => p.wcaId?.toUpperCase() === wcaId.toUpperCase());
95+
96+
if (!wcif) {
97+
return null;
98+
}
99+
100+
if (!person) {
101+
return <Navigate to={`/competitions/${competitionId}`} replace />;
102+
}
103+
104+
return (
105+
<Navigate to={`/competitions/${competitionId}/persons/${person.registrantId}/${to}`} replace />
106+
);
107+
};
108+
109+
const CompetitionRedirect = ({ to }: { to: string }) => {
110+
const { competitionId } = useParams() as { competitionId: string };
111+
112+
return <Navigate to={`/competitions/${competitionId}/${to}`} replace />;
113+
};
114+
82115
const Navigation = () => {
83116
usePageTracking(import.meta.env.VITE_GA_MEASUREMENT_ID);
84117

@@ -90,7 +123,15 @@ const Navigation = () => {
90123
<Route path="/competitions/:competitionId" element={<CompetitionLayout />}>
91124
<Route index element={<CompetitionHome />} />
92125

93-
<Route path="persons/:registrantId" element={<CompetitionPerson />} />
126+
<Route
127+
path="persons/wca/:wcaId/results"
128+
element={<CompetitionPersonByWcaIdRedirect to="results" />}
129+
/>
130+
<Route
131+
path="persons/wca/:wcaId/records"
132+
element={<CompetitionPersonByWcaIdRedirect to="records" />}
133+
/>
134+
<Route path="persons/:registrantId/*" element={<CompetitionPerson />} />
94135
<Route path="personal-bests/:wcaId" element={<CompetitionPersonalBests />} />
95136
<Route path="personal-records/:wcaId" element={<CompetitionPersonalBests />} />
96137
<Route path="compare-schedules" element={<CompetitionCompareSchedules />} />
@@ -106,8 +147,16 @@ const Navigation = () => {
106147

107148
<Route path="psych-sheet" element={<PsychSheet />} />
108149
<Route path="psych-sheet/:eventId" element={<PsychSheetEvent />} />
109-
110-
<Route path="scramblers" element={<CompetitionScramblerSchedule />} />
150+
<Route path="results" element={<CompetitionResults />} />
151+
<Route path="results/:roundId" element={<CompetitionResults />} />
152+
153+
<Route path="admin" element={<CompetitionAdmin />} />
154+
<Route path="admin/remote" element={<CompetitionRemote />} />
155+
<Route path="admin/scramblers" element={<CompetitionScramblerSchedule />} />
156+
<Route path="admin/stats" element={<CompetitionStats />} />
157+
<Route path="admin/sum-of-ranks" element={<CompetitionSumOfRanks />} />
158+
<Route path="remote" element={<CompetitionRedirect to="admin/remote" />} />
159+
<Route path="scramblers" element={<CompetitionRedirect to="admin/scramblers" />} />
111160
<Route path="stream" element={<CompetitionStreamSchedule />} />
112161
<Route path="information" element={<CompetitionInformation />} />
113162
<Route path="live" element={<CompetitionLive />} />
@@ -116,11 +165,23 @@ const Navigation = () => {
116165
<Route path="personal-schedule" element={<PersonalSchedule />} />
117166
<Route path="explore" element={<CompetitionGroupsOverview />} />
118167
<Route path="groups-schedule" element={<CompetitionGroupsSchedule />} />
119-
<Route path="stats" element={<CompetitionStats />} />
168+
<Route path="stats" element={<CompetitionRedirect to="admin/stats" />} />
169+
<Route path="sum-of-ranks" element={<CompetitionRedirect to="admin/sum-of-ranks" />} />
120170
<Route path="*" element={<p>Path not resolved</p>} />
121171
</Route>
122172
<Route path="/users/:userId" element={<UserLogin />} />
173+
{FEATURE_FLAGS.personalUserPage && (
174+
<>
175+
<Route path="/me" element={<Navigate to="/me/competitions" replace />} />
176+
<Route
177+
path="/me/results/:resultsMode"
178+
element={<Navigate to="/me/results" replace />}
179+
/>
180+
<Route path="/me/:tab" element={<UserPage />} />
181+
</>
182+
)}
123183
<Route path="about" element={<About />} />
184+
<Route path="live-activities" element={<LiveActivitiesAbout />} />
124185
<Route path="settings" element={<Settings />} />
125186
<Route path="support" element={<Support />} />
126187
</Route>
@@ -136,9 +197,13 @@ const App = () => (
136197
<QueryProvider>
137198
<ApolloProvider client={client}>
138199
<BrowserRouter>
139-
<AuthProvider>
140-
<Navigation />
141-
</AuthProvider>
200+
<ConfirmProvider>
201+
<AuthProvider>
202+
<NotifyCompRemoteAuthProvider>
203+
<Navigation />
204+
</NotifyCompRemoteAuthProvider>
205+
</AuthProvider>
206+
</ConfirmProvider>
142207
</BrowserRouter>
143208
</ApolloProvider>
144209
</QueryProvider>

0 commit comments

Comments
 (0)