feat(settings): link the Steam account through Steam
Build, Test & Push Frontend / quality-check (push) Successful in 16s
Build, Test & Push Frontend / docker (push) Successful in 25s

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:
lobo committed 2026-09-26 11:20:04 +02:00
1 parent 23ab5a91e7
commit ca75e6a530
13 files changed
+393 -86

No files matched your search

+5
View File
@@ -34,6 +34,9 @@ To Be Played is split into three repositories:
game still in the backlog, and one to set the status to 100 % when game still in the backlog, and one to set the status to 100 % when
every achievement is unlocked. every achievement is unlocked.
- **Wishlist** with store price and discount. - **Wishlist** with store price and discount.
- **Steam account** linked in the settings with "Sign in through Steam": the user signs in on Steam and Auth gets
the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to
verify it; unlinking is possible.
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion. - **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
Saved in Auth, so it follows you to every device. Saved in Auth, so it follows you to every device.
@@ -68,6 +71,8 @@ flowchart LR
Core -->|"OAuth token"| Twitch["Twitch"] Core -->|"OAuth token"| Twitch["Twitch"]
Core --> Speedrun["speedrun.com API"] Core --> Speedrun["speedrun.com API"]
Browser -.->|"game art"| Images["Steam and IGDB image hosts"] Browser -.->|"game art"| Images["Steam and IGDB image hosts"]
Browser -.->|"Sign in through Steam"| SteamLogin["steamcommunity.com (OpenID)"]
Auth -->|"check_authentication"| SteamLogin
``` ```
- nginx serves the built app. It has no backend of its own: the browser calls Auth and Core directly - nginx serves the built app. It has no backend of its own: the browser calls Auth and Core directly
+18 -8
View File
@@ -23,14 +23,12 @@
"PASSWORD": "Passwort", "PASSWORD": "Passwort",
"PASSWORD_CONFIRMATION": "Passwort bestätigen", "PASSWORD_CONFIRMATION": "Passwort bestätigen",
"INVITE_CODE": "Einladungscode", "INVITE_CODE": "Einladungscode",
"STEAM_ID": "Steam-ID",
"SUBMIT": "Konto erstellen", "SUBMIT": "Konto erstellen",
"SUCCESS": "Konto erstellt. Weiterleitung zum Login …", "SUCCESS": "Konto erstellt. Weiterleitung zum Login …",
"HAVE_ACCOUNT": "Bereits ein Konto?", "HAVE_ACCOUNT": "Bereits ein Konto?",
"ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.", "ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.",
"ERROR_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.", "ERROR_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.",
"ERROR_INVALID_INVITE_CODE": "Der Einladungscode ist ungültig oder wurde bereits verwendet.", "ERROR_INVALID_INVITE_CODE": "Der Einladungscode ist ungültig oder wurde bereits verwendet.",
"ERROR_STEAM_ID_IN_USE": "Diese Steam-ID ist bereits mit einem anderen Konto verknüpft.",
"DISPLAY_NAME": "Anzeigename (optional)", "DISPLAY_NAME": "Anzeigename (optional)",
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.", "ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.",
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen." "ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen."
@@ -51,8 +49,6 @@
"EN": "Englisch", "EN": "Englisch",
"DE": "Deutsch" "DE": "Deutsch"
}, },
"STEAM_ID": "Steam-ID",
"SAVE_STEAM_ID": "Steam-ID speichern",
"SAVED": "Einstellungen gespeichert.", "SAVED": "Einstellungen gespeichert.",
"ERROR_SAVE": "Einstellungen konnten nicht gespeichert werden.", "ERROR_SAVE": "Einstellungen konnten nicht gespeichert werden.",
"PROFILE": "Profil", "PROFILE": "Profil",
@@ -89,7 +85,23 @@
"BACKGROUND_REMOVE": "Hintergrund entfernen", "BACKGROUND_REMOVE": "Hintergrund entfernen",
"BANNER": "Dashboard-Banner", "BANNER": "Dashboard-Banner",
"BANNER_HINT": "Zeigt dein meistgespieltes Spiel der letzten zwei Wochen. Öffne ein Spiel und wähle „Als Banner“, um eins festzulegen.", "BANNER_HINT": "Zeigt dein meistgespieltes Spiel der letzten zwei Wochen. Öffne ein Spiel und wähle „Als Banner“, um eins festzulegen.",
"BANNER_AUTO": "Wieder automatisch" "BANNER_AUTO": "Wieder automatisch",
"STEAM_LINK": {
"LINK": "Steam-Konto verknüpfen",
"VERIFY": "Mit Steam bestätigen",
"CHANGE": "Anderes Steam-Konto verknüpfen",
"UNLINK": "Verknüpfung lösen",
"VERIFIED": "Bestätigt",
"NOT_VERIFIED": "Nicht bestätigt",
"NONE": "Noch kein Steam-Konto verknüpft. Du meldest dich bei Steam an, und Steam nennt uns deine Steam-ID; dein Steam-Passwort sehen wir nie.",
"VERIFY_HINT": "Diese Steam-ID wurde eingetippt und ist noch nicht bestätigt. Bestätige sie mit Steam, sonst übernimmt sie ein anderes Konto, das sie bestätigt.",
"LINKED": "Steam-Konto verknüpft.",
"IN_USE": "Dieses Steam-Konto ist schon mit einem anderen Konto verknüpft.",
"CANCELLED": "Steam-Verknüpfung abgebrochen.",
"FAILED": "Steam konnte die Verknüpfung nicht bestätigen. Bitte versuch es noch einmal.",
"ERROR": "Die Steam-Verknüpfung konnte nicht starten.",
"UNLINKED": "Steam-Verknüpfung gelöst."
}
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.", "EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -98,8 +110,6 @@
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.", "PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.", "PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
"INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.", "INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.",
"STEAM_ID_REQUIRED": "Steam-ID ist erforderlich.",
"STEAM_ID_INVALID": "Steam-ID muss eine positive Zahl sein.",
"LANGUAGE_INVALID": "Nicht unterstützte Sprache.", "LANGUAGE_INVALID": "Nicht unterstützte Sprache.",
"DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)." "DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
}, },
@@ -113,7 +123,7 @@
"LOADING": "Lade Dashboard ...", "LOADING": "Lade Dashboard ...",
"HELLO": "Hallo", "HELLO": "Hallo",
"STEAM_GAMES": "Steam Spiele", "STEAM_GAMES": "Steam Spiele",
"NO_STEAM_ID": "Keine Steam-ID hinterlegt – jetzt in den Einstellungen eintragen", "NO_STEAM_ID": "Noch kein Steam-Konto verknüpft – verknüpfe es in den Einstellungen",
"MOST_PLAYED": "Meistgespielt", "MOST_PLAYED": "Meistgespielt",
"SHOW_ALL": "Alle anzeigen", "SHOW_ALL": "Alle anzeigen",
"TO_LIBRARY": "Zur Bibliothek", "TO_LIBRARY": "Zur Bibliothek",
+18 -8
View File
@@ -23,14 +23,12 @@
"PASSWORD": "Password", "PASSWORD": "Password",
"PASSWORD_CONFIRMATION": "Confirm password", "PASSWORD_CONFIRMATION": "Confirm password",
"INVITE_CODE": "Invite code", "INVITE_CODE": "Invite code",
"STEAM_ID": "Steam ID",
"SUBMIT": "Create account", "SUBMIT": "Create account",
"SUCCESS": "Account created. Redirecting to login …", "SUCCESS": "Account created. Redirecting to login …",
"HAVE_ACCOUNT": "Already have an account?", "HAVE_ACCOUNT": "Already have an account?",
"ERROR_GENERIC": "Registration failed. Please try again.", "ERROR_GENERIC": "Registration failed. Please try again.",
"ERROR_EMAIL_IN_USE": "This email address is already registered.", "ERROR_EMAIL_IN_USE": "This email address is already registered.",
"ERROR_INVALID_INVITE_CODE": "The invite code is invalid or has already been used.", "ERROR_INVALID_INVITE_CODE": "The invite code is invalid or has already been used.",
"ERROR_STEAM_ID_IN_USE": "This Steam ID is already linked to another account.",
"DISPLAY_NAME": "Display name (optional)", "DISPLAY_NAME": "Display name (optional)",
"ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.", "ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.",
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes." "ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes."
@@ -51,8 +49,6 @@
"EN": "English", "EN": "English",
"DE": "German" "DE": "German"
}, },
"STEAM_ID": "Steam ID",
"SAVE_STEAM_ID": "Save Steam ID",
"SAVED": "Settings saved.", "SAVED": "Settings saved.",
"ERROR_SAVE": "Could not save settings.", "ERROR_SAVE": "Could not save settings.",
"PROFILE": "Profile", "PROFILE": "Profile",
@@ -89,7 +85,23 @@
"BACKGROUND_REMOVE": "Remove background", "BACKGROUND_REMOVE": "Remove background",
"BANNER": "Dashboard banner", "BANNER": "Dashboard banner",
"BANNER_HINT": "Shows your most played game of the last two weeks. Open a game and choose \"Use as banner\" to pick one.", "BANNER_HINT": "Shows your most played game of the last two weeks. Open a game and choose \"Use as banner\" to pick one.",
"BANNER_AUTO": "Back to automatic" "BANNER_AUTO": "Back to automatic",
"STEAM_LINK": {
"LINK": "Link Steam account",
"VERIFY": "Verify with Steam",
"CHANGE": "Link another Steam account",
"UNLINK": "Unlink",
"VERIFIED": "Verified",
"NOT_VERIFIED": "Not verified",
"NONE": "No Steam account linked yet. You sign in on Steam, and Steam tells us your Steam ID; we never see your Steam password.",
"VERIFY_HINT": "This Steam ID was typed in and is not proved yet. Verify it with Steam, or another account that proves it takes it over.",
"LINKED": "Steam account linked.",
"IN_USE": "This Steam account is already linked to another account.",
"CANCELLED": "Steam link cancelled.",
"FAILED": "Steam could not confirm the link. Please try again.",
"ERROR": "Could not start the Steam link.",
"UNLINKED": "Steam account unlinked."
}
}, },
"VALIDATION": { "VALIDATION": {
"EMAIL_REQUIRED": "Email is required.", "EMAIL_REQUIRED": "Email is required.",
@@ -98,8 +110,6 @@
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.", "PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
"PASSWORD_MISMATCH": "Passwords do not match.", "PASSWORD_MISMATCH": "Passwords do not match.",
"INVITE_CODE_REQUIRED": "Invite code is required.", "INVITE_CODE_REQUIRED": "Invite code is required.",
"STEAM_ID_REQUIRED": "Steam ID is required.",
"STEAM_ID_INVALID": "Steam ID must be a positive number.",
"LANGUAGE_INVALID": "Unsupported language.", "LANGUAGE_INVALID": "Unsupported language.",
"DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)." "DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
}, },
@@ -113,7 +123,7 @@
"LOADING": "Loading dashboard ...", "LOADING": "Loading dashboard ...",
"HELLO": "Hello", "HELLO": "Hello",
"STEAM_GAMES": "Steam Games", "STEAM_GAMES": "Steam Games",
"NO_STEAM_ID": "No Steam ID saved yet – add it in the settings", "NO_STEAM_ID": "No Steam account linked yet – link it in the settings",
"MOST_PLAYED": "Most played", "MOST_PLAYED": "Most played",
"SHOW_ALL": "Show all", "SHOW_ALL": "Show all",
"TO_LIBRARY": "Go to library", "TO_LIBRARY": "Go to library",
+98
View File
@@ -0,0 +1,98 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {BadgeCheck, CircleAlert, Gamepad2, Link2, Loader2, Unlink} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {markSession} from '../utils/auth.js';
import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
/**
* The linked Steam account. A Steam ID comes only from "Sign in through Steam"; one typed in before that is shown
* as not verified.
*/
export default function SteamAccountCard({steamId, verified, onUnlinked}) {
const {t} = useTranslation();
const expireSession = useSessionExpired();
const [busy, setBusy] = useState(false);
const link = async () => {
setBusy(true);
try {
const response = await startSteamLink();
if (response.ok) return; // on the way to Steam: the button stays busy
if (response.status === 401) {
expireSession();
} else {
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
}
} catch (err) {
console.error('Steam link error:', err);
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
}
setBusy(false);
};
const unlink = async () => {
setBusy(true);
try {
const response = await unlinkSteam();
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
return;
}
// The new access cookie has no Steam ID; the new stamp makes the header load again.
markSession();
toast.success(t('SETTINGS.STEAM_LINK.UNLINKED'));
onUnlinked();
} catch (err) {
console.error('Steam unlink error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.STEAM')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{steamId ? (
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">{steamId}</span>
{verified ? (
<Badge variant="outline"><BadgeCheck className="size-3"/>{t('SETTINGS.STEAM_LINK.VERIFIED')}</Badge>
) : (
<Badge variant="outline"><CircleAlert className="size-3"/>{t('SETTINGS.STEAM_LINK.NOT_VERIFIED')}</Badge>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">{t('SETTINGS.STEAM_LINK.NONE')}</p>
)}
{steamId && !verified && (
<p className="text-sm text-muted-foreground">{t('SETTINGS.STEAM_LINK.VERIFY_HINT')}</p>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant={steamId && verified ? 'outline' : 'default'} onClick={link} disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <Link2/>}
{t(!steamId ? 'SETTINGS.STEAM_LINK.LINK' : verified ? 'SETTINGS.STEAM_LINK.CHANGE' : 'SETTINGS.STEAM_LINK.VERIFY')}
</Button>
{steamId && (
<Button type="button" variant="outline" onClick={unlink} disabled={busy}>
<Unlink/>{t('SETTINGS.STEAM_LINK.UNLINK')}
</Button>
)}
</div>
</CardContent>
</Card>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ import {sessionStamp} from '../utils/auth.js';
/** /**
* The user's Steam name and avatar from Core, or null while loading, without a Steam ID or on any error. Only * The user's Steam name and avatar from Core, or null while loading, without a Steam ID or on any error. Only
* decoration, so a failed call shows the default icon and never ends the session. Loads again after a new login * decoration, so a failed call shows the default icon and never ends the session. Loads again after a new login
* or a settings save (see sessionStamp) on the next render, e.g. the next navigation. * or a Steam link or unlink (see sessionStamp) on the next render, e.g. the next navigation.
*/ */
export default function useSteamProfile(enabled) { export default function useSteamProfile(enabled) {
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState(null);
-13
View File
@@ -17,7 +17,6 @@ import {clientFetch} from '../utils/api.js';
const BACKEND_ERROR_KEY = { const BACKEND_ERROR_KEY = {
EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE', EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE',
INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE', 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', DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_IN_USE',
TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS', TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS',
}; };
@@ -38,7 +37,6 @@ export default function RegistrationPage() {
password: '', password: '',
passwordConfirmation: '', passwordConfirmation: '',
inviteCode: '', inviteCode: '',
steamId: '',
displayName: '', displayName: '',
}, },
}); });
@@ -54,7 +52,6 @@ export default function RegistrationPage() {
email: values.email, email: values.email,
password: values.password, password: values.password,
inviteCode: values.inviteCode, inviteCode: values.inviteCode,
steamId: values.steamId,
displayName: values.displayName, displayName: values.displayName,
}), }),
}); });
@@ -140,16 +137,6 @@ export default function RegistrationPage() {
/> />
</FormField> </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"> <Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="animate-spin"/>} {isSubmitting && <Loader2 className="animate-spin"/>}
{t('REGISTRATION.SUBMIT')} {t('REGISTRATION.SUBMIT')}
+10 -1
View File
@@ -27,7 +27,6 @@ function submitValidForm() {
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: 'secret123'}}); fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: 'secret123'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD_CONFIRMATION'), {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.INVITE_CODE'), {target: {value: 'code'}});
fireEvent.change(screen.getByLabelText('REGISTRATION.STEAM_ID'), {target: {value: '76561198000000001'}});
fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.SUBMIT'})); 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(); 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 () => { it('maps a known backend error to its message', async () => {
globalThis.fetch = vi.fn(async () => errorResponse(400, 'INVALID_INVITATION_CODE')); globalThis.fetch = vi.fn(async () => errorResponse(400, 'INVALID_INVITATION_CODE'));
+39 -22
View File
@@ -1,5 +1,5 @@
import {useEffect, useState} from 'react'; import {useEffect, useRef, useState} from 'react';
import {useNavigate} from 'react-router-dom'; import {useNavigate, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form'; import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod'; 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 {Skeleton} from '@/components/ui/skeleton';
import {Switch} from '@/components/ui/switch'; import {Switch} from '@/components/ui/switch';
import FormField from '../components/FormField.jsx'; import FormField from '../components/FormField.jsx';
import SteamAccountCard from '../components/SteamAccountCard.jsx';
import {settingsSchema} from '../utils/validation.js'; import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
@@ -22,14 +23,21 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {ACCENTS, CORNERS} from '../utils/appearance.js'; import {ACCENTS, CORNERS} from '../utils/appearance.js';
import {coverUrl} from '../utils/games.js'; import {coverUrl} from '../utils/games.js';
import {cn} from '@/lib/utils'; 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'; import {hasSession, markSession} from '../utils/auth.js';
const BACKEND_ERROR_KEY = { 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', 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 SUPPORTED_LANGUAGES = ['en', 'de'];
const formatDate = (value, locale) => { const formatDate = (value, locale) => {
@@ -51,6 +59,9 @@ export default function SettingsPage() {
const [profile, setProfile] = useState(null); const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null); 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 { const {
register, register,
@@ -59,9 +70,22 @@ export default function SettingsPage() {
formState: {errors, isSubmitting}, formState: {errors, isSubmitting},
} = useForm({ } = useForm({
resolver: zodResolver(settingsSchema), 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(() => { useEffect(() => {
if (!hasSession()) { if (!hasSession()) {
navigate('/login'); navigate('/login');
@@ -70,6 +94,11 @@ export default function SettingsPage() {
const load = async () => { const load = async () => {
try { 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`, { const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
method: 'GET', method: 'GET',
headers: { headers: {
@@ -92,7 +121,6 @@ export default function SettingsPage() {
reset({ reset({
useDarkTheme: !!data.settings?.useDarkTheme, useDarkTheme: !!data.settings?.useDarkTheme,
language: data.settings?.language || 'en', language: data.settings?.language || 'en',
steamId: data.steamId ?? '',
displayName: data.displayName ?? '', displayName: data.displayName ?? '',
}); });
} catch (err) { } catch (err) {
@@ -104,7 +132,7 @@ export default function SettingsPage() {
}; };
load(); load();
}, [navigate, expireSession, reset, t]); }, [navigate, expireSession, reset, t, steamOutcome]);
const persistSettings = async (payload) => { const persistSettings = async (payload) => {
if (!hasSession()) { if (!hasSession()) {
@@ -133,8 +161,6 @@ export default function SettingsPage() {
return {ok: false}; 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')); toast.success(t('SETTINGS.SAVED'));
return {ok: true}; return {ok: true};
} catch (err) { } catch (err) {
@@ -148,7 +174,6 @@ export default function SettingsPage() {
const savedSettings = () => ({ const savedSettings = () => ({
useDarkTheme: isDarkMode, useDarkTheme: isDarkMode,
language: i18n.language, language: i18n.language,
steamId: profile?.steamId ?? null,
displayName: profile?.displayName ?? null, displayName: profile?.displayName ?? null,
publicProfile: !!profile?.settings?.publicProfile, publicProfile: !!profile?.settings?.publicProfile,
}); });
@@ -186,13 +211,12 @@ export default function SettingsPage() {
const publicProfile = savedSettings().publicProfile && !!values.displayName; const publicProfile = savedSettings().publicProfile && !!values.displayName;
const result = await persistSettings({ const result = await persistSettings({
...savedSettings(), ...savedSettings(),
steamId: values.steamId,
displayName: values.displayName, displayName: values.displayName,
publicProfile, publicProfile,
}); });
if (result.ok) { if (result.ok) {
setProfile((p) => (p ? { setProfile((p) => (p ? {
...p, steamId: values.steamId, displayName: values.displayName, settings: {...p.settings, publicProfile}, ...p, displayName: values.displayName, settings: {...p.settings, publicProfile},
} : p)); } : p));
} }
}; };
@@ -358,6 +382,9 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
onUnlinked={() => setProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"><Gamepad2 className="size-4 text-primary"/>{t('SETTINGS.PROFILE')}</CardTitle> <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> <p className="text-xs text-muted-foreground">{t('SETTINGS.DISPLAY_NAME_HINT')}</p>
</FormField> </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}> <Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="animate-spin"/> : <Save/>} {isSubmitting ? <Loader2 className="animate-spin"/> : <Save/>}
{t('SETTINGS.SAVE_PROFILE')} {t('SETTINGS.SAVE_PROFILE')}
+114
View File
@@ -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);
});
});
+26
View File
@@ -0,0 +1,26 @@
import {apiFetch} from './api.js';
// Only Steam's own login page; a URL from an API must not send the user anywhere else.
export const STEAM_LOGIN_PREFIX = 'https://steamcommunity.com/openid/login?';
/**
* POST /api/users/steam-link, then leaves the page for Steam's login. The caller handles a response that is not ok.
*
* @returns the response; when it is ok the browser is already on its way to Steam
*/
export async function startSteamLink() {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/steam-link`, {method: 'POST'});
if (!response.ok) return response;
const {redirectUrl} = await response.json();
if (typeof redirectUrl !== 'string' || !redirectUrl.startsWith(STEAM_LOGIN_PREFIX)) {
throw new Error('Auth sent no Steam login URL');
}
window.location.assign(redirectUrl);
return response;
}
/**
* DELETE /api/users/steam-link. Auth sets a new access cookie without the Steam ID.
*/
export const unlinkSteam = () =>
apiFetch(`${import.meta.env.VITE_API_URL}/api/users/steam-link`, {method: 'DELETE'});
-9
View File
@@ -15,13 +15,6 @@ const inviteCodeField = z
.string() .string()
.min(1, 'VALIDATION.INVITE_CODE_REQUIRED'); .min(1, 'VALIDATION.INVITE_CODE_REQUIRED');
// A SteamID64 has 17 digits. Keep it a string: a JS number loses precision above 2^53.
const steamIdField = z
.string()
.trim()
.min(1, 'VALIDATION.STEAM_ID_REQUIRED')
.regex(/^\d{17}$/, 'VALIDATION.STEAM_ID_INVALID');
// Same rule as the Auth service: 3-24 letters, digits, space, _ . - and no space at the ends. // Same rule as the Auth service: 3-24 letters, digits, space, _ . - and no space at the ends.
// An empty field means "no display name" and is sent as null. // An empty field means "no display name" and is sent as null.
const displayNameField = z const displayNameField = z
@@ -39,7 +32,6 @@ export const registrationSchema = z
password: passwordField, password: passwordField,
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'), passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
inviteCode: inviteCodeField, inviteCode: inviteCodeField,
steamId: steamIdField,
displayName: displayNameField, displayName: displayNameField,
}) })
.refine((d) => d.password === d.passwordConfirmation, { .refine((d) => d.password === d.passwordConfirmation, {
@@ -50,6 +42,5 @@ export const registrationSchema = z
export const settingsSchema = z.object({ export const settingsSchema = z.object({
useDarkTheme: z.boolean(), useDarkTheme: z.boolean(),
language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}), language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}),
steamId: steamIdField,
displayName: displayNameField, displayName: displayNameField,
}); });
+8 -24
View File
@@ -1,36 +1,20 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import {registrationSchema, settingsSchema} from './validation.js'; import {registrationSchema, settingsSchema} from './validation.js';
const settings = (steamId) => ({useDarkTheme: true, language: 'de', steamId}); const settings = () => ({useDarkTheme: true, language: 'de'});
describe('steamId validation', () => { describe('Steam ID', () => {
it('keeps a SteamID64 as the exact string', () => { it('is no form field any more: it comes only from "Sign in through Steam"', () => {
// Number('76561198005679788') is 76561198005679790 expect(settingsSchema.parse({...settings(), steamId: '76561198005679788'})).not.toHaveProperty('steamId');
expect(settingsSchema.parse(settings('76561198005679788')).steamId).toBe('76561198005679788'); expect(registrationSchema.parse({
}); email: 'a@b.de', password: 'password1', passwordConfirmation: 'password1', inviteCode: 'code',
it('trims whitespace', () => {
expect(settingsSchema.parse(settings(' 76561198005679788 ')).steamId).toBe('76561198005679788');
});
it.each(['', '12345', '7656119800567978x', '765611980056797880'])('rejects %j', (steamId) => {
expect(settingsSchema.safeParse(settings(steamId)).success).toBe(false);
});
it('keeps the exact string on registration', () => {
const result = registrationSchema.parse({
email: 'a@b.de',
password: 'password1',
passwordConfirmation: 'password1',
inviteCode: 'code',
steamId: '76561198005679788', steamId: '76561198005679788',
}); })).not.toHaveProperty('steamId');
expect(result.steamId).toBe('76561198005679788');
}); });
}); });
describe('displayName validation', () => { describe('displayName validation', () => {
const withName = (displayName) => ({...settings('76561198005679788'), displayName}); const withName = (displayName) => ({...settings(), displayName});
it('turns an empty field into null (no display name)', () => { it('turns an empty field into null (no display name)', () => {
expect(settingsSchema.parse(withName(' ')).displayName).toBeNull(); expect(settingsSchema.parse(withName(' ')).displayName).toBeNull();