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

+39 -22
View File
@@ -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')}