Files
ToBePlayed-Frontend/src/pages/SettingsPage.jsx
T
lobo 44e23de6d6 fix(settings): keep Steam ID as string and store the new token
Number("76561198005679788") is 76561198005679790 in JavaScript, so the wrong
Steam ID was saved. The Steam ID now stays a 17-digit string. Theme and
language saves no longer send steamId 0. After a save the new token from Auth
replaces the old one.

Add Vitest and run it in CI.
2026-09-24 08:57:03 +02:00

337 lines
14 KiB
React

import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {settingsSchema} from '../utils/validation.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
const BACKEND_ERROR_KEY = {
STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE',
};
const SUPPORTED_LANGUAGES = ['en', 'de'];
const inputClass =
'w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:outline-none';
const inputErrorClass =
'w-full px-4 py-2 border border-red-500 dark:border-red-500 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-red-500 focus:outline-none';
const readOnlyClass =
'w-full px-4 py-2 border border-slate-200 dark:border-slate-700 rounded-lg bg-slate-100 dark:bg-slate-900 text-slate-500 dark:text-slate-400 cursor-not-allowed';
const formatDate = (value, locale) => {
if (!value) return '—';
try {
return new Date(value).toLocaleString(locale);
} catch {
return value;
}
};
export default function SettingsPage() {
const {t, i18n} = useTranslation();
const navigate = useNavigate();
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [feedback, setFeedback] = useState({message: '', isError: false});
const {
register,
handleSubmit,
reset,
formState: {errors, isSubmitting},
} = useForm({
resolver: zodResolver(settingsSchema),
defaultValues: {useDarkTheme: true, language: 'en', steamId: ''},
});
useEffect(() => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
navigate('/login');
return;
}
const load = async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401) {
localStorage.removeItem('tbp_jwt');
navigate('/login');
return;
}
if (!response.ok) {
setLoadError(t('INFO.ERROR.LOAD_PROFILE'));
return;
}
const data = await response.json();
setProfile(data);
reset({
useDarkTheme: !!data.settings?.useDarkTheme,
language: data.settings?.language || 'en',
steamId: data.steamId ?? '',
});
} catch (err) {
console.error('Settings load error:', err);
setLoadError(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setIsLoading(false);
}
};
load();
}, [navigate, reset, t]);
const persistSettings = async (payload) => {
const token = localStorage.getItem('tbp_jwt');
if (!token) {
navigate('/login');
return {ok: false};
}
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (response.status === 401) {
localStorage.removeItem('tbp_jwt');
navigate('/login');
return {ok: false};
}
if (!response.ok) {
const data = await response.json().catch(() => ({}));
const mappedKey = BACKEND_ERROR_KEY[data.code];
setFeedback({
message: mappedKey ? t(mappedKey) : data.message || t('SETTINGS.ERROR_SAVE'),
isError: true,
});
return {ok: false};
}
const data = await response.json();
localStorage.setItem('tbp_jwt', data.token); // Core reads the steamId from the token
setFeedback({message: t('SETTINGS.SAVED'), isError: false});
return {ok: true};
} catch (err) {
console.error('Settings save error:', err);
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
return {ok: false};
}
};
const handleDarkThemeToggle = async () => {
const next = !isDarkMode;
setDarkMode(next); // apply on the fly
await persistSettings({
useDarkTheme: next,
language: i18n.language,
steamId: profile?.steamId ?? null,
});
};
const handleLanguageChange = async (event) => {
const next = event.target.value;
setLanguage(next); // apply on the fly
await persistSettings({
useDarkTheme: isDarkMode,
language: next,
steamId: profile?.steamId ?? null,
});
};
const onSubmitSteamId = async (values) => {
const result = await persistSettings({
useDarkTheme: isDarkMode,
language: i18n.language,
steamId: values.steamId,
});
if (result.ok) {
setProfile((p) => (p ? {...p, steamId: values.steamId} : p));
}
};
const renderError = (field) =>
errors[field] && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{t(errors[field].message)}</p>
);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-white">
{t('SETTINGS.LOADING')}
</div>
);
}
if (loadError) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 text-red-500">
{loadError}
</div>
);
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 p-8">
<div className="max-w-2xl mx-auto space-y-8">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 border border-slate-200 dark:border-slate-700">
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-6">
{t('SETTINGS.TITLE')}
</h1>
{/* Read-only account info */}
<section className="space-y-4 mb-8">
<h2 className="text-lg font-semibold text-slate-700 dark:text-slate-200">
{t('SETTINGS.ACCOUNT')}
</h2>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('SETTINGS.EMAIL')}
</label>
<input type="text" value={profile?.email ?? ''} readOnly className={readOnlyClass}/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('SETTINGS.ROLE')}
</label>
<input type="text" value={profile?.role ?? ''} readOnly className={readOnlyClass}/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('SETTINGS.CREATED_DATE')}
</label>
<input
type="text"
value={formatDate(profile?.createdDate, i18n.language)}
readOnly
className={readOnlyClass}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('SETTINGS.LAST_LOGIN')}
</label>
<input
type="text"
value={formatDate(profile?.lastLogin, i18n.language)}
readOnly
className={readOnlyClass}
/>
</div>
</section>
{/* Preferences — live */}
<section className="space-y-4 mb-8">
<h2 className="text-lg font-semibold text-slate-700 dark:text-slate-200">
{t('SETTINGS.PREFERENCES')}
</h2>
<div className="flex items-center justify-between">
<label htmlFor="useDarkTheme" className="text-sm font-medium text-slate-700 dark:text-slate-300">
{t('SETTINGS.DARK_THEME')}
</label>
<button
id="useDarkTheme"
type="button"
role="switch"
aria-checked={isDarkMode}
onClick={handleDarkThemeToggle}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
isDarkMode ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isDarkMode ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<div>
<label htmlFor="language" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('SETTINGS.LANGUAGE')}
</label>
<select
id="language"
value={i18n.language}
onChange={handleLanguageChange}
className={inputClass}
>
{SUPPORTED_LANGUAGES.map((lang) => (
<option key={lang} value={lang}>
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
</option>
))}
</select>
</div>
</section>
{/* Steam ID — explicit save */}
<section>
<h2 className="text-lg font-semibold text-slate-700 dark:text-slate-200 mb-4">
{t('SETTINGS.STEAM')}
</h2>
<form onSubmit={handleSubmit(onSubmitSteamId)} className="space-y-4" noValidate>
<div>
<label htmlFor="steamId" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('SETTINGS.STEAM_ID')}
</label>
<input
id="steamId"
type="text"
inputMode="numeric"
className={errors.steamId ? inputErrorClass : inputClass}
{...register('steamId')}
/>
{renderError('steamId')}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold rounded-lg transition-colors duration-200"
>
{isSubmitting ? '...' : t('SETTINGS.SAVE_STEAM_ID')}
</button>
</form>
</section>
{feedback.message && (
<div
className={`mt-6 p-4 rounded-lg text-sm text-center ${
feedback.isError
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
}`}
>
{feedback.message}
</div>
)}
</div>
</div>
</div>
);
}