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
@@ -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
|
||||
every achievement is unlocked.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -68,6 +71,8 @@ flowchart LR
|
||||
Core -->|"OAuth token"| Twitch["Twitch"]
|
||||
Core --> Speedrun["speedrun.com API"]
|
||||
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
|
||||
|
||||
+18
-8
@@ -23,14 +23,12 @@
|
||||
"PASSWORD": "Passwort",
|
||||
"PASSWORD_CONFIRMATION": "Passwort bestätigen",
|
||||
"INVITE_CODE": "Einladungscode",
|
||||
"STEAM_ID": "Steam-ID",
|
||||
"SUBMIT": "Konto erstellen",
|
||||
"SUCCESS": "Konto erstellt. Weiterleitung zum Login …",
|
||||
"HAVE_ACCOUNT": "Bereits ein Konto?",
|
||||
"ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"ERROR_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.",
|
||||
"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)",
|
||||
"ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.",
|
||||
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen."
|
||||
@@ -51,8 +49,6 @@
|
||||
"EN": "Englisch",
|
||||
"DE": "Deutsch"
|
||||
},
|
||||
"STEAM_ID": "Steam-ID",
|
||||
"SAVE_STEAM_ID": "Steam-ID speichern",
|
||||
"SAVED": "Einstellungen gespeichert.",
|
||||
"ERROR_SAVE": "Einstellungen konnten nicht gespeichert werden.",
|
||||
"PROFILE": "Profil",
|
||||
@@ -89,7 +85,23 @@
|
||||
"BACKGROUND_REMOVE": "Hintergrund entfernen",
|
||||
"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_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": {
|
||||
"EMAIL_REQUIRED": "Email ist erforderlich.",
|
||||
@@ -98,8 +110,6 @@
|
||||
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
|
||||
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
|
||||
"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.",
|
||||
"DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
|
||||
},
|
||||
@@ -113,7 +123,7 @@
|
||||
"LOADING": "Lade Dashboard ...",
|
||||
"HELLO": "Hallo",
|
||||
"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",
|
||||
"SHOW_ALL": "Alle anzeigen",
|
||||
"TO_LIBRARY": "Zur Bibliothek",
|
||||
|
||||
+18
-8
@@ -23,14 +23,12 @@
|
||||
"PASSWORD": "Password",
|
||||
"PASSWORD_CONFIRMATION": "Confirm password",
|
||||
"INVITE_CODE": "Invite code",
|
||||
"STEAM_ID": "Steam ID",
|
||||
"SUBMIT": "Create account",
|
||||
"SUCCESS": "Account created. Redirecting to login …",
|
||||
"HAVE_ACCOUNT": "Already have an account?",
|
||||
"ERROR_GENERIC": "Registration failed. Please try again.",
|
||||
"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_STEAM_ID_IN_USE": "This Steam ID is already linked to another account.",
|
||||
"DISPLAY_NAME": "Display name (optional)",
|
||||
"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."
|
||||
@@ -51,8 +49,6 @@
|
||||
"EN": "English",
|
||||
"DE": "German"
|
||||
},
|
||||
"STEAM_ID": "Steam ID",
|
||||
"SAVE_STEAM_ID": "Save Steam ID",
|
||||
"SAVED": "Settings saved.",
|
||||
"ERROR_SAVE": "Could not save settings.",
|
||||
"PROFILE": "Profile",
|
||||
@@ -89,7 +85,23 @@
|
||||
"BACKGROUND_REMOVE": "Remove background",
|
||||
"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_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": {
|
||||
"EMAIL_REQUIRED": "Email is required.",
|
||||
@@ -98,8 +110,6 @@
|
||||
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
|
||||
"PASSWORD_MISMATCH": "Passwords do not match.",
|
||||
"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.",
|
||||
"DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
|
||||
},
|
||||
@@ -113,7 +123,7 @@
|
||||
"LOADING": "Loading dashboard ...",
|
||||
"HELLO": "Hello",
|
||||
"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",
|
||||
"SHOW_ALL": "Show all",
|
||||
"TO_LIBRARY": "Go to library",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
* 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) {
|
||||
const [profile, setProfile] = useState(null);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'});
|
||||
@@ -15,13 +15,6 @@ const inviteCodeField = z
|
||||
.string()
|
||||
.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.
|
||||
// An empty field means "no display name" and is sent as null.
|
||||
const displayNameField = z
|
||||
@@ -39,7 +32,6 @@ export const registrationSchema = z
|
||||
password: passwordField,
|
||||
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
|
||||
inviteCode: inviteCodeField,
|
||||
steamId: steamIdField,
|
||||
displayName: displayNameField,
|
||||
})
|
||||
.refine((d) => d.password === d.passwordConfirmation, {
|
||||
@@ -50,6 +42,5 @@ export const registrationSchema = z
|
||||
export const settingsSchema = z.object({
|
||||
useDarkTheme: z.boolean(),
|
||||
language: z.enum(['en', 'de'], {message: 'VALIDATION.LANGUAGE_INVALID'}),
|
||||
steamId: steamIdField,
|
||||
displayName: displayNameField,
|
||||
});
|
||||
@@ -1,36 +1,20 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {registrationSchema, settingsSchema} from './validation.js';
|
||||
|
||||
const settings = (steamId) => ({useDarkTheme: true, language: 'de', steamId});
|
||||
const settings = () => ({useDarkTheme: true, language: 'de'});
|
||||
|
||||
describe('steamId validation', () => {
|
||||
it('keeps a SteamID64 as the exact string', () => {
|
||||
// Number('76561198005679788') is 76561198005679790
|
||||
expect(settingsSchema.parse(settings('76561198005679788')).steamId).toBe('76561198005679788');
|
||||
});
|
||||
|
||||
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',
|
||||
describe('Steam ID', () => {
|
||||
it('is no form field any more: it comes only from "Sign in through Steam"', () => {
|
||||
expect(settingsSchema.parse({...settings(), steamId: '76561198005679788'})).not.toHaveProperty('steamId');
|
||||
expect(registrationSchema.parse({
|
||||
email: 'a@b.de', password: 'password1', passwordConfirmation: 'password1', inviteCode: 'code',
|
||||
steamId: '76561198005679788',
|
||||
});
|
||||
expect(result.steamId).toBe('76561198005679788');
|
||||
})).not.toHaveProperty('steamId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('displayName validation', () => {
|
||||
const withName = (displayName) => ({...settings('76561198005679788'), displayName});
|
||||
const withName = (displayName) => ({...settings(), displayName});
|
||||
|
||||
it('turns an empty field into null (no display name)', () => {
|
||||
expect(settingsSchema.parse(withName(' ')).displayName).toBeNull();
|
||||
|
||||
Reference in new issue
Block a user