1
0
Fork 0
mirror of https://git.sr.ht/~ashkeel/strimertul synced 2024-09-18 01:50:50 +00:00

feat: add test button for Twitch keys

This commit is contained in:
Ash Keel 2022-12-20 14:02:27 +01:00
parent 9f8e9e40ab
commit 56f4619a08
No known key found for this signature in database
GPG key ID: BAD8D93E7314ED3E
25 changed files with 365 additions and 71 deletions

View file

@ -0,0 +1,59 @@
export interface TwitchCredentials {
access_token: string;
expires_in: number;
token_type: string;
}
export interface TwitchError {
status: number;
message: string;
}
/**
* Retrieve OAuth2 client credentials for Twitch app
* @param clientId App Client ID
* @param clientSecret App Client secret
* @returns Twitch credentials object
* @throws Credentials are not valid or request failed
*/
export async function twitchAuth(
clientId: string,
clientSecret: string,
): Promise<TwitchCredentials> {
const url = `https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`;
const req = await fetch(url, {
method: 'POST',
});
if (!req.ok) {
const err = (await req.json()) as TwitchError;
throw new Error(`authentication failed: ${err.message} (${err.status})'`);
}
return req.json() as Promise<TwitchCredentials>;
}
/**
* Check if provided Twitch app credentials are fine by making a simple request
* @param clientId App Client ID
* @param clientSecret App Client secret
* @throws Credentials are not valid or request failed
*/
export async function checkTwitchKeys(
clientId: string,
clientSecret: string,
): Promise<void> {
const creds = await twitchAuth(clientId, clientSecret);
console.log(creds);
const req = await fetch('https://api.twitch.tv/helix/streams?first=1', {
headers: {
Authorization: `Bearer ${creds.access_token}`,
'Client-Id': clientId,
},
});
if (!req.ok) {
const err = (await req.json()) as TwitchError;
throw new Error(`API test call failed: ${err.message}`);
}
}

View file

@ -95,7 +95,10 @@
"current-status": "Current status"
},
"app-category": "Category",
"app-oauth-redirect-url": "OAuth Redirect URLs"
"app-oauth-redirect-url": "OAuth Redirect URLs",
"test-button": "Test",
"test-failed": "Test failed: \"{{0}}\". Check your app client IDs and secret!",
"test-succeeded": "Test succeeded!"
},
"botcommands": {
"title": "Bot commands",
@ -259,9 +262,16 @@
},
"onboarding": {
"welcome-header": "Welcome to {{APPNAME}}",
"welcome-p1": "It looks like this is the first time you started {{APPNAME}}. You can click the \"Get started\" button at the bottom to set things up one by one, or just skip everything and configure things manually later.",
"welcome-continue-button": "Get started",
"skip-button": "Skip onboarding"
"skip-button": "Skip onboarding",
"welcome-p1": "It looks like this is the first time you started {{APPNAME}}. You can click the \"Get started\" button at the bottom to set things up one by one, or just skip everything and configure things manually later.",
"welcome-p2": "Heads up: if you're used to other platforms, this unfortunately will require some more work on your end.",
"sections": {
"landing": "Welcome",
"twitch-config": "Twitch integration",
"twitch-bot": "Twitch bot",
"done": "All done!"
}
},
"uiconfig": {
"title": "User interface settings",

View file

@ -258,7 +258,14 @@
"skip-button": "Salta procedura guidata",
"welcome-continue-button": "Cominciamo",
"welcome-header": "Benvenuto su {{APPNAME}}",
"welcome-p1": "Sembra che questa sia la prima volta che avvii {{APPNAME}}. \nPuoi fare clic sul pulsante \"Cominciamo\" in basso per impostare tutto con una procedura guidata oppure semplicemente saltare tutto e configurare le cose manualmente in un secondo momento."
"welcome-p1": "Sembra che questa sia la prima volta che avvii {{APPNAME}}. \nPuoi fare clic sul pulsante \"Cominciamo\" in basso per impostare tutto con una procedura guidata oppure semplicemente saltare tutto e configurare le cose manualmente in un secondo momento.",
"welcome-p2": "Giusto una cosa: se sei abituato ad altre piattaforme, stavolta toccherà un po' più lavoro da parte tua!",
"sections": {
"done": "Pronti a partire!",
"landing": "Benvenuto",
"twitch-bot": "Bot per Twitch",
"twitch-config": "Integrazione Twitch"
}
},
"strimertul": {
"credits-header": "Ringraziamenti",

View file

@ -12,7 +12,7 @@ import {
import KilovoltWS from '@strimertul/kilovolt-client';
import type { kvError } from '@strimertul/kilovolt-client/types/messages';
import { AuthenticateKVClient, IsServerReady } from '@wailsapp/go/main/App';
import { delay } from '~/lib/time-utils';
import { delay } from '~/lib/time';
import {
APIState,
ConnectionStatus,

View file

@ -144,6 +144,7 @@ export interface LoyaltyRedeem {
}
export interface UISettings {
onboardingStatus: number;
onboardingDone: boolean;
language: string;
}

View file

@ -1,6 +1,6 @@
import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
import React, { useState } from 'react';
import { SortFunction } from '~/lib/type-utils';
import { SortFunction } from '~/lib/types';
import { styled } from '../theme';
import { Table, TableHeader } from '../theme/table';
import PageList from './PageList';

View file

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { RootState } from 'src/store';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { delay } from '~/lib/time-utils';
import { delay } from '~/lib/time';
import { ProcessedLogEntry } from '~/store/logging/reducer';
import {
Dialog,

View file

@ -38,21 +38,13 @@ function PageList({
},
}}
>
<ToolbarSection
css={{
'@mobile': { flex: 1 },
'@medium': { flex: 0 },
}}
>
<ToolbarSection css={{ flex: 1, '@medium': { flex: 0 } }}>
<ToolbarButton
aria-label={t('pagination.previous')}
title={t('pagination.previous')}
disabled={current <= min}
onClick={() => onPageChange(current - 1)}
css={{
'@mobile': { flex: 1 },
'@medium': { flex: 0 },
}}
css={{ flex: 1, '@medium': { flex: 0 } }}
>
&lsaquo;
</ToolbarButton>
@ -61,10 +53,7 @@ function PageList({
title={t('pagination.next')}
disabled={current >= max}
onClick={() => onPageChange(current + 1)}
css={{
'@mobile': { flex: 1 },
'@medium': { flex: 0 },
}}
css={{ flex: 1, '@medium': { flex: 0 } }}
>
&rsaquo;
</ToolbarButton>
@ -75,7 +64,7 @@ function PageList({
onChange={(ev) => onSelectChange(Number(ev.target.value))}
css={{
textAlign: 'center',
'@mobile': { flex: 1 },
flex: 1,
'@medium': { flex: 0 },
}}
>

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getInterval } from '~/lib/time-utils';
import { getInterval } from '~/lib/time';
import { ComboBox, FlexRow, InputBox } from '../../theme';
export interface TimeUnit {

View file

@ -13,8 +13,10 @@ function PasswordField(
>
>,
) {
const subprops = { ...props };
delete subprops.reveal;
return (
<input type={props.reveal ? 'text' : 'password'} {...props}>
<input type={props.reveal ? 'text' : 'password'} {...subprops}>
{props.children}
</input>
);

View file

@ -1,7 +1,7 @@
import { PlusIcon } from '@radix-ui/react-icons';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react-utils';
import { useModule } from '~/lib/react';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import {

View file

@ -2,7 +2,7 @@ import { PlusIcon } from '@radix-ui/react-icons';
import { TFunction } from 'i18next';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react-utils';
import { useModule } from '~/lib/react';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import { TwitchBotTimer } from '~/store/api/types';

View file

@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CheckIcon } from '@radix-ui/react-icons';
import { useModule, useStatus } from '~/lib/react-utils';
import { useModule, useStatus } from '~/lib/react';
import apiReducer, { modules } from '~/store/api/reducer';
import { useAppDispatch } from '~/store';
import MultiInput from '../components/forms/MultiInput';

View file

@ -1,7 +1,7 @@
import { CircleIcon } from '@radix-ui/react-icons';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useLiveKey } from '~/lib/react-utils';
import { useLiveKey } from '~/lib/react';
import { PageContainer, SectionHeader, styled } from '../theme';
import WIPNotice from '../components/utils/WIPNotice';
import BrowserLink from '../components/BrowserLink';

View file

@ -1,7 +1,7 @@
import React from 'react';
import { CheckIcon } from '@radix-ui/react-icons';
import { useTranslation } from 'react-i18next';
import { useModule, useStatus } from '~/lib/react-utils';
import { useModule, useStatus } from '~/lib/react';
import apiReducer, { modules } from '~/store/api/reducer';
import { useAppDispatch } from '~/store';
import {

View file

@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModule, useUserPoints } from '~/lib/react-utils';
import { SortFunction } from '~/lib/type-utils';
import { useModule, useUserPoints } from '~/lib/react';
import { SortFunction } from '~/lib/types';
import { useAppDispatch } from '~/store';
import { modules, removeRedeem, setUserPoints } from '~/store/api/reducer';
import { DataTable } from '../components/DataTable';

View file

@ -1,7 +1,7 @@
import { CheckIcon, PlusIcon } from '@radix-ui/react-icons';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react-utils';
import { useModule } from '~/lib/react';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import { LoyaltyGoal, LoyaltyReward } from '~/store/api/types';

View file

@ -1,3 +1,4 @@
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { keyframes } from '@stitches/react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -5,11 +6,30 @@ import { useNavigate } from 'react-router-dom';
// @ts-expect-error Asset import
import spinner from '~/assets/icon-logo.svg';
import { useModule } from '~/lib/react-utils';
import { useModule, useStatus } from '~/lib/react';
import { languages } from '~/locale/languages';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
import apiReducer, { modules } from '~/store/api/reducer';
import AlertContent from '../components/AlertContent';
import SaveButton from '../components/forms/SaveButton';
import RevealLink from '../components/utils/RevealLink';
import { Button, PageContainer, styled, TextBlock } from '../theme';
import {
Button,
Field,
FieldNote,
InputBox,
Label,
MultiToggle,
MultiToggleItem,
PageContainer,
PageHeader,
PageTitle,
PasswordInputBox,
styled,
TextBlock,
} from '../theme';
import { Alert } from '../theme/alert';
const Container = styled('div', {
display: 'flex',
@ -56,6 +76,20 @@ const HeroContainer = styled('div', {
overflow: 'hidden',
});
const HeroLanguageSelector = styled('div', {
top: '10px',
left: '10px',
display: 'flex',
gap: '1rem',
position: 'absolute',
zIndex: '10',
});
const LanguageItem = styled(MultiToggleItem, {
fontSize: '1rem',
padding: '5px 8px',
});
const HeroAnimation = styled('div', {
bottom: '-50px',
left: '50%',
@ -95,6 +129,10 @@ const Spinner = styled('img', {
const StepContainer = styled(PageContainer, {
display: 'flex',
flexDirection: 'column',
paddingTop: '1rem',
'& p': {
margin: '1.5rem 0',
},
});
const ActionContainer = styled('div', {
@ -102,21 +140,81 @@ const ActionContainer = styled('div', {
display: 'flex',
justifyContent: 'center',
gap: '1rem',
paddingTop: '1rem',
});
const StepList = styled('nav', {
flex: '1',
display: 'flex',
alignItems: 'center',
padding: '0 1rem',
flexWrap: 'wrap',
flexDirection: 'row',
justifyContent: 'flex-start',
});
const StepName = styled('div', {
padding: '0.5rem',
color: '$gray10',
'&:not(:last-child)::after': {
color: '$gray10',
content: '',
margin: '0 0 0 1rem',
},
display: 'none',
'@thin': {
display: 'inherit',
},
variants: {
status: {
active: {
color: '$teal12',
display: 'inherit',
},
},
interaction: {
clickable: {
cursor: 'pointer',
},
},
},
});
enum OnboardingSteps {
Landing = 0,
ServerConfig = 1,
TwitchIntegration = 1,
TwitchBot = 2,
Done = 999,
}
const steps = [
OnboardingSteps.Landing,
OnboardingSteps.TwitchIntegration,
OnboardingSteps.TwitchBot,
OnboardingSteps.Done,
];
const stepI18n = {
[OnboardingSteps.Landing]: 'pages.onboarding.sections.landing',
[OnboardingSteps.TwitchIntegration]:
'pages.onboarding.sections.twitch-config',
[OnboardingSteps.TwitchBot]: 'pages.onboarding.sections.twitch-bot',
[OnboardingSteps.Done]: 'pages.onboarding.sections.done',
};
const maxKeys = languages.reduce(
(current, it) => Math.max(current, it.keys),
0,
);
export default function OnboardingPage() {
const { t } = useTranslation();
const [t, i18n] = useTranslation();
const [animationItems, setAnimationItems] = useState<JSX.Element[]>([]);
const [currentStep, setStep] = useState(OnboardingSteps.Landing);
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
const dispatch = useAppDispatch();
const navigate = useNavigate();
const currentStep = steps[uiConfig?.onboardingStatus || 0];
const landing = currentStep === OnboardingSteps.Landing;
const onboardingDone = uiConfig?.onboardingDone;
@ -158,26 +256,10 @@ export default function OnboardingPage() {
);
}, []);
return (
<Container>
<TopBanner>
{landing ? (
<HeroContainer>
<HeroAnimation>{animationItems}</HeroAnimation>
<HeroTitle>{t('pages.onboarding.welcome-header')}</HeroTitle>
<HeroContent>
<TextBlock>{t('pages.onboarding.welcome-p1')}</TextBlock>
<TextBlock css={{ color: '$gray11' }}>
Heads up: if you're used to other platforms, this unfortunately
will require some more work on your end.
</TextBlock>
</HeroContent>
</HeroContainer>
) : (
<div></div>
)}
</TopBanner>
<StepContainer>
let currentStepBody: JSX.Element = null;
switch (currentStep) {
case OnboardingSteps.Landing:
currentStepBody = (
<ActionContainer>
<Button
css={{ width: '20vw', justifyContent: 'center' }}
@ -188,12 +270,91 @@ export default function OnboardingPage() {
<Button
css={{ width: '20vw', justifyContent: 'center' }}
variation="primary"
onClick={() => setStep(OnboardingSteps.ServerConfig)}
onClick={() => {
void dispatch(
setUiConfig({
...uiConfig,
onboardingStatus: (uiConfig?.onboardingStatus ?? 0) + 1,
}),
);
}}
>
{t('pages.onboarding.welcome-continue-button')}
</Button>
</ActionContainer>
</StepContainer>
);
break;
}
return (
<Container>
<TopBanner>
{landing ? (
<HeroContainer>
<HeroLanguageSelector>
<MultiToggle
value={uiConfig?.language ?? i18n.resolvedLanguage}
type="single"
onValueChange={(newLang) => {
void dispatch(
setUiConfig({ ...uiConfig, language: newLang }),
);
}}
>
{languages.map((lang) => (
<LanguageItem
key={lang.code}
aria-label={lang.name}
value={lang.code}
title={`${lang.name} ${
lang.keys < maxKeys
? `(${t('pages.uiconfig.partial-translation')})`
: ''
}`}
>
{lang.name}
{lang.keys < maxKeys ? <ExclamationTriangleIcon /> : null}
</LanguageItem>
))}
</MultiToggle>
</HeroLanguageSelector>
<HeroAnimation>{animationItems}</HeroAnimation>
<HeroTitle>{t('pages.onboarding.welcome-header')}</HeroTitle>
<HeroContent>
<TextBlock>{t('pages.onboarding.welcome-p1')}</TextBlock>
<TextBlock css={{ color: '$gray11' }}>
{t('pages.onboarding.welcome-p2')}
</TextBlock>
</HeroContent>
</HeroContainer>
) : (
<StepList>
{steps.map((step) => (
<StepName
key={step}
interaction={step < currentStep ? 'clickable' : undefined}
status={step === currentStep ? 'active' : undefined}
onClick={() => {
// Can't skip ahead
if (step >= currentStep) {
return;
}
void dispatch(
setUiConfig({
...uiConfig,
onboardingStatus:
steps.findIndex((val) => val === step) ?? 0,
}),
);
}}
>
{t(stepI18n[step])}
</StepName>
))}
</StepList>
)}
</TopBanner>
<StepContainer>{currentStepBody}</StepContainer>
</Container>
);
}

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModule, useStatus } from '~/lib/react-utils';
import { useModule, useStatus } from '~/lib/react';
import { useAppDispatch } from '~/store';
import apiReducer, { modules } from '~/store/api/reducer';
import AlertContent from '../components/AlertContent';

View file

@ -6,9 +6,10 @@ import React, { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import eventsubTests from '~/data/eventsub-tests';
import { useModule, useStatus } from '~/lib/react-utils';
import { useModule, useStatus } from '~/lib/react';
import { RootState, useAppDispatch } from '~/store';
import apiReducer, { modules } from '~/store/api/reducer';
import { checkTwitchKeys } from '~/lib/twitch';
import BrowserLink from '../components/BrowserLink';
import DefinitionTable from '../components/DefinitionTable';
import RevealLink from '../components/utils/RevealLink';
@ -35,6 +36,8 @@ import {
TabList,
TextBlock,
} from '../theme';
import AlertContent from '../components/AlertContent';
import { Alert } from '../theme/alert';
const StepList = styled('ul', {
lineHeight: '1.5',
@ -195,6 +198,8 @@ function TwitchBotSettings() {
);
}
type TestResult = { open: boolean; error?: Error };
function TwitchAPISettings() {
const { t } = useTranslation();
const [httpConfig] = useModule(modules.httpConfig);
@ -204,6 +209,27 @@ function TwitchAPISettings() {
const status = useStatus(loadStatus.save);
const dispatch = useAppDispatch();
const [revealClientSecret, setRevealClientSecret] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult>({
open: false,
});
async function checkCredentials() {
setTesting(true);
if (twitchConfig) {
try {
await checkTwitchKeys(
twitchConfig.api_client_id,
twitchConfig.api_client_secret,
);
setTestResult({ open: true });
} catch (e: unknown) {
console.log(e);
setTestResult({ open: true, error: e as Error });
}
}
setTesting(false);
}
return (
<form
@ -291,7 +317,40 @@ function TwitchAPISettings() {
}
/>
</Field>
<SaveButton status={status} />
<ButtonGroup>
<SaveButton status={status} />
<Button
type="button"
onClick={() => {
void checkCredentials();
}}
disabled={testing}
>
{t('pages.twitch-settings.test-button')}
</Button>
</ButtonGroup>
<Alert
defaultOpen={false}
open={testResult.open}
onOpenChange={(val: boolean) => {
setTestResult({ ...testResult, open: val });
}}
>
<AlertContent
variation={testResult.error ? 'danger' : 'default'}
description={
testResult.error
? t('pages.twitch-settings.test-failed', [
testResult.error.message,
])
: t('pages.twitch-settings.test-succeeded')
}
actionText={t('form-actions.ok')}
onAction={() => {
setTestResult({ ...testResult, open: false });
}}
/>
</Alert>
</form>
);
}

View file

@ -1,6 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useModule } from '~/lib/react-utils';
import { useModule } from '~/lib/react';
import { languages } from '~/locale/languages';
import { useAppDispatch } from '~/store';
import { modules } from '~/store/api/reducer';
@ -19,16 +19,16 @@ const PartialWarning = styled('small', {
color: '$yellow11',
});
const maxKeys = languages.reduce(
(current, it) => Math.max(current, it.keys),
0,
);
export default function UISettingsPage(): React.ReactElement {
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
const [t, i18n] = useTranslation();
const dispatch = useAppDispatch();
const maxKeys = languages.reduce(
(current, it) => Math.max(current, it.keys),
0,
);
return (
<PageContainer>
<PageHeader css={{ paddingBottom: '1rem' }}>
@ -63,7 +63,13 @@ export default function UISettingsPage(): React.ReactElement {
<Button
type="button"
onClick={() => {
void dispatch(setUiConfig({ ...uiConfig, onboardingDone: false }));
void dispatch(
setUiConfig({
...uiConfig,
onboardingDone: false,
onboardingStatus: 0,
}),
);
}}
>
{t('pages.uiconfig.repeat-onboarding')}

View file

@ -46,7 +46,7 @@ export const { styled, theme } = createStitches({
},
},
media: {
mobile: '(min-width: 640px)',
thin: '(min-width: 480px)',
medium: '(min-width: 768px)',
wide: '(min-width: 1024px)',
},