Skip to content

Commit d5a4d12

Browse files
committed
Improve live activity remote controls
1 parent c42e726 commit d5a4d12

44 files changed

Lines changed: 1781 additions & 279 deletions

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

netlify/functions/notify-comp-token.js

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ const PUSH_SCOPE = 'assignment_notifications';
1313
const REMOTE_TOKEN_TTL_SECONDS = 12 * 60 * 60;
1414
const PUSH_TOKEN_TTL_SECONDS = 10 * 60;
1515

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+
1630
const signJwt = (claims, secret) => {
1731
const encodedHeader = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
1832
const encodedPayload = base64Url(JSON.stringify(claims));
@@ -58,6 +72,7 @@ exports.handler = async (event) => {
5872
}
5973

6074
const { accessToken, competitionId, scope } = body;
75+
const tokenScope = scope === REMOTE_SCOPE ? REMOTE_SCOPE : PUSH_SCOPE;
6176
if (!accessToken) {
6277
return {
6378
statusCode: 400,
@@ -67,7 +82,9 @@ exports.handler = async (event) => {
6782
}
6883

6984
const wcaOrigin = process.env.WCA_ORIGIN || 'https://www.worldcubeassociation.org';
70-
const meResponse = await fetch(`${wcaOrigin}/api/v0/me`, {
85+
const meParams =
86+
tokenScope === REMOTE_SCOPE ? '?upcoming_competitions=true&ongoing_competitions=true' : '';
87+
const meResponse = await fetch(`${wcaOrigin}/api/v0/me${meParams}`, {
7188
headers: {
7289
Authorization: `Bearer ${accessToken}`,
7390
},
@@ -81,7 +98,7 @@ exports.handler = async (event) => {
8198
};
8299
}
83100

84-
const { me } = await meResponse.json();
101+
const { me, ongoing_competitions = [], upcoming_competitions = [] } = await meResponse.json();
85102
if (!me?.id) {
86103
return {
87104
statusCode: 401,
@@ -90,7 +107,6 @@ exports.handler = async (event) => {
90107
};
91108
}
92109

93-
const tokenScope = scope === REMOTE_SCOPE ? REMOTE_SCOPE : PUSH_SCOPE;
94110
const tokenTtlSeconds =
95111
tokenScope === REMOTE_SCOPE ? REMOTE_TOKEN_TTL_SECONDS : PUSH_TOKEN_TTL_SECONDS;
96112
if (tokenScope === REMOTE_SCOPE && !competitionId) {
@@ -101,11 +117,50 @@ exports.handler = async (event) => {
101117
};
102118
}
103119

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+
104159
const now = Math.floor(Date.now() / 1000);
105160
const token = signJwt(
106161
{
107162
aud: process.env.COMPETITION_GROUPS_JWT_AUDIENCE || 'notifycomp',
108-
competitionIds: tokenScope === REMOTE_SCOPE ? [competitionId] : undefined,
163+
competitionIds: tokenScope === REMOTE_SCOPE ? remoteCompetitionIds : undefined,
109164
exp: now + tokenTtlSeconds,
110165
iat: now,
111166
iss: process.env.COMPETITION_GROUPS_JWT_ISSUER || 'competitiongroups.com',

src/App.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,14 @@ import CompetitionStats from './pages/Competition/Stats';
3131
import CompetitionStreamSchedule from './pages/Competition/StreamSchedule';
3232
import CompetitionSumOfRanks from './pages/Competition/SumOfRanks';
3333
import Home from './pages/Home';
34+
import LiveActivitiesAbout from './pages/LiveActivities/About';
3435
import Settings from './pages/Settings';
3536
import Support from './pages/Support';
3637
import Test from './pages/Test';
3738
import UserLogin from './pages/UserLogin';
3839
import { AppProvider } from './providers/AppProvider';
3940
import { AuthProvider, useAuth } from './providers/AuthProvider';
41+
import { ConfirmProvider } from './providers/ConfirmProvider';
4042
import { NotifyCompRemoteAuthProvider } from './providers/NotifyCompRemoteAuthProvider';
4143
import { QueryProvider } from './providers/QueryProvider/QueryProvider';
4244
import { UserSettingsProvider } from './providers/UserSettingsProvider';
@@ -129,6 +131,7 @@ const Navigation = () => {
129131
</Route>
130132
<Route path="/users/:userId" element={<UserLogin />} />
131133
<Route path="about" element={<About />} />
134+
<Route path="live-activities" element={<LiveActivitiesAbout />} />
132135
<Route path="settings" element={<Settings />} />
133136
<Route path="support" element={<Support />} />
134137
</Route>
@@ -144,11 +147,13 @@ const App = () => (
144147
<QueryProvider>
145148
<ApolloProvider client={client}>
146149
<BrowserRouter>
147-
<AuthProvider>
148-
<NotifyCompRemoteAuthProvider>
149-
<Navigation />
150-
</NotifyCompRemoteAuthProvider>
151-
</AuthProvider>
150+
<ConfirmProvider>
151+
<AuthProvider>
152+
<NotifyCompRemoteAuthProvider>
153+
<Navigation />
154+
</NotifyCompRemoteAuthProvider>
155+
</AuthProvider>
156+
</ConfirmProvider>
152157
</BrowserRouter>
153158
</ApolloProvider>
154159
</QueryProvider>

src/apolloClient.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
44
import { getMainDefinition } from '@apollo/client/utilities';
55
import { createClient } from 'graphql-ws';
66
import { getNotifyCompRemoteToken } from './lib/notifyCompRemoteAuth';
7+
import { setNotifyCompWebSocketStatus } from './lib/notifyCompWebSocketStatus';
78
import { NOTIFYCOMP_GRAPHQL_ORIGIN, NOTIFYCOMP_WS_ORIGIN } from './lib/remoteConfig';
89

910
const httpLink = createHttpLink({
@@ -13,6 +14,24 @@ const httpLink = createHttpLink({
1314
const wsLink = new GraphQLWsLink(
1415
createClient({
1516
url: NOTIFYCOMP_WS_ORIGIN,
17+
on: {
18+
connecting: () => {
19+
setNotifyCompWebSocketStatus({ status: 'connecting' });
20+
},
21+
connected: () => {
22+
setNotifyCompWebSocketStatus({ status: 'connected' });
23+
},
24+
closed: () => {
25+
setNotifyCompWebSocketStatus({ status: 'disconnected' });
26+
},
27+
error: () => {
28+
setNotifyCompWebSocketStatus({
29+
status: 'disconnected',
30+
message:
31+
'Unable to connect to NotifyComp live updates. Activity changes may not update automatically.',
32+
});
33+
},
34+
},
1635
}),
1736
);
1837

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import classNames from 'classnames';
2+
import { NoteBox } from '@/components/Notebox';
3+
import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus';
4+
5+
interface NotifyCompConnectionStatusProps {
6+
className?: string;
7+
compact?: boolean;
8+
}
9+
10+
const statusText = {
11+
connecting: 'Connecting to NotifyComp live updates...',
12+
disconnected:
13+
'Not connected to NotifyComp live updates. Activity changes may not update automatically.',
14+
idle: '',
15+
connected: '',
16+
};
17+
18+
export function NotifyCompConnectionStatus({
19+
className,
20+
compact = false,
21+
}: NotifyCompConnectionStatusProps) {
22+
const { message, status } = useNotifyCompWebSocketStatus();
23+
24+
if (status === 'idle' || status === 'connected') {
25+
return null;
26+
}
27+
28+
const text = message || statusText[status];
29+
const toneClassName =
30+
status === 'connecting' ? 'bg-yellow-400 dark:bg-yellow-300' : 'bg-red-500 dark:bg-red-400';
31+
32+
if (compact) {
33+
return (
34+
<div
35+
className={classNames(
36+
'flex items-center justify-center gap-1 text-xs font-medium text-muted',
37+
className,
38+
)}
39+
title={text}>
40+
<span className={classNames('h-2 w-2 rounded-full', toneClassName)} aria-hidden="true" />
41+
<span>{status === 'connecting' ? 'Connecting live updates' : 'Live updates offline'}</span>
42+
</div>
43+
);
44+
}
45+
46+
return <NoteBox className={className} prefix="Live updates" text={text} />;
47+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './NotifyCompConnectionStatus';

0 commit comments

Comments
 (0)