diff --git a/src/App.tsx b/src/App.tsx
index 648f747..f7eb726 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -21,6 +21,7 @@ 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 CompetitionRemoteWebhooks from './pages/Competition/Remote/Webhooks';
import CompetitionResults from './pages/Competition/Results';
import {
CompetitionActivity,
@@ -152,10 +153,12 @@ const Navigation = () => {
} />
} />
+ } />
} />
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/containers/CompetitionRound/CompetitionRound.test.tsx b/src/containers/CompetitionRound/CompetitionRound.test.tsx
index 7e627dc..b949b53 100644
--- a/src/containers/CompetitionRound/CompetitionRound.test.tsx
+++ b/src/containers/CompetitionRound/CompetitionRound.test.tsx
@@ -57,6 +57,10 @@ jest.mock('react-i18next', () => ({
return `Round ${options?.roundNumber}`;
}
+ if (key === 'common.activityCodeToName.group') {
+ return `Group ${options?.groupNumber}`;
+ }
+
return key;
},
}),
@@ -89,7 +93,15 @@ const linkedRoundsCompetition = {
cutoff: null,
timeLimit: null,
advancementCondition: null,
- results: [],
+ results: [
+ {
+ personId: 1,
+ ranking: 1,
+ attempts: [],
+ best: 1000,
+ average: 1200,
+ },
+ ],
},
{
id: '333-r3',
@@ -195,9 +207,29 @@ describe('CompetitionRoundContainer', () => {
it('links to results for the selected round', () => {
renderRound('333-r2');
- expect(screen.getByText('See Results')).toHaveAttribute(
- 'href',
- '/competitions/TestComp2026/results/333-r2',
+ const resultsLink = screen.getByText('See Results');
+
+ expect(resultsLink).toHaveAttribute('href', '/competitions/TestComp2026/results/333-r2');
+ expect(screen.getByText('Group 1').compareDocumentPosition(resultsLink)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
);
});
+
+ it('uses shared button spacing for the back link', () => {
+ renderRound('333-r2');
+
+ expect(screen.getByText('Back To Events')).toHaveClass('btn', 'btn-block');
+ });
+
+ it('shows when the selected round has results', () => {
+ renderRound('333-r2');
+
+ expect(screen.getByText('See Results')).toHaveClass('btn-green');
+ });
+
+ it('shows a neutral results link when the selected round has no results yet', () => {
+ renderRound('333-r3');
+
+ expect(screen.getByText('See Results')).toHaveClass('btn-light');
+ });
});
diff --git a/src/containers/CompetitionRound/CompetitionRound.tsx b/src/containers/CompetitionRound/CompetitionRound.tsx
index 16e5201..eb3c87e 100644
--- a/src/containers/CompetitionRound/CompetitionRound.tsx
+++ b/src/containers/CompetitionRound/CompetitionRound.tsx
@@ -60,6 +60,7 @@ export function CompetitionRoundContainer({
const rounds = roundActivies.filter((ra) => toRoundAttemptId(ra.activityCode) === roundId);
const groups = rounds.flatMap((r) => r.childActivities);
const uniqueGroupCodes = [...new Set(groups.map((g) => g.activityCode))];
+ const hasResults = (round?.results.length ?? 0) > 0;
return (
@@ -79,14 +80,6 @@ export function CompetitionRoundContainer({
)}
{round && }
- {round && (
-
- )}
@@ -111,12 +104,22 @@ export function CompetitionRoundContainer({
);
})}
+ {round && (
+
+
+
+ )}
-
- {t('competition.groups.backToEvents')}
-
+ title={t('competition.groups.backToEvents')}
+ LinkComponent={LinkComponent}
+ />
);
diff --git a/src/containers/MyCompetitions/MyCompetitions.query.ts b/src/containers/MyCompetitions/MyCompetitions.query.ts
index 1be874c..1932a9b 100644
--- a/src/containers/MyCompetitions/MyCompetitions.query.ts
+++ b/src/containers/MyCompetitions/MyCompetitions.query.ts
@@ -25,12 +25,18 @@ export const useMyCompetitionsQuery = (userId?: number, options: { enabled?: boo
return undefined;
}
- const upcoming_competitions = JSON.parse(
- getLocalStorage('my.upcoming_competitions') || '[]',
- ) as ApiCompetition[];
- const ongoing_competitions = JSON.parse(
- getLocalStorage('my.ongoing_competitions') || '[]',
- ) as ApiCompetition[];
+ const rawUpcomingCompetitions = getLocalStorage('my.upcoming_competitions');
+ const rawOngoingCompetitions = getLocalStorage('my.ongoing_competitions');
+ if (!rawUpcomingCompetitions && !rawOngoingCompetitions) {
+ return undefined;
+ }
+
+ const upcoming_competitions = JSON.parse(rawUpcomingCompetitions || '[]') as ApiCompetition[];
+ const ongoing_competitions = JSON.parse(rawOngoingCompetitions || '[]') as ApiCompetition[];
+
+ if (!upcoming_competitions.length && !ongoing_competitions.length) {
+ return undefined;
+ }
return { user: user, upcoming_competitions, ongoing_competitions };
},
diff --git a/src/hooks/useNotifyCompRemoteWebhooks/index.ts b/src/hooks/useNotifyCompRemoteWebhooks/index.ts
new file mode 100644
index 0000000..66cb51e
--- /dev/null
+++ b/src/hooks/useNotifyCompRemoteWebhooks/index.ts
@@ -0,0 +1 @@
+export { useNotifyCompRemoteWebhooks } from './useNotifyCompRemoteWebhooks';
diff --git a/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.test.tsx b/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.test.tsx
new file mode 100644
index 0000000..6d8c5ab
--- /dev/null
+++ b/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.test.tsx
@@ -0,0 +1,224 @@
+import { MockedProvider, MockedResponse } from '@apollo/client/testing';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { PropsWithChildren } from 'react';
+import {
+ CreateRemoteWebhookDocument,
+ DeleteRemoteWebhookDocument,
+ RemoteWebhooksDocument,
+ TestEditingRemoteWebhookDocument,
+ TestRemoteWebhookDocument,
+ UpdateRemoteWebhookDocument,
+} from '@/lib/notifyCompRemoteGraphql';
+import { useNotifyCompRemoteWebhooks } from './useNotifyCompRemoteWebhooks';
+
+const competitionId = 'ExampleComp2026';
+
+const webhooksMock = (webhooks: unknown[]): MockedResponse => ({
+ request: {
+ query: RemoteWebhooksDocument,
+ variables: { competitionId },
+ },
+ result: {
+ data: {
+ competition: {
+ __typename: 'Competition',
+ id: competitionId,
+ webhooks,
+ },
+ },
+ },
+});
+
+const createWrapper = (mocks: MockedResponse[]) =>
+ function MockedApolloWrapper({ children }: PropsWithChildren) {
+ return {children};
+ };
+
+describe('useNotifyCompRemoteWebhooks', () => {
+ it('loads competition webhooks', async () => {
+ const { result } = renderHook(() => useNotifyCompRemoteWebhooks({ competitionId }), {
+ wrapper: createWrapper([
+ webhooksMock([
+ {
+ __typename: 'Webhook',
+ id: 1,
+ method: 'POST',
+ url: 'https://example.com/notify',
+ },
+ ]),
+ ]),
+ });
+
+ await waitFor(() => {
+ expect(result.current.webhooks).toHaveLength(1);
+ });
+
+ expect(result.current.webhooks[0]).toMatchObject({
+ method: 'POST',
+ url: 'https://example.com/notify',
+ });
+ });
+
+ it('creates, updates, and deletes webhooks with NotifyComp variables', async () => {
+ const createVariables = {
+ competitionId,
+ webhook: {
+ method: 'POST' as const,
+ url: 'https://example.com/created',
+ },
+ };
+ const updateVariables = {
+ id: 4,
+ webhook: {
+ method: 'GET' as const,
+ url: 'https://example.com/updated',
+ },
+ };
+
+ const { result } = renderHook(() => useNotifyCompRemoteWebhooks({ competitionId }), {
+ wrapper: createWrapper([
+ webhooksMock([]),
+ {
+ request: {
+ query: CreateRemoteWebhookDocument,
+ variables: createVariables,
+ },
+ result: {
+ data: {
+ createWebhook: {
+ __typename: 'Webhook',
+ id: 4,
+ ...createVariables.webhook,
+ },
+ },
+ },
+ },
+ webhooksMock([
+ {
+ __typename: 'Webhook',
+ id: 4,
+ ...createVariables.webhook,
+ },
+ ]),
+ {
+ request: {
+ query: UpdateRemoteWebhookDocument,
+ variables: updateVariables,
+ },
+ result: {
+ data: {
+ updateWebhook: {
+ __typename: 'Webhook',
+ id: 4,
+ ...updateVariables.webhook,
+ },
+ },
+ },
+ },
+ webhooksMock([
+ {
+ __typename: 'Webhook',
+ id: 4,
+ ...updateVariables.webhook,
+ },
+ ]),
+ {
+ request: {
+ query: DeleteRemoteWebhookDocument,
+ variables: { id: 4 },
+ },
+ result: {
+ data: {
+ deleteWebhook: null,
+ },
+ },
+ },
+ webhooksMock([]),
+ ]),
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ await act(async () => {
+ await result.current.saveWebhook(createVariables.webhook);
+ });
+ await act(async () => {
+ await result.current.saveWebhook(updateVariables.webhook, 4);
+ });
+ await act(async () => {
+ await result.current.removeWebhook(4);
+ });
+
+ expect(result.current.error).toBeNull();
+ });
+
+ it('tests saved and unsaved webhook settings', async () => {
+ const webhook = {
+ method: 'PUT' as const,
+ url: 'https://example.com/test',
+ };
+ const { result } = renderHook(() => useNotifyCompRemoteWebhooks({ competitionId }), {
+ wrapper: createWrapper([
+ webhooksMock([]),
+ {
+ request: {
+ query: TestRemoteWebhookDocument,
+ variables: { id: 8 },
+ },
+ result: {
+ data: {
+ testWebhook: {
+ __typename: 'WebhookResponse',
+ body: 'ok',
+ status: 200,
+ statusText: 'OK',
+ url: webhook.url,
+ },
+ },
+ },
+ },
+ {
+ request: {
+ query: TestEditingRemoteWebhookDocument,
+ variables: { competitionId, webhook },
+ },
+ result: {
+ data: {
+ testEditingWebhook: {
+ __typename: 'WebhookResponse',
+ body: 'failed',
+ status: 500,
+ statusText: 'Server Error',
+ url: webhook.url,
+ },
+ },
+ },
+ },
+ ]),
+ });
+
+ await waitFor(() => {
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ await act(async () => {
+ await result.current.testSavedWebhook(8);
+ });
+
+ expect(result.current.testResult).toMatchObject({
+ status: 200,
+ statusText: 'OK',
+ });
+
+ await act(async () => {
+ await result.current.testWebhookSettings(webhook);
+ });
+
+ expect(result.current.testResult).toMatchObject({
+ status: 500,
+ statusText: 'Server Error',
+ });
+ });
+});
diff --git a/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.ts b/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.ts
new file mode 100644
index 0000000..72f96b1
--- /dev/null
+++ b/src/hooks/useNotifyCompRemoteWebhooks/useNotifyCompRemoteWebhooks.ts
@@ -0,0 +1,133 @@
+import { useMutation, useQuery } from '@apollo/client';
+import { useMemo, useState } from 'react';
+import {
+ CreateRemoteWebhookDocument,
+ DeleteRemoteWebhookDocument,
+ NotifyCompWebhook,
+ NotifyCompWebhookInput,
+ NotifyCompWebhookResponse,
+ RemoteWebhooksDocument,
+ TestEditingRemoteWebhookDocument,
+ TestRemoteWebhookDocument,
+ UpdateRemoteWebhookDocument,
+} from '@/lib/notifyCompRemoteGraphql';
+
+interface UseNotifyCompRemoteWebhooksParams {
+ competitionId: string;
+ enabled?: boolean;
+}
+
+interface RemoteWebhooksQueryData {
+ competition: {
+ id: string;
+ webhooks: NotifyCompWebhook[];
+ } | null;
+}
+
+export function useNotifyCompRemoteWebhooks({
+ competitionId,
+ enabled = true,
+}: UseNotifyCompRemoteWebhooksParams) {
+ const [mutationError, setMutationError] = useState(null);
+ const [testResult, setTestResult] = useState(null);
+
+ const webhooksQuery = useQuery(RemoteWebhooksDocument, {
+ variables: { competitionId },
+ skip: !competitionId || !enabled,
+ });
+
+ const refetchQueries = useMemo(
+ () => [
+ {
+ query: RemoteWebhooksDocument,
+ variables: { competitionId },
+ },
+ ],
+ [competitionId],
+ );
+
+ const mutationOptions = {
+ refetchQueries,
+ };
+
+ const [createWebhook, createWebhookStatus] = useMutation<
+ { createWebhook: NotifyCompWebhook },
+ { competitionId: string; webhook: NotifyCompWebhookInput }
+ >(CreateRemoteWebhookDocument, mutationOptions);
+ const [updateWebhook, updateWebhookStatus] = useMutation<
+ { updateWebhook: NotifyCompWebhook },
+ { id: number; webhook: NotifyCompWebhookInput }
+ >(UpdateRemoteWebhookDocument, mutationOptions);
+ const [deleteWebhook, deleteWebhookStatus] = useMutation<
+ { deleteWebhook?: null },
+ { id: number }
+ >(DeleteRemoteWebhookDocument, mutationOptions);
+ const [testWebhook, testWebhookStatus] = useMutation<
+ { testWebhook: NotifyCompWebhookResponse | null },
+ { id: number }
+ >(TestRemoteWebhookDocument);
+ const [testEditingWebhook, testEditingWebhookStatus] = useMutation<
+ { testEditingWebhook: NotifyCompWebhookResponse | null },
+ { competitionId: string; webhook: NotifyCompWebhookInput }
+ >(TestEditingRemoteWebhookDocument);
+
+ const runMutation = async (operation: () => Promise) => {
+ setMutationError(null);
+ setTestResult(null);
+
+ try {
+ await operation();
+ } catch (err) {
+ setMutationError(err instanceof Error ? err.message : 'Webhook operation failed.');
+ throw err;
+ }
+ };
+
+ const saveWebhook = (webhook: NotifyCompWebhookInput, id?: number) =>
+ runMutation(() =>
+ id
+ ? updateWebhook({
+ variables: { id, webhook },
+ })
+ : createWebhook({
+ variables: { competitionId, webhook },
+ }),
+ );
+
+ const removeWebhook = (id: number) =>
+ runMutation(() =>
+ deleteWebhook({
+ variables: { id },
+ }),
+ );
+
+ const testSavedWebhook = (id: number) =>
+ runMutation(async () => {
+ const result = await testWebhook({
+ variables: { id },
+ });
+ setTestResult(result.data?.testWebhook || null);
+ });
+
+ const testWebhookSettings = (webhook: NotifyCompWebhookInput) =>
+ runMutation(async () => {
+ const result = await testEditingWebhook({
+ variables: { competitionId, webhook },
+ });
+ setTestResult(result.data?.testEditingWebhook || null);
+ });
+
+ return {
+ error: mutationError || webhooksQuery.error?.message || null,
+ isLoading: webhooksQuery.loading,
+ isSaving:
+ createWebhookStatus.loading || updateWebhookStatus.loading || deleteWebhookStatus.loading,
+ isTesting: testWebhookStatus.loading || testEditingWebhookStatus.loading,
+ removeWebhook,
+ saveWebhook,
+ testResult,
+ testSavedWebhook,
+ testWebhookSettings,
+ webhooks: webhooksQuery.data?.competition?.webhooks || [],
+ };
+}
diff --git a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx
index 7e66508..22db621 100644
--- a/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx
+++ b/src/layouts/CompetitionLayout/CompetitionLayout.tabs.tsx
@@ -71,12 +71,20 @@ export const useCompetitionLayoutTabs = ({ competitionId, wcif }: CompetitionLay
}
if (canManageRemote) {
- _adminTabs.push({
- href: `/competitions/${competitionId}/admin/remote`,
- text: t('header.tabs.remote', {
- defaultValue: 'Remote',
- }),
- });
+ _adminTabs.push(
+ {
+ href: `/competitions/${competitionId}/admin/remote`,
+ text: t('header.tabs.remote', {
+ defaultValue: 'Remote',
+ }),
+ },
+ {
+ href: `/competitions/${competitionId}/admin/webhooks`,
+ text: t('header.tabs.webhooks', {
+ defaultValue: 'Webhooks',
+ }),
+ },
+ );
}
if (hasStream) {
diff --git a/src/lib/notifications/assignmentNotifications.ts b/src/lib/notifications/assignmentNotifications.ts
index e97cb13..ebfe566 100644
--- a/src/lib/notifications/assignmentNotifications.ts
+++ b/src/lib/notifications/assignmentNotifications.ts
@@ -1,5 +1,10 @@
import { deleteLocalStorage, getLocalStorage, setLocalStorage } from '@/lib/localStorage';
import { getStoredWcaAccessToken } from '@/lib/wcaAccessToken';
+import {
+ clearNotifyCompPushSessionToken,
+ getNotifyCompPushSessionToken,
+ setNotifyCompPushSessionToken,
+} from './notifyCompPushSession';
const NOTIFY_COMP_ORIGIN =
import.meta.env.VITE_NOTIFY_COMP_ORIGIN ?? 'https://api.notifycomp.com/api';
@@ -15,6 +20,17 @@ interface PushSubscriptionJson {
};
}
+interface PushSessionResponse {
+ sessionToken?: string;
+ token?: string;
+}
+
+interface PushSubscriptionPayload {
+ endpoint: string;
+ p256dh: string;
+ auth: string;
+}
+
export interface AssignmentNotificationWatch {
competitionId: string;
wcaUserId: number;
@@ -25,7 +41,7 @@ export type AssignmentNotificationStatus = NotificationPermission | 'reauthorize
const notifyCompUrl = (path: string) => `${NOTIFY_COMP_ORIGIN}${path}`;
export const isAssignmentNotificationsEnabled = () =>
- getLocalStorage(ENABLED_STORAGE_KEY) === 'true';
+ Boolean(getNotifyCompPushSessionToken()) || getLocalStorage(ENABLED_STORAGE_KEY) === 'true';
const toUint8Array = (base64: string) => {
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
@@ -49,7 +65,7 @@ export const getAssignmentNotificationStatus = (): AssignmentNotificationStatus
return 'unsupported';
}
- if (!getStoredWcaAccessToken()) {
+ if (!getNotifyCompPushSessionToken() && !getStoredWcaAccessToken()) {
return 'reauthorize';
}
@@ -105,6 +121,9 @@ const fetchNotifyCompToken = async () => {
return payload.token;
};
+const canFallbackToLegacySubscriptions = (response: Response) =>
+ response.status === 404 || response.status === 405;
+
const fetchVapidPublicKey = async () => {
const response = await fetch(notifyCompUrl('/v0/external/push/vapid-public-key'));
@@ -153,30 +172,33 @@ const getPushSubscription = async () => {
});
};
-export const enableAssignmentNotifications = async (watches: AssignmentNotificationWatch[]) => {
- const permission = await requestAssignmentNotificationPermission();
- if (permission !== 'granted') {
- return permission;
- }
-
- const token = await fetchNotifyCompToken();
- const subscription = await getPushSubscription();
+const pushSubscriptionPayload = (subscription: PushSubscription): PushSubscriptionPayload => {
const payload = subscription.toJSON() as PushSubscriptionJson;
if (!payload.endpoint || !payload.keys?.p256dh || !payload.keys.auth) {
throw new Error('Browser push subscription is missing required keys.');
}
+ return {
+ auth: payload.keys.auth,
+ endpoint: payload.endpoint,
+ p256dh: payload.keys.p256dh,
+ };
+};
+
+const registerLegacySubscription = async (
+ authToken: string,
+ payload: PushSubscriptionPayload,
+ watches: AssignmentNotificationWatch[],
+) => {
const response = await fetch(notifyCompUrl('/v0/external/push/subscriptions'), {
method: 'POST',
headers: {
- Authorization: `Bearer ${token}`,
+ Authorization: `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
- endpoint: payload.endpoint,
- p256dh: payload.keys.p256dh,
- auth: payload.keys.auth,
+ ...payload,
watches,
}),
});
@@ -185,34 +207,150 @@ export const enableAssignmentNotifications = async (watches: AssignmentNotificat
deleteLocalStorage(ENABLED_STORAGE_KEY);
throw new Error(await readErrorMessage(response));
}
+};
+
+const createPushSession = async (
+ authToken: string,
+ payload: PushSubscriptionPayload,
+ watches: AssignmentNotificationWatch[],
+) => {
+ const response = await fetch(notifyCompUrl('/v0/external/push/sessions'), {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ ...payload,
+ watches,
+ }),
+ });
+
+ if (canFallbackToLegacySubscriptions(response)) {
+ await registerLegacySubscription(authToken, payload, watches);
+ return;
+ }
+
+ if (!response.ok) {
+ clearNotifyCompPushSessionToken();
+ deleteLocalStorage(ENABLED_STORAGE_KEY);
+ throw new Error(await readErrorMessage(response));
+ }
+
+ const session = (await response.json()) as PushSessionResponse;
+ const sessionToken = session.sessionToken || session.token;
+ if (!sessionToken) {
+ clearNotifyCompPushSessionToken();
+ deleteLocalStorage(ENABLED_STORAGE_KEY);
+ throw new Error('NotifyComp push session response was missing a session token.');
+ }
+
+ setNotifyCompPushSessionToken(sessionToken);
+};
+
+const updatePushSession = async (
+ sessionToken: string,
+ payload: PushSubscriptionPayload,
+ watches: AssignmentNotificationWatch[],
+) => {
+ const response = await fetch(notifyCompUrl('/v0/external/push/sessions/current'), {
+ method: 'PUT',
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ ...payload,
+ watches,
+ }),
+ });
+
+ if (response.status === 401 || response.status === 403) {
+ clearNotifyCompPushSessionToken();
+ return false;
+ }
+
+ if (!response.ok) {
+ throw new Error(await readErrorMessage(response));
+ }
+
+ return true;
+};
+
+export const enableAssignmentNotifications = async (watches: AssignmentNotificationWatch[]) => {
+ const permission = await requestAssignmentNotificationPermission();
+ if (permission !== 'granted') {
+ return permission;
+ }
+
+ const subscription = await getPushSubscription();
+ const payload = pushSubscriptionPayload(subscription);
+ const sessionToken = getNotifyCompPushSessionToken();
+
+ if (sessionToken && (await updatePushSession(sessionToken, payload, watches))) {
+ setLocalStorage(ENABLED_STORAGE_KEY, 'true');
+ return permission;
+ }
+
+ await createPushSession(await fetchNotifyCompToken(), payload, watches);
setLocalStorage(ENABLED_STORAGE_KEY, 'true');
return permission;
};
+const deletePushSession = async (sessionToken: string, endpoint?: string) => {
+ const response = await fetch(notifyCompUrl('/v0/external/push/sessions/current'), {
+ method: 'DELETE',
+ headers: {
+ Authorization: `Bearer ${sessionToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ endpoint }),
+ });
+
+ if (
+ !response.ok &&
+ response.status !== 401 &&
+ response.status !== 403 &&
+ response.status !== 404
+ ) {
+ throw new Error(await readErrorMessage(response));
+ }
+};
+
+const deleteLegacySubscription = async (endpoint: string) => {
+ const token = await fetchNotifyCompToken();
+
+ await fetch(notifyCompUrl('/v0/external/push/subscriptions'), {
+ method: 'DELETE',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ endpoint }),
+ });
+};
+
export const disableAssignmentNotifications = async () => {
const registration = await getServiceWorkerRegistration();
const subscription = await registration.pushManager.getSubscription();
+ const sessionToken = getNotifyCompPushSessionToken();
if (!subscription) {
+ clearNotifyCompPushSessionToken();
deleteLocalStorage(ENABLED_STORAGE_KEY);
return;
}
- const token = await fetchNotifyCompToken();
const payload = subscription.toJSON() as PushSubscriptionJson;
- if (payload.endpoint) {
- await fetch(notifyCompUrl('/v0/external/push/subscriptions'), {
- method: 'DELETE',
- headers: {
- Authorization: `Bearer ${token}`,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ endpoint: payload.endpoint }),
- });
+ if (sessionToken) {
+ await deletePushSession(sessionToken, payload.endpoint);
+ } else if (payload.endpoint) {
+ await deleteLegacySubscription(payload.endpoint);
}
await subscription.unsubscribe();
+ clearNotifyCompPushSessionToken();
deleteLocalStorage(ENABLED_STORAGE_KEY);
};
diff --git a/src/lib/notifications/notifyCompPushSession.test.ts b/src/lib/notifications/notifyCompPushSession.test.ts
new file mode 100644
index 0000000..6c01dc4
--- /dev/null
+++ b/src/lib/notifications/notifyCompPushSession.test.ts
@@ -0,0 +1,53 @@
+import {
+ clearNotifyCompPushSessionToken,
+ getNotifyCompPushSessionToken,
+ setNotifyCompPushSessionToken,
+} from './notifyCompPushSession';
+
+jest.mock('@/lib/localStorage', () => ({
+ deleteLocalStorage: (key: string) => localStorage.removeItem(key),
+ getLocalStorage: (key: string) => localStorage.getItem(key),
+ setLocalStorage: (key: string, value: string) => localStorage.setItem(key, value),
+}));
+
+const base64Url = (value: unknown) =>
+ Buffer.from(JSON.stringify(value)).toString('base64url').replace(/=/g, '');
+
+const jwtWithClaims = (claims: Record) =>
+ `${base64Url({ alg: 'HS256', typ: 'JWT' })}.${base64Url(claims)}.signature`;
+
+describe('notifyCompPushSession', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ clearNotifyCompPushSessionToken();
+ });
+
+ it('stores opaque NotifyComp push session tokens', () => {
+ setNotifyCompPushSessionToken('opaque-session-token');
+
+ expect(getNotifyCompPushSessionToken()).toBe('opaque-session-token');
+ });
+
+ it('reads unpadded JWT push session tokens', () => {
+ const token = jwtWithClaims({
+ exp: Math.floor(Date.now() / 1000) + 60,
+ });
+
+ setNotifyCompPushSessionToken(token);
+
+ expect(getNotifyCompPushSessionToken()).toBe(token);
+ });
+
+ it('clears expired JWT push session tokens', () => {
+ setNotifyCompPushSessionToken(
+ jwtWithClaims({
+ exp: Math.floor(Date.now() / 1000) - 60,
+ }),
+ );
+
+ expect(getNotifyCompPushSessionToken()).toBe(null);
+ });
+});
diff --git a/src/lib/notifications/notifyCompPushSession.ts b/src/lib/notifications/notifyCompPushSession.ts
new file mode 100644
index 0000000..95f2e07
--- /dev/null
+++ b/src/lib/notifications/notifyCompPushSession.ts
@@ -0,0 +1,45 @@
+import { deleteLocalStorage, getLocalStorage, setLocalStorage } from '@/lib/localStorage';
+
+const PUSH_SESSION_TOKEN_KEY = 'notifyComp.pushSessionToken';
+
+interface JwtClaims {
+ exp?: number;
+}
+
+const decodeJwtPayload = (token: string): JwtClaims | null => {
+ try {
+ const payload = token.split('.')[1];
+ if (!payload) {
+ return null;
+ }
+
+ const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
+ const normalized = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
+ return JSON.parse(window.atob(normalized)) as JwtClaims;
+ } catch {
+ return null;
+ }
+};
+
+export const getNotifyCompPushSessionToken = () => {
+ const token = getLocalStorage(PUSH_SESSION_TOKEN_KEY);
+ if (!token) {
+ return null;
+ }
+
+ const claims = decodeJwtPayload(token);
+ if (claims?.exp && claims.exp * 1000 <= Date.now()) {
+ deleteLocalStorage(PUSH_SESSION_TOKEN_KEY);
+ return null;
+ }
+
+ return token;
+};
+
+export const setNotifyCompPushSessionToken = (token: string) => {
+ setLocalStorage(PUSH_SESSION_TOKEN_KEY, token);
+};
+
+export const clearNotifyCompPushSessionToken = () => {
+ deleteLocalStorage(PUSH_SESSION_TOKEN_KEY);
+};
diff --git a/src/lib/notifyCompRemoteGraphql.ts b/src/lib/notifyCompRemoteGraphql.ts
index ddaf9ce..1cb945b 100644
--- a/src/lib/notifyCompRemoteGraphql.ts
+++ b/src/lib/notifyCompRemoteGraphql.ts
@@ -14,6 +14,26 @@ export interface NotifyCompCompetition {
autoAdvanceDelay?: number | null;
}
+export type HttpMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
+
+export interface NotifyCompWebhook {
+ id: number;
+ method: HttpMethod;
+ url: string;
+}
+
+export interface NotifyCompWebhookInput {
+ method: HttpMethod;
+ url: string;
+}
+
+export interface NotifyCompWebhookResponse {
+ body?: string | null;
+ status: number;
+ statusText: string;
+ url: string;
+}
+
export const RemoteActivityFragment = gql`
fragment RemoteActivityFragment on Activity {
activityId
@@ -34,6 +54,23 @@ export const RemoteCompetitionDocument = gql`
}
`;
+export const RemoteWebhookFragment = gql`
+ fragment RemoteWebhookFragment on Webhook {
+ id
+ url
+ method
+ }
+`;
+
+export const RemoteWebhookResponseFragment = gql`
+ fragment RemoteWebhookResponseFragment on WebhookResponse {
+ url
+ status
+ statusText
+ body
+ }
+`;
+
export const RemoteActivitiesDocument = gql`
query RemoteActivities($competitionId: String!, $roomId: Int) {
activities(competitionId: $competitionId, roomId: $roomId) {
@@ -43,6 +80,18 @@ export const RemoteActivitiesDocument = gql`
${RemoteActivityFragment}
`;
+export const RemoteWebhooksDocument = gql`
+ query RemoteWebhooks($competitionId: String!) {
+ competition(competitionId: $competitionId) {
+ id
+ webhooks {
+ ...RemoteWebhookFragment
+ }
+ }
+ }
+ ${RemoteWebhookFragment}
+`;
+
export const RemoteActivitiesSubscriptionDocument = gql`
subscription RemoteActivities($competitionIds: [String!]!) {
activity: activityUpdated(competitionIds: $competitionIds) {
@@ -52,6 +101,48 @@ export const RemoteActivitiesSubscriptionDocument = gql`
${RemoteActivityFragment}
`;
+export const CreateRemoteWebhookDocument = gql`
+ mutation CreateRemoteWebhook($competitionId: String!, $webhook: WebhookInput!) {
+ createWebhook(competitionId: $competitionId, webhook: $webhook) {
+ ...RemoteWebhookFragment
+ }
+ }
+ ${RemoteWebhookFragment}
+`;
+
+export const UpdateRemoteWebhookDocument = gql`
+ mutation UpdateRemoteWebhook($id: Int!, $webhook: WebhookInput!) {
+ updateWebhook(id: $id, webhook: $webhook) {
+ ...RemoteWebhookFragment
+ }
+ }
+ ${RemoteWebhookFragment}
+`;
+
+export const DeleteRemoteWebhookDocument = gql`
+ mutation DeleteRemoteWebhook($id: Int!) {
+ deleteWebhook(id: $id)
+ }
+`;
+
+export const TestRemoteWebhookDocument = gql`
+ mutation TestRemoteWebhook($id: Int!) {
+ testWebhook(id: $id) {
+ ...RemoteWebhookResponseFragment
+ }
+ }
+ ${RemoteWebhookResponseFragment}
+`;
+
+export const TestEditingRemoteWebhookDocument = gql`
+ mutation TestEditingRemoteWebhook($competitionId: String!, $webhook: WebhookInput!) {
+ testEditingWebhook(competitionId: $competitionId, webhook: $webhook) {
+ ...RemoteWebhookResponseFragment
+ }
+ }
+ ${RemoteWebhookResponseFragment}
+`;
+
export const ImportRemoteCompetitionDocument = gql`
mutation ImportRemoteCompetition($competitionId: String!) {
importCompetition(competitionId: $competitionId) {
diff --git a/src/pages/Competition/Admin/index.stories.tsx b/src/pages/Competition/Admin/index.stories.tsx
new file mode 100644
index 0000000..7d0b93c
--- /dev/null
+++ b/src/pages/Competition/Admin/index.stories.tsx
@@ -0,0 +1,56 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { storybookCompetitionFixture } from '@/storybook/competitionFixtures';
+import { makeCompetitionContainerDecorator } from '@/storybook/competitionStorybook';
+import CompetitionAdmin from './index';
+
+const adminUser: User = {
+ id: 9001,
+ name: 'Nick Silvestri',
+ email: '',
+ wca_id: '2016SILV08',
+ avatar: {
+ url: 'https://avatars.worldcubeassociation.org/nsg38gkpoch8xiji3hodmrs672m4',
+ thumb_url: 'https://avatars.worldcubeassociation.org/uge6fzvlpmz6c8ztn8ey5wi4i8uf',
+ },
+ delegate_status: 'delegate',
+};
+
+const nonAdminUser: User = {
+ id: 1005,
+ name: 'Eva Park',
+ email: '',
+ wca_id: '2026PARK05',
+ avatar: {
+ url: '',
+ thumb_url: '',
+ },
+ delegate_status: '',
+};
+
+const meta = {
+ title: 'Pages/Competition/Admin',
+ component: CompetitionAdmin,
+ decorators: [makeCompetitionContainerDecorator()],
+ parameters: {
+ layout: 'padded',
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const AvailableTools: Story = {
+ parameters: {
+ competition: storybookCompetitionFixture,
+ currentUser: adminUser,
+ },
+};
+
+export const NoAvailableTools: Story = {
+ parameters: {
+ competition: storybookCompetitionFixture,
+ currentUser: nonAdminUser,
+ },
+};
diff --git a/src/pages/Competition/Admin/index.tsx b/src/pages/Competition/Admin/index.tsx
index 32bcb24..4b336ee 100644
--- a/src/pages/Competition/Admin/index.tsx
+++ b/src/pages/Competition/Admin/index.tsx
@@ -7,6 +7,7 @@ import { useWCIF } from '@/providers/WCIFProvider';
const adminDescriptions: Record = {
scramblers: 'View scrambler assignments by event and round.',
remote: 'Control Live Activities updates for the competition.',
+ webhooks: 'Send Live Activities updates to external services.',
'sum-of-ranks': 'Review competitor rankings across completed rounds.',
stats: 'View competition registration and event counts.',
};
diff --git a/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.stories.tsx b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.stories.tsx
new file mode 100644
index 0000000..820d140
--- /dev/null
+++ b/src/pages/Competition/Remote/RemoteAutoAdvanceToggle.stories.tsx
@@ -0,0 +1,66 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { useState } from 'react';
+import { RemoteAutoAdvanceToggle } from './RemoteAutoAdvanceToggle';
+
+interface AutoAdvanceStoryArgs {
+ checked: boolean;
+ disabled: boolean;
+}
+
+function AutoAdvanceStory({ checked, disabled }: AutoAdvanceStoryArgs) {
+ const [isChecked, setIsChecked] = useState(checked);
+
+ return (
+
+
+
Auto-advance
+
+ Automatically moves Live Activities to the next scheduled group when the current group
+ ends.
+
+
+
+ {
+ setIsChecked((currentValue) => !currentValue);
+ }}
+ />
+
+
+ );
+}
+
+const meta = {
+ title: 'Pages/Competition/Remote/Auto Advance Toggle',
+ component: AutoAdvanceStory,
+ render: (args) => ,
+ args: {
+ checked: false,
+ disabled: false,
+ },
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Off: Story = {};
+
+export const On: Story = {
+ args: {
+ checked: true,
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ checked: true,
+ disabled: true,
+ },
+};
diff --git a/src/pages/Competition/Remote/RemoteWebhookSetup.stories.tsx b/src/pages/Competition/Remote/RemoteWebhookSetup.stories.tsx
new file mode 100644
index 0000000..317c63e
--- /dev/null
+++ b/src/pages/Competition/Remote/RemoteWebhookSetup.stories.tsx
@@ -0,0 +1,157 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { useState } from 'react';
+import { ConfirmProvider } from '@/providers/ConfirmProvider';
+import { RemoteWebhookSetupController, RemoteWebhookSetupPanel } from './RemoteWebhookSetup';
+
+type StoryWebhook = RemoteWebhookSetupController['webhooks'][number];
+type StoryTestResult = RemoteWebhookSetupController['testResult'];
+
+interface WebhookSetupStoryArgs {
+ disabled: boolean;
+ error: string | null;
+ initialTestResult: StoryTestResult;
+ initialWebhooks: StoryWebhook[];
+ isLoading: boolean;
+}
+
+const savedWebhooks: StoryWebhook[] = [
+ {
+ id: 1,
+ method: 'POST',
+ url: 'https://hooks.example.com/live-activities',
+ },
+ {
+ id: 2,
+ method: 'GET',
+ url: 'https://status.example.com/ping',
+ },
+];
+
+function WebhookSetupStory({
+ disabled,
+ error,
+ initialTestResult,
+ initialWebhooks,
+ isLoading,
+}: WebhookSetupStoryArgs) {
+ const [webhooks, setWebhooks] = useState(initialWebhooks);
+ const [testResult, setTestResult] = useState(initialTestResult);
+
+ const controller: RemoteWebhookSetupController = {
+ error,
+ isLoading,
+ isSaving: false,
+ isTesting: false,
+ removeWebhook: async (id) => {
+ setWebhooks((currentWebhooks) => currentWebhooks.filter((webhook) => webhook.id !== id));
+ },
+ saveWebhook: async (webhook, id) => {
+ setWebhooks((currentWebhooks) => {
+ if (id) {
+ return currentWebhooks.map((currentWebhook) =>
+ currentWebhook.id === id ? { id, ...webhook } : currentWebhook,
+ );
+ }
+
+ return [
+ ...currentWebhooks,
+ {
+ id: Math.max(0, ...currentWebhooks.map((currentWebhook) => currentWebhook.id)) + 1,
+ ...webhook,
+ },
+ ];
+ });
+ },
+ testResult,
+ testSavedWebhook: async (id) => {
+ const webhook = webhooks.find((currentWebhook) => currentWebhook.id === id);
+
+ setTestResult({
+ body: '{"ok":true,"message":"Ping accepted"}',
+ status: 202,
+ statusText: 'Accepted',
+ url: webhook?.url || 'https://hooks.example.com/live-activities',
+ });
+ },
+ testWebhookSettings: async (webhook) => {
+ setTestResult({
+ body: 'Draft webhook settings responded successfully.',
+ status: 200,
+ statusText: 'OK',
+ url: webhook.url,
+ });
+ },
+ webhooks,
+ };
+
+ return ;
+}
+
+const meta = {
+ title: 'Pages/Competition/Remote/Webhook Setup',
+ component: WebhookSetupStory,
+ render: (args) => ,
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+ args: {
+ disabled: false,
+ error: null,
+ initialTestResult: null,
+ initialWebhooks: savedWebhooks,
+ isLoading: false,
+ },
+ parameters: {
+ layout: 'padded',
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const SavedWebhooks: Story = {};
+
+export const Empty: Story = {
+ args: {
+ initialWebhooks: [],
+ },
+};
+
+export const TestResult: Story = {
+ args: {
+ initialTestResult: {
+ body: '{"ok":true,"received":1}',
+ status: 200,
+ statusText: 'OK',
+ url: 'https://hooks.example.com/live-activities',
+ },
+ },
+};
+
+export const Loading: Story = {
+ args: {
+ initialWebhooks: [],
+ isLoading: true,
+ },
+};
+
+export const Error: Story = {
+ args: {
+ error: 'Webhook URL must use HTTPS',
+ initialWebhooks: [],
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ disabled: true,
+ },
+};
diff --git a/src/pages/Competition/Remote/RemoteWebhookSetup.test.tsx b/src/pages/Competition/Remote/RemoteWebhookSetup.test.tsx
new file mode 100644
index 0000000..d39f2c2
--- /dev/null
+++ b/src/pages/Competition/Remote/RemoteWebhookSetup.test.tsx
@@ -0,0 +1,198 @@
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useNotifyCompRemoteWebhooks } from '@/hooks/useNotifyCompRemoteWebhooks';
+import { ConfirmProvider } from '@/providers/ConfirmProvider';
+import { RemoteWebhookSetup } from './RemoteWebhookSetup';
+
+jest.mock('@/hooks/useNotifyCompRemoteWebhooks', () => ({
+ useNotifyCompRemoteWebhooks: jest.fn(),
+}));
+
+const competitionId = 'ExampleComp2026';
+
+const defaultHookValue = {
+ error: null,
+ isLoading: false,
+ isSaving: false,
+ isTesting: false,
+ removeWebhook: jest.fn().mockResolvedValue(undefined),
+ saveWebhook: jest.fn().mockResolvedValue(undefined),
+ testResult: null,
+ testSavedWebhook: jest.fn().mockResolvedValue(undefined),
+ testWebhookSettings: jest.fn().mockResolvedValue(undefined),
+ webhooks: [],
+};
+
+const renderSetup = (hookValue = {}) => {
+ const value = {
+ ...defaultHookValue,
+ ...hookValue,
+ };
+ jest
+ .mocked(useNotifyCompRemoteWebhooks)
+ .mockReturnValue(value as unknown as ReturnType);
+
+ render(
+
+
+ ,
+ );
+
+ return value;
+};
+
+describe('RemoteWebhookSetup', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('lists saved webhooks with method and URL', () => {
+ renderSetup({
+ webhooks: [
+ {
+ id: 1,
+ method: 'POST',
+ url: 'https://example.com/notify',
+ },
+ ],
+ });
+
+ expect(screen.getByRole('heading', { name: 'Webhook setup' })).toBeInTheDocument();
+ expect(screen.getByText('POST')).toBeInTheDocument();
+ expect(screen.getByText('https://example.com/notify')).toBeInTheDocument();
+ });
+
+ it('creates a webhook from the form', async () => {
+ const user = userEvent.setup();
+ const hook = renderSetup();
+
+ await act(async () => {
+ await user.click(screen.getByRole('button', { name: 'Add webhook' }));
+ });
+ await act(async () => {
+ await user.selectOptions(screen.getByLabelText('HTTP method'), 'GET');
+ await user.type(screen.getByLabelText('URL'), 'https://example.com/new');
+ await user.click(screen.getByRole('button', { name: 'Create webhook' }));
+ });
+
+ await waitFor(() => {
+ expect(hook.saveWebhook).toHaveBeenCalledWith(
+ {
+ method: 'GET',
+ url: 'https://example.com/new',
+ },
+ undefined,
+ );
+ });
+ });
+
+ it('updates an existing webhook from the edit form', async () => {
+ const user = userEvent.setup();
+ const hook = renderSetup({
+ webhooks: [
+ {
+ id: 2,
+ method: 'POST',
+ url: 'https://example.com/old',
+ },
+ ],
+ });
+
+ await act(async () => {
+ await user.click(screen.getByRole('button', { name: 'Edit' }));
+ });
+ await act(async () => {
+ await user.clear(screen.getByLabelText('URL'));
+ await user.type(screen.getByLabelText('URL'), 'https://example.com/updated');
+ await user.click(screen.getByRole('button', { name: 'Save webhook' }));
+ });
+
+ await waitFor(() => {
+ expect(hook.saveWebhook).toHaveBeenCalledWith(
+ {
+ method: 'POST',
+ url: 'https://example.com/updated',
+ },
+ 2,
+ );
+ });
+ });
+
+ it('deletes a webhook after confirmation', async () => {
+ const user = userEvent.setup();
+ const hook = renderSetup({
+ webhooks: [
+ {
+ id: 3,
+ method: 'DELETE',
+ url: 'https://example.com/remove',
+ },
+ ],
+ });
+
+ await act(async () => {
+ await user.click(screen.getByRole('button', { name: 'Delete' }));
+ });
+ await act(async () => {
+ await user.click(screen.getByRole('button', { name: 'Delete webhook' }));
+ });
+
+ await waitFor(() => {
+ expect(hook.removeWebhook).toHaveBeenCalledWith(3);
+ });
+ });
+
+ it('tests saved and unsaved webhook settings', async () => {
+ const user = userEvent.setup();
+ const hook = renderSetup({
+ testResult: {
+ body: 'Request accepted',
+ status: 202,
+ statusText: 'Accepted',
+ url: 'https://example.com/test',
+ },
+ webhooks: [
+ {
+ id: 4,
+ method: 'POST',
+ url: 'https://example.com/test',
+ },
+ ],
+ });
+
+ await act(async () => {
+ await user.click(screen.getByRole('button', { name: 'Test' }));
+ });
+
+ expect(hook.testSavedWebhook).toHaveBeenCalledWith(4);
+
+ await act(async () => {
+ await user.click(screen.getByRole('button', { name: 'Add webhook' }));
+ });
+ await act(async () => {
+ await user.type(screen.getByLabelText('URL'), 'https://example.com/draft');
+ await user.click(screen.getAllByRole('button', { name: 'Test' })[0]);
+ });
+
+ expect(hook.testWebhookSettings).toHaveBeenCalledWith({
+ method: 'POST',
+ url: 'https://example.com/draft',
+ });
+ expect(screen.getByText('202 Accepted')).toBeInTheDocument();
+ expect(screen.getByText('Request accepted')).toBeInTheDocument();
+ });
+
+ it('renders failed test results', () => {
+ renderSetup({
+ testResult: {
+ body: '',
+ status: 0,
+ statusText: '',
+ url: 'https://example.com/fail',
+ },
+ });
+
+ expect(screen.getByText('0 Request failed')).toBeInTheDocument();
+ expect(screen.getByText('Empty response body')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/Competition/Remote/RemoteWebhookSetup.tsx b/src/pages/Competition/Remote/RemoteWebhookSetup.tsx
new file mode 100644
index 0000000..0b95d05
--- /dev/null
+++ b/src/pages/Competition/Remote/RemoteWebhookSetup.tsx
@@ -0,0 +1,347 @@
+import { FormEvent, useEffect, useState } from 'react';
+import { BarLoader } from 'react-spinners';
+import { Button } from '@/components/Button';
+import { NoteBox } from '@/components/Notebox';
+import { useNotifyCompRemoteWebhooks } from '@/hooks/useNotifyCompRemoteWebhooks';
+import {
+ HttpMethod,
+ NotifyCompWebhook,
+ NotifyCompWebhookInput,
+ NotifyCompWebhookResponse,
+} from '@/lib/notifyCompRemoteGraphql';
+import { useConfirm } from '@/providers/ConfirmProvider';
+
+const HTTP_METHODS: HttpMethod[] = ['POST', 'GET', 'PUT', 'PATCH', 'DELETE'];
+const BODY_SUMMARY_LENGTH = 300;
+
+interface WebhookFormState {
+ id?: number;
+ method: HttpMethod;
+ url: string;
+}
+
+export interface RemoteWebhookSetupController {
+ error: string | null;
+ isLoading: boolean;
+ isSaving: boolean;
+ isTesting: boolean;
+ removeWebhook: (id: number) => Promise;
+ saveWebhook: (webhook: NotifyCompWebhookInput, id?: number) => Promise;
+ testResult: NotifyCompWebhookResponse | null;
+ testSavedWebhook: (id: number) => Promise;
+ testWebhookSettings: (webhook: NotifyCompWebhookInput) => Promise;
+ webhooks: NotifyCompWebhook[];
+}
+
+interface RemoteWebhookSetupPanelProps {
+ disabled?: boolean;
+ webhooks: RemoteWebhookSetupController;
+}
+
+interface RemoteWebhookSetupProps {
+ competitionId: string;
+ disabled?: boolean;
+}
+
+const emptyForm: WebhookFormState = {
+ method: 'POST',
+ url: '',
+};
+
+const webhookToForm = (webhook: NotifyCompWebhook): WebhookFormState => ({
+ id: webhook.id,
+ method: webhook.method,
+ url: webhook.url,
+});
+
+const toWebhookInput = (form: WebhookFormState): NotifyCompWebhookInput => ({
+ method: form.method,
+ url: form.url.trim(),
+});
+
+const bodySummary = (body?: string | null) => {
+ const trimmedBody = body?.trim();
+
+ if (!trimmedBody) {
+ return 'Empty response body';
+ }
+
+ return trimmedBody.length > BODY_SUMMARY_LENGTH
+ ? `${trimmedBody.slice(0, BODY_SUMMARY_LENGTH)}...`
+ : trimmedBody;
+};
+
+function WebhookTestResult({ result }: { result: NotifyCompWebhookResponse }) {
+ const statusText = result.statusText || (result.status === 0 ? 'Request failed' : 'No text');
+
+ return (
+
+
+ Test result
+
+ {result.status} {statusText}
+
+
+
{result.url}
+
+ {bodySummary(result.body)}
+
+
+ );
+}
+
+interface WebhookFormProps {
+ disabled: boolean;
+ form: WebhookFormState;
+ isSaving: boolean;
+ isTesting: boolean;
+ onCancel: () => void;
+ onChange: (form: WebhookFormState) => void;
+ onSave: (form: WebhookFormState) => void;
+ onTest: (form: WebhookFormState) => void;
+}
+
+function WebhookForm({
+ disabled,
+ form,
+ isSaving,
+ isTesting,
+ onCancel,
+ onChange,
+ onSave,
+ onTest,
+}: WebhookFormProps) {
+ const isBusy = disabled || isSaving || isTesting;
+ const canSubmit = Boolean(form.url.trim()) && !isBusy;
+
+ const submit = (event: FormEvent) => {
+ event.preventDefault();
+
+ if (canSubmit) {
+ onSave(form);
+ }
+ };
+
+ return (
+
+ );
+}
+
+export function RemoteWebhookSetupPanel({
+ disabled = false,
+ webhooks,
+}: RemoteWebhookSetupPanelProps) {
+ const confirm = useConfirm();
+ const [form, setForm] = useState(null);
+
+ useEffect(() => {
+ if (form?.id) {
+ const latestWebhook = webhooks.webhooks.find((webhook) => webhook.id === form.id);
+
+ if (latestWebhook) {
+ setForm(webhookToForm(latestWebhook));
+ }
+ }
+ }, [form?.id, webhooks.webhooks]);
+
+ const controlsDisabled = disabled || webhooks.isSaving || webhooks.isTesting;
+
+ const saveWebhook = async (nextForm: WebhookFormState) => {
+ try {
+ await webhooks.saveWebhook(toWebhookInput(nextForm), nextForm.id);
+ setForm(null);
+ } catch {
+ // The hook exposes the mutation error inline.
+ }
+ };
+
+ const removeWebhook = async (webhook: NotifyCompWebhook) => {
+ const confirmed = await confirm({
+ confirmLabel: 'Delete webhook',
+ confirmVariant: 'gray',
+ message: `Delete the ${webhook.method} webhook for ${webhook.url}?`,
+ });
+
+ if (confirmed) {
+ try {
+ await webhooks.removeWebhook(webhook.id);
+ } catch {
+ // The hook exposes the mutation error inline.
+ }
+ }
+ };
+
+ const testWebhookSettings = async (nextForm: WebhookFormState) => {
+ try {
+ await webhooks.testWebhookSettings(toWebhookInput(nextForm));
+ } catch {
+ // The hook exposes the mutation error inline.
+ }
+ };
+
+ const testSavedWebhook = async (webhook: NotifyCompWebhook) => {
+ try {
+ await webhooks.testSavedWebhook(webhook.id);
+ } catch {
+ // The hook exposes the mutation error inline.
+ }
+ };
+
+ return (
+
+
+
+
+
Webhook setup
+
+ Notify external services when Live Activities are started or stopped.
+
+
+
+
+
+ {webhooks.isLoading &&
}
+ {webhooks.error &&
}
+
+ {form && (
+
{
+ setForm(null);
+ }}
+ onChange={setForm}
+ onSave={(nextForm) => {
+ void saveWebhook(nextForm);
+ }}
+ onTest={(nextForm) => {
+ void testWebhookSettings(nextForm);
+ }}
+ />
+ )}
+
+ {webhooks.testResult && }
+
+ {!webhooks.isLoading && webhooks.webhooks.length === 0 ? (
+
+ ) : (
+
+ {webhooks.webhooks.map((webhook) => (
+
+
+
+
+ {webhook.method}
+
+ {webhook.url}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+export function RemoteWebhookSetup({ competitionId, disabled = false }: RemoteWebhookSetupProps) {
+ const webhooks = useNotifyCompRemoteWebhooks({ competitionId });
+
+ return ;
+}
diff --git a/src/pages/Competition/Remote/Webhooks.stories.tsx b/src/pages/Competition/Remote/Webhooks.stories.tsx
new file mode 100644
index 0000000..a30ce91
--- /dev/null
+++ b/src/pages/Competition/Remote/Webhooks.stories.tsx
@@ -0,0 +1,127 @@
+import { MockedProvider, MockedResponse } from '@apollo/client/testing';
+import type { Meta, StoryObj } from '@storybook/react';
+import { ReactNode } from 'react';
+import { RemoteCompetitionDocument, RemoteWebhooksDocument } from '@/lib/notifyCompRemoteGraphql';
+import { ConfirmProvider } from '@/providers/ConfirmProvider';
+import {
+ NotifyCompRemoteAuthContext,
+ NotifyCompRemoteAuthContextValue,
+} from '@/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext';
+import { storybookCompetitionFixture } from '@/storybook/competitionFixtures';
+import {
+ makeCompetitionContainerDecorator,
+ makeRouteDecorator,
+} from '@/storybook/competitionStorybook';
+import CompetitionRemoteWebhooks from './Webhooks';
+
+const competitionId = storybookCompetitionFixture.id;
+
+const remoteAdminUser: User = {
+ id: 9001,
+ name: 'Nick Silvestri',
+ email: '',
+ wca_id: '2016SILV08',
+ avatar: {
+ url: 'https://avatars.worldcubeassociation.org/nsg38gkpoch8xiji3hodmrs672m4',
+ thumb_url: 'https://avatars.worldcubeassociation.org/uge6fzvlpmz6c8ztn8ey5wi4i8uf',
+ },
+ delegate_status: 'delegate',
+};
+
+const remoteAuthContext: NotifyCompRemoteAuthContextValue = {
+ authenticating: false,
+ error: null,
+ isAuthenticated: true,
+ isAuthenticatedForCompetition: () => true,
+ signIn: async () => {},
+ signOut: () => {},
+ userName: remoteAdminUser.name,
+};
+
+const webhookMocks: MockedResponse[] = [
+ {
+ request: {
+ query: RemoteCompetitionDocument,
+ variables: {
+ competitionId,
+ },
+ },
+ result: {
+ data: {
+ competition: {
+ id: competitionId,
+ autoAdvance: true,
+ autoAdvanceDelay: null,
+ },
+ },
+ },
+ },
+ {
+ request: {
+ query: RemoteWebhooksDocument,
+ variables: {
+ competitionId,
+ },
+ },
+ result: {
+ data: {
+ competition: {
+ id: competitionId,
+ webhooks: [
+ {
+ id: 1,
+ method: 'POST',
+ url: 'https://hooks.example.com/live-activities',
+ },
+ {
+ id: 2,
+ method: 'GET',
+ url: 'https://status.example.com/ping',
+ },
+ ],
+ },
+ },
+ },
+ },
+];
+
+function WebhookPageStoryProviders({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+const meta = {
+ title: 'Pages/Competition/Remote/Webhooks',
+ component: CompetitionRemoteWebhooks,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ makeRouteDecorator({
+ initialPath: `/competitions/${competitionId}/admin/webhooks`,
+ routePath: '/competitions/:competitionId/admin/webhooks',
+ }),
+ makeCompetitionContainerDecorator({
+ currentUser: remoteAdminUser,
+ }),
+ ],
+ parameters: {
+ layout: 'padded',
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const WebhookEditor: Story = {};
diff --git a/src/pages/Competition/Remote/Webhooks.test.tsx b/src/pages/Competition/Remote/Webhooks.test.tsx
new file mode 100644
index 0000000..f6dccad
--- /dev/null
+++ b/src/pages/Competition/Remote/Webhooks.test.tsx
@@ -0,0 +1,124 @@
+import { useQuery } from '@apollo/client';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { isCompetitionDelegateOrOrganizer } from '@/lib/competitionAuthorization';
+import { useAuth } from '@/providers/AuthProvider';
+import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider';
+import { useWCIF } from '@/providers/WCIFProvider';
+import CompetitionRemoteWebhooks from './Webhooks';
+
+jest.mock('@apollo/client', () => ({
+ ...jest.requireActual('@apollo/client'),
+ useQuery: jest.fn(),
+}));
+jest.mock('@/lib/competitionAuthorization', () => ({
+ isCompetitionDelegateOrOrganizer: jest.fn(),
+}));
+jest.mock('@/providers/AuthProvider', () => ({
+ useAuth: jest.fn(),
+}));
+jest.mock('@/providers/NotifyCompRemoteAuthProvider', () => ({
+ useNotifyCompRemoteAuth: jest.fn(),
+}));
+jest.mock('@/providers/WCIFProvider', () => ({
+ useWCIF: jest.fn(),
+}));
+jest.mock('./RemoteWebhookSetup', () => ({
+ RemoteWebhookSetup: ({ competitionId }: { competitionId: string }) => (
+ Webhook editor for {competitionId}
+ ),
+}));
+
+const wcif = {
+ persons: [],
+ schedule: {
+ numberOfDays: 1,
+ startDate: '2026-06-01',
+ venues: [],
+ },
+};
+
+const renderWebhooksPage = () => {
+ render(
+
+
+ }
+ />
+
+ ,
+ );
+};
+
+describe('CompetitionRemoteWebhooks', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mocked(useWCIF).mockReturnValue({
+ competitionId: 'ExampleComp2026',
+ setTitle: jest.fn(),
+ wcif,
+ } as unknown as ReturnType);
+ jest.mocked(useAuth).mockReturnValue({
+ user: { id: 1 },
+ } as unknown as ReturnType);
+ jest.mocked(isCompetitionDelegateOrOrganizer).mockReturnValue(true);
+ jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({
+ authenticating: false,
+ error: null,
+ isAuthenticatedForCompetition: () => true,
+ signIn: jest.fn(),
+ } as unknown as ReturnType);
+ jest.mocked(useQuery).mockReturnValue({
+ data: {
+ competition: {
+ id: 'ExampleComp2026',
+ },
+ },
+ error: null,
+ loading: false,
+ } as unknown as ReturnType);
+ });
+
+ it('asks users to authenticate before managing webhooks', () => {
+ jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({
+ authenticating: false,
+ error: null,
+ isAuthenticatedForCompetition: () => false,
+ signIn: jest.fn(),
+ } as unknown as ReturnType);
+
+ renderWebhooksPage();
+
+ expect(
+ screen.getByRole('button', { name: 'Sign in to Live Activities Remote' }),
+ ).toBeInTheDocument();
+ expect(screen.queryByText('Webhook editor for ExampleComp2026')).not.toBeInTheDocument();
+ });
+
+ it('asks users to import the competition before managing webhooks', () => {
+ jest.mocked(useQuery).mockReturnValue({
+ data: {
+ competition: null,
+ },
+ error: null,
+ loading: false,
+ } as unknown as ReturnType);
+
+ renderWebhooksPage();
+
+ expect(screen.getByText(/Not imported/)).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Go to Remote' })).toHaveAttribute(
+ 'href',
+ '/competitions/ExampleComp2026/admin/remote',
+ );
+ expect(screen.queryByText('Webhook editor for ExampleComp2026')).not.toBeInTheDocument();
+ });
+
+ it('renders the webhook editor for imported competitions', () => {
+ renderWebhooksPage();
+
+ expect(screen.getByRole('heading', { name: 'Webhooks' })).toBeInTheDocument();
+ expect(screen.getByText('Webhook editor for ExampleComp2026')).toBeInTheDocument();
+ });
+});
diff --git a/src/pages/Competition/Remote/Webhooks.tsx b/src/pages/Competition/Remote/Webhooks.tsx
new file mode 100644
index 0000000..8c95c03
--- /dev/null
+++ b/src/pages/Competition/Remote/Webhooks.tsx
@@ -0,0 +1,107 @@
+import { useQuery } from '@apollo/client';
+import { useEffect } from 'react';
+import { Link, useParams } from 'react-router-dom';
+import { BarLoader } from 'react-spinners';
+import { Button } from '@/components/Button';
+import { Container } from '@/components/Container';
+import { NoteBox } from '@/components/Notebox';
+import { isCompetitionDelegateOrOrganizer } from '@/lib/competitionAuthorization';
+import { NotifyCompCompetition, RemoteCompetitionDocument } from '@/lib/notifyCompRemoteGraphql';
+import { useAuth } from '@/providers/AuthProvider';
+import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider';
+import { useWCIF } from '@/providers/WCIFProvider';
+import { RemoteWebhookSetup } from './RemoteWebhookSetup';
+
+export default function CompetitionRemoteWebhooks() {
+ const { competitionId } = useParams<{ competitionId: string }>();
+ const { wcif, setTitle } = useWCIF();
+ const { user } = useAuth();
+ const remoteAuth = useNotifyCompRemoteAuth();
+ const canManageRemote = isCompetitionDelegateOrOrganizer(wcif, user);
+ const isAuthenticated = remoteAuth.isAuthenticatedForCompetition(competitionId || '');
+ const canLoadRemoteCompetition = Boolean(competitionId && isAuthenticated && canManageRemote);
+
+ const remoteCompetitionQuery = useQuery<{ competition: NotifyCompCompetition | null }>(
+ RemoteCompetitionDocument,
+ {
+ variables: { competitionId },
+ skip: !canLoadRemoteCompetition,
+ },
+ );
+
+ useEffect(() => {
+ setTitle('Webhooks');
+ }, [setTitle]);
+
+ if (!competitionId || !wcif) {
+ return null;
+ }
+
+ const accessDenied = (Boolean(user) || isAuthenticated) && !canManageRemote;
+
+ return (
+
+
+
+
Webhooks
+
+ Send Live Activities updates to another service when activities start or stop.
+
+
+ Back to Live Activities Remote
+
+
+
+ {remoteAuth.error &&
}
+
+ {accessDenied ? (
+
+ ) : !isAuthenticated ? (
+
+
+ Sign in to Live Activities Remote with your WCA account before managing webhooks.
+
+
+
+ ) : (
+ <>
+ {remoteCompetitionQuery.loading &&
}
+ {remoteCompetitionQuery.error && (
+
+ )}
+
+ {!remoteCompetitionQuery.loading && !remoteCompetitionQuery.data?.competition ? (
+
+
+
+ Go to Remote
+
+
+ ) : (
+ remoteCompetitionQuery.data?.competition && (
+
+ )
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/src/pages/Competition/Remote/index.stories.tsx b/src/pages/Competition/Remote/index.stories.tsx
new file mode 100644
index 0000000..b168e99
--- /dev/null
+++ b/src/pages/Competition/Remote/index.stories.tsx
@@ -0,0 +1,126 @@
+import { MockedProvider, MockedResponse } from '@apollo/client/testing';
+import type { Meta, StoryObj } from '@storybook/react';
+import { ReactNode, useEffect } from 'react';
+import { RemoteActivitiesDocument, RemoteCompetitionDocument } from '@/lib/notifyCompRemoteGraphql';
+import { setNotifyCompWebSocketStatus } from '@/lib/notifyCompWebSocketStatus';
+import { ConfirmProvider } from '@/providers/ConfirmProvider';
+import {
+ NotifyCompRemoteAuthContext,
+ NotifyCompRemoteAuthContextValue,
+} from '@/providers/NotifyCompRemoteAuthProvider/NotifyCompRemoteAuthContext';
+import {
+ storybookCompetitionFixture,
+ storybookOngoingActivitiesFixture,
+} from '@/storybook/competitionFixtures';
+import {
+ makeCompetitionContainerDecorator,
+ makeRouteDecorator,
+} from '@/storybook/competitionStorybook';
+import CompetitionRemote from './index';
+
+const competitionId = storybookCompetitionFixture.id;
+
+const remoteAdminUser: User = {
+ id: 9001,
+ name: 'Nick Silvestri',
+ email: '',
+ wca_id: '2016SILV08',
+ avatar: {
+ url: 'https://avatars.worldcubeassociation.org/nsg38gkpoch8xiji3hodmrs672m4',
+ thumb_url: 'https://avatars.worldcubeassociation.org/uge6fzvlpmz6c8ztn8ey5wi4i8uf',
+ },
+ delegate_status: 'delegate',
+};
+
+const remoteAuthContext: NotifyCompRemoteAuthContextValue = {
+ authenticating: false,
+ error: null,
+ isAuthenticated: true,
+ isAuthenticatedForCompetition: () => true,
+ signIn: async () => {},
+ signOut: () => {},
+ userName: remoteAdminUser.name,
+};
+
+const remoteMocks: MockedResponse[] = [
+ {
+ request: {
+ query: RemoteCompetitionDocument,
+ variables: {
+ competitionId,
+ },
+ },
+ result: {
+ data: {
+ competition: {
+ id: competitionId,
+ autoAdvance: true,
+ autoAdvanceDelay: null,
+ },
+ },
+ },
+ },
+ {
+ request: {
+ query: RemoteActivitiesDocument,
+ variables: {
+ competitionId,
+ roomId: undefined,
+ },
+ },
+ result: {
+ data: {
+ activities: storybookOngoingActivitiesFixture,
+ },
+ },
+ },
+];
+
+function RemoteStoryProviders({ children }: { children: ReactNode }) {
+ useEffect(() => {
+ setNotifyCompWebSocketStatus({ status: 'connected' });
+
+ return () => {
+ setNotifyCompWebSocketStatus({ status: 'idle' });
+ };
+ }, []);
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+const meta = {
+ title: 'Pages/Competition/Remote',
+ component: CompetitionRemote,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ makeRouteDecorator({
+ initialPath: `/competitions/${competitionId}/admin/remote`,
+ routePath: '/competitions/:competitionId/admin/remote',
+ }),
+ makeCompetitionContainerDecorator({
+ currentUser: remoteAdminUser,
+ }),
+ ],
+ parameters: {
+ layout: 'padded',
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const LiveActivitiesRemote: Story = {};
diff --git a/src/pages/Competition/Remote/index.test.tsx b/src/pages/Competition/Remote/index.test.tsx
new file mode 100644
index 0000000..1a56d68
--- /dev/null
+++ b/src/pages/Competition/Remote/index.test.tsx
@@ -0,0 +1,124 @@
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl';
+import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus';
+import { useAuth } from '@/providers/AuthProvider';
+import { useNotifyCompRemoteAuth } from '@/providers/NotifyCompRemoteAuthProvider';
+import { useWCIF } from '@/providers/WCIFProvider';
+import CompetitionRemote from './index';
+
+jest.mock('@/hooks/useCompetitionRemoteControl', () => ({
+ useCompetitionRemoteControl: jest.fn(),
+}));
+jest.mock('@/hooks/useNotifyCompWebSocketStatus', () => ({
+ useNotifyCompWebSocketStatus: jest.fn(),
+}));
+jest.mock('./RemoteActivityList', () => ({
+ RemoteGroupList: () => ,
+}));
+jest.mock('@/providers/AuthProvider', () => ({
+ useAuth: jest.fn(),
+}));
+jest.mock('@/providers/NotifyCompRemoteAuthProvider', () => ({
+ useNotifyCompRemoteAuth: jest.fn(),
+}));
+jest.mock('@/providers/WCIFProvider', () => ({
+ useWCIF: jest.fn(),
+}));
+
+const wcif = {
+ persons: [],
+ schedule: {
+ numberOfDays: 1,
+ startDate: '2026-06-01',
+ venues: [],
+ },
+};
+
+const remoteDefaults = {
+ activities: [],
+ activityGroups: [],
+ autoAdvance: false,
+ canManageRemote: true,
+ error: null,
+ importCompetition: jest.fn(),
+ isAuthenticated: true,
+ isLoading: false,
+ isSaving: false,
+ nextGroup: null,
+ scheduledActivities: [],
+};
+
+const renderRemote = () => {
+ render(
+
+
+ } />
+
+ ,
+ );
+};
+
+describe('CompetitionRemote webhook management link', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mocked(useWCIF).mockReturnValue({
+ setTitle: jest.fn(),
+ wcif,
+ } as unknown as ReturnType);
+ jest.mocked(useAuth).mockReturnValue({
+ user: { id: 1 },
+ } as unknown as ReturnType);
+ jest.mocked(useNotifyCompWebSocketStatus).mockReturnValue({
+ status: 'connected',
+ } as ReturnType);
+ jest.mocked(useNotifyCompRemoteAuth).mockReturnValue({
+ authenticating: false,
+ error: null,
+ signIn: jest.fn(),
+ } as unknown as ReturnType);
+ });
+
+ it('does not show webhook management before Remote authentication', () => {
+ jest.mocked(useCompetitionRemoteControl).mockReturnValue({
+ ...remoteDefaults,
+ isAuthenticated: false,
+ } as unknown as ReturnType);
+
+ renderRemote();
+
+ expect(
+ screen.getByRole('button', { name: 'Sign in to Live Activities Remote' }),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: 'Manage webhooks' })).not.toBeInTheDocument();
+ });
+
+ it('does not show webhook management before the competition is imported', () => {
+ jest.mocked(useCompetitionRemoteControl).mockReturnValue({
+ ...remoteDefaults,
+ competition: null,
+ } as unknown as ReturnType);
+
+ renderRemote();
+
+ expect(screen.getByText('Import schedule')).toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: 'Manage webhooks' })).not.toBeInTheDocument();
+ });
+
+ it('links to webhook management after the competition is imported', () => {
+ jest.mocked(useCompetitionRemoteControl).mockReturnValue({
+ ...remoteDefaults,
+ competition: {
+ id: 'ExampleComp2026',
+ },
+ } as unknown as ReturnType);
+
+ renderRemote();
+
+ expect(screen.getByRole('link', { name: 'Manage webhooks' })).toHaveAttribute(
+ 'href',
+ '/competitions/ExampleComp2026/admin/webhooks',
+ );
+ expect(screen.queryByRole('heading', { name: 'Webhook setup' })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/pages/Competition/Remote/index.tsx b/src/pages/Competition/Remote/index.tsx
index 10c27cb..007f250 100644
--- a/src/pages/Competition/Remote/index.tsx
+++ b/src/pages/Competition/Remote/index.tsx
@@ -2,13 +2,11 @@ import classNames from 'classnames';
import { useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import { BarLoader } from 'react-spinners';
-import {
- Button,
- Container,
- NoteBox,
- NotifyCompConnectionStatus,
- RemoteActivitySummaryList,
-} from '@/components';
+import { Button } from '@/components/Button';
+import { Container } from '@/components/Container';
+import { NoteBox } from '@/components/Notebox';
+import { NotifyCompConnectionStatus } from '@/components/NotifyCompConnectionStatus';
+import { RemoteActivitySummaryList } from '@/components/RemoteActivitySummaryList';
import { useCompetitionRemoteControl } from '@/hooks/useCompetitionRemoteControl';
import { useNotifyCompWebSocketStatus } from '@/hooks/useNotifyCompWebSocketStatus';
import { isCompetitionDayOrAfter } from '@/lib/competitionDates';
@@ -166,7 +164,24 @@ export default function CompetitionRemote() {
<>
{remote.isLoading && }
{remote.error && }
- {remote.competition && }
+ {remote.competition && (
+
+
+
+
+
Webhooks
+
+ Configure external Live Activities update webhooks on a separate page.
+
+
+
+ Manage webhooks
+
+
+
+ )}
{!remote.isLoading && !remote.competition ? (
diff --git a/src/providers/AuthProvider/AuthProvider.tsx b/src/providers/AuthProvider/AuthProvider.tsx
index e46ac1c..524a42f 100644
--- a/src/providers/AuthProvider/AuthProvider.tsx
+++ b/src/providers/AuthProvider/AuthProvider.tsx
@@ -84,7 +84,10 @@ export function AuthProvider({ children }: PropsWithChildren) {
return;
}
- fetchMe(accessToken)
+ fetchMe(accessToken, {
+ upcoming_competitions: true,
+ ongoing_competitions: true,
+ })
.then(({ me, ongoing_competitions, upcoming_competitions }) => {
setLocalStorage('accessToken', accessToken);
setLocalStorage(
@@ -92,6 +95,8 @@ export function AuthProvider({ children }: PropsWithChildren) {
String(Date.now() + Number(hashParams.get('expires_in') ?? 0) * 1000),
);
setUserAndSave(me);
+ setLocalStorage('my.upcoming_competitions', JSON.stringify(upcoming_competitions || []));
+ setLocalStorage('my.ongoing_competitions', JSON.stringify(ongoing_competitions || []));
queryClient.setQueryData(['userCompetitions', me.id], {
user: me,
ongoing_competitions,