Public Access
feat(settings): link the Steam account through Steam
A Steam card in the settings links, verifies or unlinks the Steam account through "Sign in through Steam". After the way back the page shows the outcome and refreshes the session, so Core gets the new Steam ID at once. Registration and settings no longer ask for a typed Steam ID.
This commit is contained in:
1 parent
23ab5a91e7
commit
ca75e6a530
13 files changed
+393
-86
No files matched your search
@@ -17,7 +17,6 @@ import {clientFetch} from '../utils/api.js';
|
||||
const BACKEND_ERROR_KEY = {
|
||||
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
|
||||
INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE',
|
||||
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
|
||||
DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_IN_USE',
|
||||
TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS',
|
||||
};
|
||||
@@ -38,7 +37,6 @@ export default function RegistrationPage() {
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
inviteCode: '',
|
||||
steamId: '',
|
||||
displayName: '',
|
||||
},
|
||||
});
|
||||
@@ -54,7 +52,6 @@ export default function RegistrationPage() {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
inviteCode: values.inviteCode,
|
||||
steamId: values.steamId,
|
||||
displayName: values.displayName,
|
||||
}),
|
||||
});
|
||||
@@ -140,16 +137,6 @@ export default function RegistrationPage() {
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField id="steamId" label={t('REGISTRATION.STEAM_ID')} error={errors.steamId} t={t}>
|
||||
<Input
|
||||
id="steamId"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-invalid={!!errors.steamId}
|
||||
{...register('steamId')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
{isSubmitting && <Loader2 className="animate-spin"/>}
|
||||
{t('REGISTRATION.SUBMIT')}
|
||||
|
||||
@@ -27,7 +27,6 @@ function submitValidForm() {
|
||||
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: 'secret123'}});
|
||||
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD_CONFIRMATION'), {target: {value: 'secret123'}});
|
||||
fireEvent.change(screen.getByLabelText('REGISTRATION.INVITE_CODE'), {target: {value: 'code'}});
|
||||
fireEvent.change(screen.getByLabelText('REGISTRATION.STEAM_ID'), {target: {value: '76561198000000001'}});
|
||||
fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.SUBMIT'}));
|
||||
}
|
||||
|
||||
@@ -44,6 +43,16 @@ describe('RegistrationPage', () => {
|
||||
expect(await screen.findByText('REGISTRATION.ERROR_TOO_MANY_ATTEMPTS:15')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('asks for no Steam ID and sends none', async () => {
|
||||
globalThis.fetch = vi.fn(async () => errorResponse(400, 'INVALID_INVITATION_CODE'));
|
||||
|
||||
submitValidForm();
|
||||
|
||||
await screen.findByText('REGISTRATION.ERROR_INVALID_INVITE_CODE');
|
||||
expect(screen.queryByLabelText('REGISTRATION.STEAM_ID')).toBeNull();
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('steamId');
|
||||
});
|
||||
|
||||
it('maps a known backend error to its message', async () => {
|
||||
globalThis.fetch = vi.fn(async () => errorResponse(400, 'INVALID_INVITATION_CODE'));
|
||||
|
||||
|
||||
+39
-22
@@ -1,5 +1,5 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
@@ -15,6 +15,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import FormField from '../components/FormField.jsx';
|
||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||
import {settingsSchema} from '../utils/validation.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
@@ -22,14 +23,21 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {ACCENTS, CORNERS} from '../utils/appearance.js';
|
||||
import {coverUrl} from '../utils/games.js';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {apiFetch, refreshSession} from '../utils/api.js';
|
||||
import {hasSession, markSession} from '../utils/auth.js';
|
||||
|
||||
const BACKEND_ERROR_KEY = {
|
||||
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
|
||||
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
|
||||
};
|
||||
|
||||
// The `steam` query parameter Auth adds when "Sign in through Steam" sends the user back.
|
||||
const STEAM_LINK_TOASTS = {
|
||||
linked: {show: toast.success, key: 'SETTINGS.STEAM_LINK.LINKED'},
|
||||
'in-use': {show: toast.error, key: 'SETTINGS.STEAM_LINK.IN_USE'},
|
||||
cancelled: {show: toast.info, key: 'SETTINGS.STEAM_LINK.CANCELLED'},
|
||||
failed: {show: toast.error, key: 'SETTINGS.STEAM_LINK.FAILED'},
|
||||
};
|
||||
|
||||
const SUPPORTED_LANGUAGES = ['en', 'de'];
|
||||
|
||||
const formatDate = (value, locale) => {
|
||||
@@ -51,6 +59,9 @@ export default function SettingsPage() {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
// Read once: removing the parameter below must not load the profile again.
|
||||
const [steamOutcome] = useState(() => searchParams.get('steam'));
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -59,9 +70,22 @@ export default function SettingsPage() {
|
||||
formState: {errors, isSubmitting},
|
||||
} = useForm({
|
||||
resolver: zodResolver(settingsSchema),
|
||||
defaultValues: {useDarkTheme: true, language: 'en', steamId: '', displayName: ''},
|
||||
defaultValues: {useDarkTheme: true, language: 'en', displayName: ''},
|
||||
});
|
||||
|
||||
// A language change gives a new `t` and runs the effect again; the toast shows once.
|
||||
const steamToastShown = useRef(false);
|
||||
useEffect(() => {
|
||||
const outcome = STEAM_LINK_TOASTS[steamOutcome];
|
||||
if (!outcome || steamToastShown.current) return;
|
||||
steamToastShown.current = true;
|
||||
outcome.show(t(outcome.key));
|
||||
setSearchParams((params) => {
|
||||
params.delete('steam');
|
||||
return params;
|
||||
}, {replace: true});
|
||||
}, [steamOutcome, setSearchParams, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
@@ -70,6 +94,11 @@ export default function SettingsPage() {
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
if (steamOutcome === 'linked') {
|
||||
// Refresh reads the new Steam ID from the database, so Core gets it in the token at once.
|
||||
await refreshSession().catch(() => false);
|
||||
markSession();
|
||||
}
|
||||
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -92,7 +121,6 @@ export default function SettingsPage() {
|
||||
reset({
|
||||
useDarkTheme: !!data.settings?.useDarkTheme,
|
||||
language: data.settings?.language || 'en',
|
||||
steamId: data.steamId ?? '',
|
||||
displayName: data.displayName ?? '',
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -104,7 +132,7 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
load();
|
||||
}, [navigate, expireSession, reset, t]);
|
||||
}, [navigate, expireSession, reset, t, steamOutcome]);
|
||||
|
||||
const persistSettings = async (payload) => {
|
||||
if (!hasSession()) {
|
||||
@@ -133,8 +161,6 @@ export default function SettingsPage() {
|
||||
return {ok: false};
|
||||
}
|
||||
|
||||
// Auth set a new access cookie for the new Steam ID; the new stamp makes the header load it again.
|
||||
markSession();
|
||||
toast.success(t('SETTINGS.SAVED'));
|
||||
return {ok: true};
|
||||
} catch (err) {
|
||||
@@ -148,7 +174,6 @@ export default function SettingsPage() {
|
||||
const savedSettings = () => ({
|
||||
useDarkTheme: isDarkMode,
|
||||
language: i18n.language,
|
||||
steamId: profile?.steamId ?? null,
|
||||
displayName: profile?.displayName ?? null,
|
||||
publicProfile: !!profile?.settings?.publicProfile,
|
||||
});
|
||||
@@ -186,13 +211,12 @@ export default function SettingsPage() {
|
||||
const publicProfile = savedSettings().publicProfile && !!values.displayName;
|
||||
const result = await persistSettings({
|
||||
...savedSettings(),
|
||||
steamId: values.steamId,
|
||||
displayName: values.displayName,
|
||||
publicProfile,
|
||||
});
|
||||
if (result.ok) {
|
||||
setProfile((p) => (p ? {
|
||||
...p, steamId: values.steamId, displayName: values.displayName, settings: {...p.settings, publicProfile},
|
||||
...p, displayName: values.displayName, settings: {...p.settings, publicProfile},
|
||||
} : p));
|
||||
}
|
||||
};
|
||||
@@ -358,6 +382,9 @@ export default function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
|
||||
onUnlinked={() => setProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.PROFILE')}</CardTitle>
|
||||
@@ -375,16 +402,6 @@ export default function SettingsPage() {
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('SETTINGS.DISPLAY_NAME_HINT')}</p>
|
||||
</FormField>
|
||||
<FormField id="steamId" label={t('SETTINGS.STEAM_ID')} error={errors.steamId} t={t}>
|
||||
<Input
|
||||
id="steamId"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
aria-invalid={!!errors.steamId}
|
||||
{...register('steamId')}
|
||||
/>
|
||||
</FormField>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Loader2 className="animate-spin"/> : <Save/>}
|
||||
{t('SETTINGS.SAVE_PROFILE')}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import SettingsPage from './SettingsPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const mock = await import('../test/i18n-mock.js');
|
||||
const i18n = {language: 'de', changeLanguage: vi.fn()};
|
||||
return {...mock, useTranslation: () => ({t: mock.useTranslation().t, i18n})};
|
||||
});
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}}));
|
||||
|
||||
const STEAM_URL = 'https://steamcommunity.com/openid/login?openid.mode=checkid_setup';
|
||||
|
||||
const profile = (overrides = {}) => ({
|
||||
id: 1, email: 'a@b.de', role: 'USER', displayName: 'Lobo', steamId: '76561198005679788', steamIdVerified: false,
|
||||
settings: {useDarkTheme: true, language: 'de', publicProfile: false}, ...overrides,
|
||||
});
|
||||
|
||||
const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method);
|
||||
|
||||
function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (init?.method === 'POST' && url.endsWith('/api/users/steam-link')) {
|
||||
return {ok: true, status: 200, json: async () => ({redirectUrl})};
|
||||
}
|
||||
if (init?.method && init.method !== 'GET') {
|
||||
return {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }};
|
||||
}
|
||||
return {ok: true, status: 200, json: async () => me};
|
||||
});
|
||||
render(<MemoryRouter initialEntries={[entry]}><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
}
|
||||
|
||||
describe('SettingsPage Steam account', () => {
|
||||
const assign = vi.fn();
|
||||
const realLocation = window.location;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
Object.defineProperty(window, 'location', {configurable: true, value: {...realLocation, assign}});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, 'location', {configurable: true, value: realLocation});
|
||||
});
|
||||
|
||||
it('shows a typed-in ID as not verified and sends the user to Steam to verify it', async () => {
|
||||
renderPage(profile());
|
||||
|
||||
expect(await screen.findByText('SETTINGS.STEAM_LINK.NOT_VERIFIED')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', {name: /SETTINGS.STEAM_LINK.VERIFY/}));
|
||||
|
||||
await waitFor(() => expect(assign).toHaveBeenCalledWith(STEAM_URL));
|
||||
expect(calls('POST').map(([url]) => url).some((url) => url.endsWith('/api/users/steam-link'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not follow a link that is not Steam', async () => {
|
||||
renderPage(profile({steamId: null}), {redirectUrl: 'https://evil.example/openid/login?x'});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.STEAM_LINK.LINK/}));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.STEAM_LINK.ERROR'));
|
||||
expect(assign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a verified ID with the option to link another account', async () => {
|
||||
renderPage(profile({steamIdVerified: true}));
|
||||
|
||||
expect(await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED')).toBeTruthy();
|
||||
expect(screen.getByRole('button', {name: /SETTINGS.STEAM_LINK.CHANGE/})).toBeTruthy();
|
||||
});
|
||||
|
||||
it('back from Steam: refreshes the session so Core gets the new Steam ID, and says so', async () => {
|
||||
renderPage(profile({steamIdVerified: true}), {entry: '/settings?steam=linked'});
|
||||
|
||||
await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED');
|
||||
expect(calls('POST').some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(true);
|
||||
expect(toast.success).toHaveBeenCalledWith('SETTINGS.STEAM_LINK.LINKED');
|
||||
});
|
||||
|
||||
it.each([['in-use', 'error', 'SETTINGS.STEAM_LINK.IN_USE'], ['failed', 'error', 'SETTINGS.STEAM_LINK.FAILED'],
|
||||
['cancelled', 'info', 'SETTINGS.STEAM_LINK.CANCELLED']])('back from Steam with %s', async (outcome, kind, key) => {
|
||||
renderPage(profile(), {entry: `/settings?steam=${outcome}`});
|
||||
|
||||
await screen.findByText('SETTINGS.STEAM_LINK.NOT_VERIFIED');
|
||||
expect(toast[kind]).toHaveBeenCalledWith(key);
|
||||
expect(calls('POST').some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(false);
|
||||
});
|
||||
|
||||
it('unlinks the Steam account', async () => {
|
||||
renderPage(profile({steamIdVerified: true}));
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.STEAM_LINK.UNLINK/}));
|
||||
|
||||
expect(await screen.findByText('SETTINGS.STEAM_LINK.NONE')).toBeTruthy();
|
||||
expect(calls('DELETE')[0][0]).toMatch(/\/api\/users\/steam-link$/);
|
||||
});
|
||||
|
||||
it('never sends a Steam ID with the settings', async () => {
|
||||
renderPage(profile());
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
|
||||
|
||||
await waitFor(() => expect(calls('PUT')).toHaveLength(1));
|
||||
expect(JSON.parse(calls('PUT')[0][1].body)).not.toHaveProperty('steamId');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import SettingsPage from './SettingsPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
// Like react-i18next: a language change re-renders every subscriber with a fresh `t`.
|
||||
vi.mock('react-i18next', async () => {
|
||||
const {useSyncExternalStore} = await import('react');
|
||||
const listeners = new Set();
|
||||
const state = {language: 'de', t: (key) => key};
|
||||
const i18n = {
|
||||
get language() { return state.language; },
|
||||
changeLanguage: vi.fn((language) => {
|
||||
state.language = language;
|
||||
state.t = (key) => key;
|
||||
listeners.forEach((listener) => listener());
|
||||
}),
|
||||
};
|
||||
const subscribe = (listener) => { listeners.add(listener); return () => listeners.delete(listener); };
|
||||
return {useTranslation: () => ({t: useSyncExternalStore(subscribe, () => state.t), i18n}), i18n};
|
||||
});
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}}));
|
||||
|
||||
const profile = {
|
||||
id: 1, email: 'a@b.de', role: 'USER', displayName: 'Lobo', steamId: '76561198005679788', steamIdVerified: true,
|
||||
settings: {useDarkTheme: true, language: 'de', publicProfile: false},
|
||||
};
|
||||
|
||||
describe('SettingsPage Steam toast', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async (url, init) => (init?.method && init.method !== 'GET'
|
||||
? {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}
|
||||
: {ok: true, status: 200, json: async () => profile}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows the linked toast once, also after a language change', async () => {
|
||||
render(<MemoryRouter initialEntries={['/settings?steam=linked']}><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED');
|
||||
expect(toast.success).toHaveBeenCalledTimes(1);
|
||||
|
||||
const {i18n} = await import('react-i18next');
|
||||
await act(async () => { i18n.changeLanguage('en'); });
|
||||
|
||||
expect(toast.success).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user