Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
fea3281
add notifycomp remote controls
coder13 May 12, 2026
7642e05
switch remote auth to competitiongroups token exchange
coder13 May 12, 2026
32f4e2d
add remote control bar
coder13 May 12, 2026
b51aefd
make remote bar a horizontal player
coder13 May 12, 2026
46935a4
flatten remote page layout
coder13 May 12, 2026
4bd9ccc
add remote description
coder13 May 12, 2026
72b14c3
add notifycomp import and sign in copy
coder13 May 12, 2026
364aa64
move notifycomp sign out to settings
coder13 May 12, 2026
6eb10f5
theme remote bar for competitiongroups
coder13 May 12, 2026
3c68ec1
Simplify remote activity controls
coder13 May 12, 2026
39e1a6a
Confirm remote next activity advancement
coder13 May 12, 2026
567391d
Resume notifycomp remote sign in
coder13 May 12, 2026
eb0f2a3
Remove all-room remote selector option
coder13 May 12, 2026
8b97a12
Refine remote schedule overview
coder13 May 12, 2026
b5a2f4e
Use font awesome remote nav arrows
coder13 May 12, 2026
75e7268
Replace auto advance button with toggle
coder13 May 12, 2026
81ea2fd
Extend remote token lifetime
coder13 May 12, 2026
f57e51f
Fix remote auto advance toggle styling
coder13 May 12, 2026
8e538f1
Align remote toggle with app theme
coder13 May 12, 2026
6bb61d5
Label remote auto advance toggle
coder13 May 12, 2026
316b6ed
Center remote control group
coder13 May 12, 2026
e18851e
Center remote bar content in full width
coder13 May 12, 2026
dd98ba9
Make remote bar progress time based
coder13 May 12, 2026
557ba70
Reset current activity before previous remote start
coder13 May 12, 2026
65a0c54
Hide remote explanation after sign in
coder13 May 12, 2026
ae07fed
Refine remote bar inline layout
coder13 May 12, 2026
915347b
Increase remote bar touch targets
coder13 May 12, 2026
6807d60
Show duration based remote progress
coder13 May 12, 2026
0c6ab0c
Use minute rounding on remote schedule
coder13 May 12, 2026
c42e726
Add off-day remote reset all action
coder13 May 12, 2026
d5a4d12
Improve live activity remote controls
coder13 May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ yarn-error.log*
storybook-static

.cache
.netlify
94 changes: 90 additions & 4 deletions netlify/functions/notify-comp-token.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ const headers = {
};

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' }));
Expand Down Expand Up @@ -42,7 +60,19 @@ exports.handler = async (event) => {
};
}

const { accessToken } = JSON.parse(event.body || '{}');
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,
Expand All @@ -52,7 +82,9 @@ exports.handler = async (event) => {
}

const wcaOrigin = process.env.WCA_ORIGIN || 'https://www.worldcubeassociation.org';
const meResponse = await fetch(`${wcaOrigin}/api/v0/me`, {
const meParams =
tokenScope === REMOTE_SCOPE ? '?upcoming_competitions=true&ongoing_competitions=true' : '';
const meResponse = await fetch(`${wcaOrigin}/api/v0/me${meParams}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
Expand All @@ -66,7 +98,7 @@ exports.handler = async (event) => {
};
}

const { me } = await meResponse.json();
const { me, ongoing_competitions = [], upcoming_competitions = [] } = await meResponse.json();
if (!me?.id) {
return {
statusCode: 401,
Expand All @@ -75,14 +107,68 @@ exports.handler = async (event) => {
};
}

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',
exp: now + 10 * 60,
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,
Expand Down
16 changes: 13 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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,
Expand All @@ -30,12 +31,15 @@ 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 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';
Expand Down Expand Up @@ -108,6 +112,7 @@ const Navigation = () => {

<Route path="psych-sheet" element={<PsychSheet />} />
<Route path="psych-sheet/:eventId" element={<PsychSheetEvent />} />
<Route path="remote" element={<CompetitionRemote />} />
<Route path="results" element={<CompetitionResults />} />
<Route path="results/:roundId" element={<CompetitionResults />} />

Expand All @@ -126,6 +131,7 @@ const Navigation = () => {
</Route>
<Route path="/users/:userId" element={<UserLogin />} />
<Route path="about" element={<About />} />
<Route path="live-activities" element={<LiveActivitiesAbout />} />
<Route path="settings" element={<Settings />} />
<Route path="support" element={<Support />} />
</Route>
Expand All @@ -141,9 +147,13 @@ const App = () => (
<QueryProvider>
<ApolloProvider client={client}>
<BrowserRouter>
<AuthProvider>
<Navigation />
</AuthProvider>
<ConfirmProvider>
<AuthProvider>
<NotifyCompRemoteAuthProvider>
<Navigation />
</NotifyCompRemoteAuthProvider>
</AuthProvider>
</ConfirmProvider>
</BrowserRouter>
</ApolloProvider>
</QueryProvider>
Expand Down
39 changes: 36 additions & 3 deletions src/apolloClient.ts
Original file line number Diff line number Diff line change
@@ -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://api.notifycomp.com/api/graphql',
uri: NOTIFYCOMP_GRAPHQL_ORIGIN,
});

const wsLink = new GraphQLWsLink(
createClient({
url: import.meta.env.VITE_NOTIFYCOMP_WS_ORIGIN || 'wss://api.notifycomp.com/api/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({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import classNames from 'classnames';
import { NoteBox } from '@/components/Notebox';
import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus';

interface NotifyCompConnectionStatusProps {
className?: string;
compact?: boolean;
}

const statusText = {
connecting: 'Connecting to NotifyComp live updates...',
disconnected:
'Not connected to NotifyComp live updates. Activity changes may not update automatically.',
idle: '',
connected: '',
};

export function NotifyCompConnectionStatus({
className,
compact = false,
}: NotifyCompConnectionStatusProps) {
const { message, status } = useNotifyCompWebSocketStatus();

if (status === 'idle' || status === 'connected') {
return null;
}

const text = message || statusText[status];
const toneClassName =
status === 'connecting' ? 'bg-yellow-400 dark:bg-yellow-300' : 'bg-red-500 dark:bg-red-400';

if (compact) {
return (
<div
className={classNames(
'flex items-center justify-center gap-1 text-xs font-medium text-muted',
className,
)}
title={text}>
<span className={classNames('h-2 w-2 rounded-full', toneClassName)} aria-hidden="true" />
<span>{status === 'connecting' ? 'Connecting live updates' : 'Live updates offline'}</span>
</div>
);
}

return <NoteBox className={className} prefix="Live updates" text={text} />;
}
1 change: 1 addition & 0 deletions src/components/NotifyCompConnectionStatus/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './NotifyCompConnectionStatus';
Loading
Loading