Added registration and settings page

This commit is contained in:
Andreas Dahm committed 2026-04-23 09:04:12 +02:00
1 parent b34234caa1
commit 2b5f77f0b9
12 files changed
+905 -142

No files matched your search

+10 -4
View File
@@ -1,5 +1,5 @@
import React, {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {Link, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
const Dashboard = () => {
@@ -83,9 +83,15 @@ const Dashboard = () => {
<div className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center">
<span className="text-slate-400 dark:text-slate-500">Tile 2 (Coming soon)</span>
</div>
<div className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex items-center justify-center">
<span className="text-slate-400 dark:text-slate-500">Settings (Coming soon)</span>
</div>
<Link
to="/settings"
className="bg-white dark:bg-slate-800 h-40 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 flex flex-col items-center justify-center hover:border-blue-500 dark:hover:border-blue-500 transition-colors"
>
<i className="mdi mdi-cog text-4xl text-slate-500 dark:text-slate-400 mb-2"></i>
<span className="text-slate-700 dark:text-slate-300 font-semibold">
{t('SETTINGS.TITLE')}
</span>
</Link>
</div>
</div>
+8 -1
View File
@@ -1,6 +1,6 @@
import {useTranslation} from 'react-i18next';
import {useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {Link, useNavigate} from 'react-router-dom';
export default function LoginPage() {
const {t} = useTranslation();
@@ -131,6 +131,13 @@ export default function LoginPage() {
</div>
)}
<p className="mt-6 text-sm text-center text-slate-600 dark:text-slate-400">
{t('LOGIN.NO_ACCOUNT')}{' '}
<Link to="/register" className="text-blue-600 hover:text-blue-500 font-semibold">
{t('REGISTRATION.TITLE')}
</Link>
</p>
</div>
</div>
);
+185
View File
@@ -0,0 +1,185 @@
import {useState} from 'react';
import {Link, useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {registrationSchema} from '../utils/validation.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',
};
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';
export default function RegistrationPage() {
const {t} = useTranslation();
const navigate = useNavigate();
const [feedback, setFeedback] = useState({message: '', isError: false});
const {
register,
handleSubmit,
formState: {errors, isSubmitting},
} = useForm({
resolver: zodResolver(registrationSchema),
defaultValues: {
email: '',
password: '',
passwordConfirmation: '',
inviteCode: '',
steamId: '',
},
});
const onSubmit = async (values) => {
setFeedback({message: '', isError: false});
try {
const apiUrl = `${import.meta.env.VITE_API_URL}/api/registration/new`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
email: values.email,
password: values.password,
inviteCode: values.inviteCode,
steamId: values.steamId,
}),
});
if (response.ok) {
setFeedback({message: t('REGISTRATION.SUCCESS'), isError: false});
setTimeout(() => navigate('/login'), 800);
return;
}
const data = await response.json().catch(() => ({}));
const mappedKey = BACKEND_ERROR_KEY[data.code];
setFeedback({
message: mappedKey ? t(mappedKey) : data.message || t('REGISTRATION.ERROR_GENERIC'),
isError: true,
});
} catch (err) {
console.error('Registration API error:', err);
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
}
};
const renderError = (field) =>
errors[field] && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{t(errors[field].message)}</p>
);
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 dark:bg-slate-900 px-4 py-8">
<div className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8">
<h2 className="text-2xl font-bold text-slate-900 dark:text-white mb-6 text-center">
{t('REGISTRATION.TITLE')}
</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('REGISTRATION.EMAIL')}
</label>
<input
id="email"
type="email"
autoComplete="email"
className={errors.email ? inputErrorClass : inputClass}
{...register('email')}
/>
{renderError('email')}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('REGISTRATION.PASSWORD')}
</label>
<input
id="password"
type="password"
autoComplete="new-password"
className={errors.password ? inputErrorClass : inputClass}
{...register('password')}
/>
{renderError('password')}
</div>
<div>
<label htmlFor="passwordConfirmation" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('REGISTRATION.PASSWORD_CONFIRMATION')}
</label>
<input
id="passwordConfirmation"
type="password"
autoComplete="new-password"
className={errors.passwordConfirmation ? inputErrorClass : inputClass}
{...register('passwordConfirmation')}
/>
{renderError('passwordConfirmation')}
</div>
<div>
<label htmlFor="inviteCode" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('REGISTRATION.INVITE_CODE')}
</label>
<input
id="inviteCode"
type="text"
className={errors.inviteCode ? inputErrorClass : inputClass}
{...register('inviteCode')}
/>
{renderError('inviteCode')}
</div>
<div>
<label htmlFor="steamId" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
{t('REGISTRATION.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('REGISTRATION.SUBMIT')}
</button>
</form>
{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>
)}
<p className="mt-6 text-sm text-center text-slate-600 dark:text-slate-400">
{t('REGISTRATION.HAVE_ACCOUNT')}{' '}
<Link to="/login" className="text-blue-600 hover:text-blue-500 font-semibold">
{t('MAIN.LOGIN')}
</Link>
</p>
</div>
</div>
);
}
+334
View File
@@ -0,0 +1,334 @@
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 ? String(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};
}
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 ?? 0,
});
};
const handleLanguageChange = async (event) => {
const next = event.target.value;
setLanguage(next); // apply on the fly
await persistSettings({
useDarkTheme: isDarkMode,
language: next,
steamId: profile?.steamId ?? 0,
});
};
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>
);
}