Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move twitch login to backend API call, rather than exposing access to… #30

Merged
merged 1 commit into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 0 additions & 1 deletion .env.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ VITE_REDIRECT_URI="http://localhost:5173/auth-callback"
VITE_LOGOUT_URI="http://localhost:5173/login"

VITE_TWITCH_CLIENT_ID=???
VITE_TWITCH_REDIRECT_URI="http://localhost:5173/twitch-callback"

VITE_API_URL="http://localhost:5173/api/"
WEBSOCKET_URL="ws://localhost:5173"
Expand Down
15 changes: 13 additions & 2 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"script": "lint:vscode",
"problemMatcher": {
"owner": "biome",
"fileLocation": ["absolute"],
"fileLocation": [
"absolute"
],
"pattern": {
"regexp": "^::(error|warning) title=(.+),file=(.+),line=(\\d+),endLine=(\\d+),col=(\\d+),endColumn=(\\d+)::(.+)$",
"severity": 1,
Expand All @@ -28,6 +30,15 @@
"problemMatcher": [],
"label": "npm: format",
"detail": "biome format"
},
{
"type": "npm",
"script": "typecheck",
"problemMatcher": [
"$tsc"
],
"label": "npm: typecheck",
"detail": "tsc --project tsconfig.json --noEmit"
}
]
}
}
8 changes: 6 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Route, RouterProvider, createBrowserRouter } from 'react-router-dom';
import episodes from '@/resources/episodes';
import streamPlans, { StreamPlansCalendar } from '@/resources/stream_plans';
import streams from '@/resources/streams';
import twitch_streams from '@/resources/twitch_streams';
import twitch from '@/resources/twitch';
import video_clips from '@/resources/video_clips';

import GlobalStyles from '@mui/material/GlobalStyles';
Expand Down Expand Up @@ -79,7 +79,11 @@ function App() {
</Resource>
<Resource name="episodes" {...episodes} />
<Resource name="video_clips" {...video_clips} />
<Resource name="twitch_streams" {...twitch_streams} />
<Resource
name="twitch"
{...twitch}
options={{ label: 'Twitch Streams' }}
/>
<Resource name="profile" />

<CustomRoutes>
Expand Down
107 changes: 107 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { TwitchAccess } from '@/types';
import { userManager } from '@/utilities/auth';
import type {
TwitchAuthRequest,
TwitchCallbackRequest,
} from 'glowing-telegram-types/src/types';

const { VITE_API_URL: baseApiUrl } = import.meta.env;

export async function authenticatedFetch(
url: string,
options: RequestInit = {},
): Promise<Response> {
const user = await userManager.getUser();

if (!user) {
throw new Error('User not found');
}

const token = user.id_token;

if (token === undefined) {
throw new Error('User not authenticated');
}

return fetch(url, {
...options,
headers: {
Authorization: token,
Accept: 'application/json',
...options.headers,
},
});
}

export async function fetchTwitchAccessToken(): Promise<TwitchAccess> {
const url = new URL('auth/twitch/token', baseApiUrl);

try {
const res = await authenticatedFetch(url.toString());

const data = await res.json();

return {
valid: true,
id: data.broadcaster_id,
accessToken: data.access_token,
};
} catch (error) {
return {
id: 'twitchToken',
valid: false,
};
}
}

export async function generateAuthorizeUri(
provider: 'twitch',
scopes: string[],
): Promise<string> {
const url = new URL(`auth/${provider}/url`, baseApiUrl);

const body: TwitchAuthRequest = {
scopes,
redirect_uri: window.location.href,
};

const res = await authenticatedFetch(url.toString(), {
method: 'POST',
body: JSON.stringify(body),
headers: {
'Content-Type': 'application/json',
},
});

const data = await res.json();

return data.url;
}

export async function handleOAuthCallback(
provider: 'twitch',
code: string,
state: string,
): Promise<string> {
const body: TwitchCallbackRequest = {
code,
state,
scope: [],
};

const res = await authenticatedFetch(
new URL(`auth/${provider}/callback`, baseApiUrl).toString(),
{
method: 'POST',
body: JSON.stringify(body),
redirect: 'manual',
headers: {
'Content-Type': 'application/json',
},
},
);

const data = await res.json();

return data.url;
}
7 changes: 6 additions & 1 deletion src/components/atoms/TwitchCCLSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ function TwitchCCLSelect({
useEffect(() => {
const abortController = new AbortController();

if (!profile.twitch?.accessToken) {
setError(new Error('Missing Twitch access token'));
return;
}

setLoading(true);

getContentClassificationLabels(getLocale(), profile.twitch.accessToken, {
Expand All @@ -62,7 +67,7 @@ function TwitchCCLSelect({
return () => {
abortController.abort();
};
}, [profile.twitch.accessToken, getLocale]);
}, [profile.twitch?.accessToken, getLocale]);

return (
<FormControl error={Boolean(error)}>
Expand Down
7 changes: 6 additions & 1 deletion src/components/atoms/TwitchCategoryAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ function TwitchCategoryAutocomplete({
return;
}

if (!profile.twitch?.accessToken) {
setError(new Error('Missing Twitch access token'));
return;
}

const abortController = new AbortController();

setLoading(true);
Expand All @@ -61,7 +66,7 @@ function TwitchCategoryAutocomplete({
setLoading(false);
}
}, FETCH_CATEGORIES_DEBOUNCE_TIME),
[profile.twitch.accessToken],
[profile.twitch?.accessToken],
);

useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/atoms/TwitchCategoryAutocompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function TwitchCategoryAutocompleteInput({
category={field.value === '' ? null : field.value}
onChange={field.onChange}
profile={profile}
label={label}
label={label || ''}
/>
);
}
Expand Down
72 changes: 17 additions & 55 deletions src/components/atoms/TwitchOAuthButton.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { getCsrfToken } from '@/utilities/csrf';
import { generateAuthorizeUri } from '@/utilities/twitch';
import CheckIcon from '@mui/icons-material/Check';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import { LoadingIndicator, useTranslate, useUpdate } from 'react-admin';
import { useDataProvider, useTranslate } from 'react-admin';

interface TwitchOAuthButtonProps {
tokens: {
Expand All @@ -19,64 +16,29 @@ const scopes = [

function TwitchOAuthButton({ tokens }: TwitchOAuthButtonProps) {
const translate = useTranslate();
const [update, { isPending, isError }] = useUpdate();
const dataprovider = useDataProvider();

const csrfToken = getCsrfToken();
const url = generateAuthorizeUri(csrfToken, scopes);

const handleDisconnect = () => {
update('profile', {
id: 'my-profile',
data: { twitch: { accessToken: null } },
});
};

const handleConnect = () => {
const handleConnect = async () => {
const url = await dataprovider.generateAuthorizeUri('twitch', scopes);
window.location.href = url;
};

if (isPending) {
return <LoadingIndicator />;
}

if (isError) {
return (
<Alert severity="error">
{translate('gt.profile.errorSaving', {
_: 'There was an error saving the profile',
})}
</Alert>
);
}

// if the user is connected, show the reauthorize button
return (
<>
{tokens.accessToken && (
<>
<Button
variant="contained"
color="success"
title={translate('gt.profile.reauthorizeTwitch', {
_: 'Click to Re-authorize Twitch',
})}
onClick={handleConnect}
>
<CheckIcon />{' '}
{translate('gt.profile.connectedToTwitch', {
_: 'Connected to Twitch',
})}
</Button>
<Button
color="warning"
variant="contained"
onClick={handleDisconnect}
>
{translate('gt.profile.disconnectTwitch', {
_: 'Click to Disconnect Twitch',
})}
</Button>
</>
<Button
variant="contained"
color="success"
title={translate('gt.profile.reauthorizeTwitch', {
_: 'Click to Re-authorize Twitch',
})}
onClick={handleConnect}
>
<CheckIcon />{' '}
{translate('gt.profile.connectedToTwitch', {
_: 'Connected to Twitch',
})}
</Button>
)}

{!tokens.accessToken && (
Expand Down
50 changes: 0 additions & 50 deletions src/components/atoms/TwitchTokenLivenessChecker.tsx

This file was deleted.

14 changes: 13 additions & 1 deletion src/components/molecules/AdManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,24 @@ function AdManager({ profile }: AdManagerProps) {
useEffect(() => {
const abortController = new AbortController();

if (!profile.twitch?.accessToken || !profile.twitch?.broadcasterId) {
return;
}

getAdSchedule(profile.twitch.broadcasterId, profile.twitch.accessToken, {
signal: abortController.signal,
})
.then((data) => setAdSchedule(data))
.catch((e) => setError(e));

return () => abortController.abort();
}, [profile.twitch.accessToken, profile.twitch.broadcasterId]);
}, [profile.twitch?.accessToken, profile.twitch?.broadcasterId]);

const handleSnooze = async () => {
if (!profile.twitch?.accessToken || !profile.twitch?.broadcasterId) {
return;
}

setIsPending(true);
try {
const data = await snoozeNextAd(
Expand All @@ -55,6 +63,10 @@ function AdManager({ profile }: AdManagerProps) {
};

const handleStartCommercial = async () => {
if (!profile.twitch?.accessToken || !profile.twitch?.broadcasterId) {
return;
}

setIsPending(true);
try {
await startCommercial(
Expand Down
Loading
Loading