Public Access
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.
337 lines
14 KiB
React
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>
|
|
);
|
|
}
|