feat(onboarding): remove dead ends for new users (#55)

Registration shows the address used and can start over.
Confirm page says an admin approves; no login link.
Welcome puts next steps first; Steam link returns to the dashboard.
Dashboard and library explain an empty or private library.
First sync shows queued, polls slower up to 10 min, then a calm text.
Login: simple checks, resend offer after 401, password toggles.
Only a real expired link counts as invalid; typed passwords stay.
Default language follows the browser; texts are platform-neutral.
This commit is contained in:
lobo committed 2026-10-21 09:37:17 +02:00
1 parent a60010e055
commit f9f0d94e2c
45 files changed
+979 -171

No files matched your search

+24 -7
View File
@@ -138,7 +138,8 @@ flowchart LR
- **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.
verify it; unlinking is possible. After a successful link the settings page opens the dashboard (success toast),
where the first sync starts; the other outcomes (in use, cancelled, failed) stay on the settings page.
- **Public profile** at `/u/<display name>`, opt-in in the settings, with perfect games and average completion.
The owner arranges it with the same edit mode on the profile page itself (the tiles, incl. the stat groups of
"Your stats"), and sets its look in the settings: accent, corners, banner and background from own game art
@@ -151,9 +152,16 @@ flowchart LR
- **Registration** (`/register`): no invite code; the form asks for email and display name only (no password); the page says that the user gets a mail with a link, then an admin
checks the request, and stays on "registration sent" after the send. Auth answers `409 PENDING_LIMIT_REACHED` while
20 wait. The mail link opens `/confirm-email#<token>` (the token stays in the fragment and goes to Auth in a POST
body); the page asks for the password (and its repeat) and confirms on a button click, not on load, so mail scanners do not burn the link. An expired or used
link and the page after the send offer "send the link
again" (same answer for any address).
body); the page asks for the password (and its repeat, both with a show/hide toggle and the rules as helper text) and confirms on a button click, not on load, so mail scanners do not burn the link. Only a `410` (or a `400` that names the `token` field) shows "link
invalid"; any other `400` is a form error and keeps the typed password. An expired or used link and the page after
the send offer "send the link again" (same answer for any address). After the send the page shows the address used
and "Wrong address? Start over", which brings the form back. After the confirmation the page says that a mail comes
once an admin has approved the account (no waiting time, no login link).
- **Login** (`/login`): the form checks both fields before it sends (zod, like the other forms) and has a show/hide
toggle on the password. Auth answers every failed login with `401` (a wrong password, an unknown address and an
unconfirmed one look the same), so after a `401` the page offers "Not confirmed yet? Send the link again" with the
typed address.
- **Language default**: the cached language, else the browser language (`de*` gives German, any other English), else German.
- **Forgot password** (`/reset-password`): the login page links to it. Without a fragment it asks for the email and
shows the same text for any address. The mail link opens `/reset-password#<token>` (fragment, sent in a POST body on
the button click only): the page asks for the new password twice. A used or expired link shows the email form again.
@@ -202,8 +210,14 @@ flowchart LR
picked ones, send a test to themselves, confirm with the real recipient count, and see the history with the counts.
- **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the
dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from
`/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page
links to it again.
`/me` and the library summary. "Next steps" come first; the main button is the first open step ("Link Steam", then
"Import games"), the one other button is "Go to dashboard". Every way out (buttons, step links, feature links)
stamps it in Auth first, so the gate does not send the user back. The settings page links to it again.
- **Empty library** (dashboard): a card above the tiles. Without a Steam ID or PlayStation link: "Link Steam /
PlayStation" and "Import games". With Steam, a finished sync and no games: Steam's "Game details" must be public,
with a link to the Steam privacy settings (also in the empty library list); no such text for a PlayStation-only user.
The "no Steam" and "empty library" hints stay until the user closes them. Texts are platform neutral ("Welcome"
before the first sync, "Games", "Syncing your library").
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
speedrun link, moods, endless flag, genres and time to beat (hours and minutes) for every user; each field goes back to the source on its own.
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
@@ -340,7 +354,10 @@ flowchart LR
- Owner and user lists (`src/components/people/`, `src/utils/people.js`) need a login and show listed users by name
(a link to `/u/<name>`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is
saved on its own (`PUT /api/users/settings/owner-lists`), never through `saveSettings`.
- `useLibrarySync` polls Core's `/api/library/sync-status` while a Steam sync runs, then loads the data again.
- `useLibrarySync` polls Core's `/api/library/sync-status` while a sync runs, then loads the data again. While a first
sync is expected but Core has no status row (`queued`: "First sync queued ..." with "usually under a minute"), it
polls every 3 s for 15 s, then every 15 s, for 10 minutes in all; a run still going after that shows a calm "check
back later" text.
- The bookmarklet hands its list over in the URL fragment (base64url JSON), which the browser sends to no server.
The import page reads it once and removes it from the URL. `RequireSession` keeps the fragment on the way
through the login page.
+30 -12
View File
@@ -11,14 +11,17 @@
"SUBMIT": "Spiel betreten",
"NO_ACCOUNT": "Noch kein Konto?",
"FORGOT_PASSWORD": "Passwort vergessen?",
"ERROR_EMAIL": "Gib eine gültige E-Mail-Adresse mit „@“ und „.“ ein.",
"ERROR_EMAIL": "Gib eine E-Mail-Adresse mit „@“ ein.",
"ERROR_PASSWORD": "Das Passwort darf nicht leer sein.",
"ERROR_INVALID_CREDENTIALS": "E-Mail oder Passwort ist falsch.",
"ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Versuche es in {{minutes}} Minuten noch einmal.",
"ERROR_GENERIC": "Anmeldung fehlgeschlagen. Versuche es später noch einmal.",
"SESSION_EXPIRED": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
"ERROR_ACCOUNT_BLOCKED": "Dieses Konto ist gesperrt. Bitte wende dich an den Admin.",
"ERROR_ACCOUNT_PENDING": "Deine Registrierung wartet auf den Admin. Du bekommst eine Mail, sobald sie entschieden ist."
"ERROR_ACCOUNT_PENDING": "Deine Registrierung wartet auf den Admin. Du bekommst eine Mail, sobald sie entschieden ist.",
"NOT_CONFIRMED_HINT": "Noch nicht bestätigt? Lass dir den Link noch einmal senden.",
"SHOW_PASSWORD": "Passwort anzeigen",
"HIDE_PASSWORD": "Passwort verbergen"
},
"REGISTRATION": {
"TITLE": "Registrieren",
@@ -26,7 +29,8 @@
"PASSWORD": "Passwort",
"PASSWORD_CONFIRMATION": "Passwort bestätigen",
"SUBMIT": "Konto erstellen",
"SUCCESS": "Wir haben dir eine Mail geschickt. Klick auf den Link darin, um deine Adresse zu bestätigen und dein Passwort zu wählen; erst dann sieht der Admin deine Anfrage.",
"SENT_TO": "Wir haben den Link an {{email}} geschickt.",
"START_OVER": "Falsche Adresse? Neu beginnen",
"RESEND_EMAIL": "E-Mail",
"RESEND": "Link noch einmal senden",
"RESEND_SENT": "Wenn für diese Adresse eine unbestätigte Registrierung besteht, ist ein neuer Link unterwegs.",
@@ -49,6 +53,7 @@
"DONE": "Dein Passwort ist geändert. Melde dich mit dem neuen an.",
"TO_LOGIN": "Anmelden",
"INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 1 Stunde. Fordere einen neuen an:",
"ERROR_REJECTED": "Der Server hat dieses Passwort nicht akzeptiert. Prüfe die Regeln und versuche es erneut.",
"ERROR_TOO_MANY": "Zu viele Versuche. Bitte versuche es in {{minutes}} Minuten noch einmal.",
"ERROR_GENERIC": "Das hat nicht geklappt. Bitte versuche es noch einmal."
},
@@ -56,9 +61,9 @@
"TITLE": "E-Mail-Adresse bestätigen",
"INTRO": "Wähle ein Passwort, um deine E-Mail-Adresse zu bestätigen.",
"SUBMIT": "Bestätigen",
"DONE": "Deine Adresse ist bestätigt. Ein Admin prüft deine Anfrage; du bekommst eine Mail, sobald entschieden ist.",
"DONE": "Deine Adresse ist bestätigt. Du bekommst eine Mail, sobald ein Admin dein Konto freigegeben hat.",
"INVALID": "Dieser Link ist unbekannt, schon benutzt oder älter als 24 Stunden. Fordere einen neuen an:",
"TO_LOGIN": "Anmelden"
"ERROR_REJECTED": "Der Server hat dieses Passwort nicht akzeptiert. Prüfe die Regeln und versuche es erneut."
},
"SETTINGS": {
"PROFILE_STYLE": "Aussehen des Profils",
@@ -211,6 +216,7 @@
"EMAIL_INVALID": "Gib eine gültige E-Mail-Adresse ein.",
"PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.",
"PASSWORD_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.",
"PASSWORD_RULES": "Mindestens 8 Zeichen, höchstens 72 (mit Umlauten oder Emoji weniger).",
"PASSWORD_CONFIRMATION_REQUIRED": "Bestätige dein Passwort.",
"CURRENT_PASSWORD_REQUIRED": "Gib dein aktuelles Passwort ein.",
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
@@ -225,12 +231,19 @@
"DASHBOARD": {
"LOADING": "Lade Dashboard ...",
"HELLO": "Hallo",
"STEAM_GAMES": "Steam-Spiele",
"GAMES": "Spiele",
"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",
"WELCOME_BACK": "Willkommen zurück",
"WELCOME": "Willkommen",
"EMPTY": {
"TITLE": "Deine Bibliothek ist leer",
"NO_SOURCE": "Verknüpfe eine Plattform, dann synchronisieren wir deine Spiele, oder importiere eine Liste.",
"LINK": "Steam / PlayStation verknüpfen",
"IMPORT": "Spiele importieren"
},
"CURRENTLY_PLAYING": "Spiele ich gerade",
"NOTHING_PLAYING": "Gerade nichts. Setze ein Spiel auf „Spiele ich“.",
"UP_NEXT": "Als Nächstes",
@@ -267,7 +280,8 @@
"SOURCE": { "STEAM": "Steam", "PSN": "PlayStation", "MANUAL": "Manuell" },
"TITLE": "Spielebibliothek",
"LOADING": "Lade Spiele …",
"EMPTY": "Noch keine Spiele. Synchronisiere deine Steam-Bibliothek oder füge ein Spiel hinzu.",
"EMPTY": "Noch keine Spiele. Verknüpfe eine Plattform, importiere eine Liste oder füge ein Spiel hinzu.",
"EMPTY_PRIVATE": "Der Sync hat keine Spiele gefunden. Bei Steam müssen die „Spieldetails“ öffentlich sein: [Steam-Datenschutzeinstellungen](https://steamcommunity.com/my/edit/settings). Danach erneut synchronisieren.",
"ERROR_LOAD": "Spiele konnten nicht geladen werden.",
"COMING": "Erscheint am {{date}}",
"COMING_SOON": "Erscheint bald",
@@ -284,10 +298,13 @@
"RESYNC": "Jetzt synchronisieren",
"SYNC_STARTED": "Sync gestartet …",
"SYNC_COOLDOWN": "In {{seconds}}s erneut versuchen",
"SYNCING": "Synchronisiere mit Steam …",
"SYNCING_FIRST_TIME": "Erster Sync mit Steam läuft. Deine Spiele erscheinen gleich.",
"SYNCING": "Synchronisiere deine Bibliothek …",
"SYNCING_FIRST_TIME": "Erster Sync läuft. Deine Spiele erscheinen gleich.",
"SYNC_QUEUED": "Erster Sync ist eingereiht …",
"SYNC_DURATION_HINT": "Meist unter einer Minute, große Bibliotheken länger.",
"SYNC_FAILED": "Sync fehlgeschlagen. Versuche es später noch einmal.",
"SYNC_STALE": "Der Sync dauert länger als sonst. Lade die Seite später neu.",
"SYNC_STALE": "Der Sync läuft noch. Große Bibliotheken brauchen mehr Zeit. Schau später noch einmal vorbei.",
"SYNC_NOT_STARTED": "Der erste Sync ist nicht gestartet. Versuche „Jetzt synchronisieren“.",
"LAST_SYNCED": "Zuletzt synchronisiert {{when}}",
"FILTER_ALL": "Alle",
"SEARCH_PLACEHOLDER": "Spiel suchen …",
@@ -1035,8 +1052,9 @@
"STEP_STEAM": "Steam-Konto verknüpfen",
"STEP_IMPORT": "Deine Spiele importieren",
"DONE": "Erledigt",
"GO": "Los geht's",
"SKIP": "Überspringen"
"GO": "Zum Dashboard",
"ACTION_STEAM": "Steam verknüpfen",
"ACTION_IMPORT": "Spiele importieren"
},
"ADMIN_NEWS": {
"NEW": "Neuer Eintrag",
+30 -12
View File
@@ -11,14 +11,17 @@
"SUBMIT": "Enter the game",
"NO_ACCOUNT": "No account yet?",
"FORGOT_PASSWORD": "Forgot password?",
"ERROR_EMAIL": "Enter a valid email address with '@' and '.'.",
"ERROR_EMAIL": "Enter an email address with '@'.",
"ERROR_PASSWORD": "The password must not be empty.",
"ERROR_INVALID_CREDENTIALS": "Email or password is wrong.",
"ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes.",
"ERROR_GENERIC": "Login failed. Please try again later.",
"SESSION_EXPIRED": "Your session has expired. Please log in again.",
"ERROR_ACCOUNT_BLOCKED": "This account is blocked. Please contact the admin.",
"ERROR_ACCOUNT_PENDING": "Your registration is waiting for the admin. You will get an email once it is decided."
"ERROR_ACCOUNT_PENDING": "Your registration is waiting for the admin. You will get an email once it is decided.",
"NOT_CONFIRMED_HINT": "Not confirmed yet? Have the link sent again.",
"SHOW_PASSWORD": "Show password",
"HIDE_PASSWORD": "Hide password"
},
"REGISTRATION": {
"TITLE": "Register",
@@ -26,7 +29,8 @@
"PASSWORD": "Password",
"PASSWORD_CONFIRMATION": "Confirm password",
"SUBMIT": "Create account",
"SUCCESS": "We sent you an email. Click the link in it to confirm your address and choose your password; only then will the admin see your request.",
"SENT_TO": "We sent the link to {{email}}.",
"START_OVER": "Wrong address? Start over",
"RESEND_EMAIL": "Email",
"RESEND": "Send the link again",
"RESEND_SENT": "If this address has an unconfirmed registration, a new link is on its way.",
@@ -49,6 +53,7 @@
"DONE": "Your password has been changed. Log in with the new one.",
"TO_LOGIN": "Log in",
"INVALID": "This link is unknown, already used or older than 1 hour. Request a new one:",
"ERROR_REJECTED": "The server did not accept this password. Check the rules and try again.",
"ERROR_TOO_MANY": "Too many attempts. Please try again in {{minutes}} minutes.",
"ERROR_GENERIC": "That did not work. Please try again."
},
@@ -56,9 +61,9 @@
"TITLE": "Confirm your email address",
"INTRO": "Choose a password to confirm your email address.",
"SUBMIT": "Confirm",
"DONE": "Your address is confirmed. An admin will check your request; you will get an email once it is decided.",
"DONE": "Your address is confirmed. You get a mail once an admin has approved your account.",
"INVALID": "This link is unknown, already used or older than 24 hours. Request a new one:",
"TO_LOGIN": "Log in"
"ERROR_REJECTED": "The server did not accept this password. Check the rules and try again."
},
"SETTINGS": {
"PROFILE_STYLE": "Profile look",
@@ -211,6 +216,7 @@
"EMAIL_INVALID": "Enter a valid email address.",
"PASSWORD_TOO_SHORT": "Password must be at least 8 characters.",
"PASSWORD_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.",
"PASSWORD_RULES": "At least 8 characters, at most 72 (fewer with umlauts or emoji).",
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
"CURRENT_PASSWORD_REQUIRED": "Enter your current password.",
"PASSWORD_MISMATCH": "Passwords do not match.",
@@ -225,12 +231,19 @@
"DASHBOARD": {
"LOADING": "Loading dashboard ...",
"HELLO": "Hello",
"STEAM_GAMES": "Steam games",
"GAMES": "Games",
"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",
"WELCOME_BACK": "Welcome back",
"WELCOME": "Welcome",
"EMPTY": {
"TITLE": "Your library is empty",
"NO_SOURCE": "Link a platform and we sync your games, or import a list.",
"LINK": "Link Steam / PlayStation",
"IMPORT": "Import games"
},
"CURRENTLY_PLAYING": "Currently playing",
"NOTHING_PLAYING": "Nothing right now. Set a game to \"Playing\".",
"UP_NEXT": "Up next",
@@ -267,7 +280,8 @@
"SOURCE": { "STEAM": "Steam", "PSN": "PlayStation", "MANUAL": "Manual" },
"TITLE": "Game Library",
"LOADING": "Loading games …",
"EMPTY": "No games yet. Sync your Steam library or add a game.",
"EMPTY": "No games yet. Link a platform, import a list or add a game.",
"EMPTY_PRIVATE": "The sync found no games. On Steam, \"Game details\" must be public: [Steam privacy settings](https://steamcommunity.com/my/edit/settings). Then sync again.",
"ERROR_LOAD": "Could not load your games.",
"COMING": "Out on {{date}}",
"COMING_SOON": "Coming soon",
@@ -284,10 +298,13 @@
"RESYNC": "Sync now",
"SYNC_STARTED": "Sync started …",
"SYNC_COOLDOWN": "Try again in {{seconds}}s",
"SYNCING": "Syncing with Steam …",
"SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.",
"SYNCING": "Syncing your library …",
"SYNCING_FIRST_TIME": "First sync in progress. Your games will appear here shortly.",
"SYNC_QUEUED": "First sync queued …",
"SYNC_DURATION_HINT": "Usually under a minute, large libraries longer.",
"SYNC_FAILED": "Sync failed. Please try again later.",
"SYNC_STALE": "The sync is taking longer than usual. Reload the page later.",
"SYNC_STALE": "The sync is still running. Large libraries need more time. Check back later.",
"SYNC_NOT_STARTED": "The first sync did not start. Try \"Sync now\".",
"LAST_SYNCED": "Last synced {{when}}",
"FILTER_ALL": "All",
"SEARCH_PLACEHOLDER": "Search games …",
@@ -1035,8 +1052,9 @@
"STEP_STEAM": "Link your Steam account",
"STEP_IMPORT": "Import your games",
"DONE": "Done",
"GO": "Let's go",
"SKIP": "Skip"
"GO": "Go to dashboard",
"ACTION_STEAM": "Link Steam",
"ACTION_IMPORT": "Import games"
},
"ADMIN_NEWS": {
"NEW": "New entry",
+7 -5
View File
@@ -8,6 +8,7 @@ import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import FormField from './FormField.jsx';
import PasswordInput from './PasswordInput.jsx';
import {changePassword} from '../utils/account.js';
import {changePasswordSchema} from '../utils/validation.js';
@@ -54,13 +55,14 @@ export default function ChangePasswordCard() {
<Input id="currentPassword" type="password" autoComplete="current-password"
aria-invalid={!!errors.currentPassword} {...register('currentPassword')}/>
</FormField>
<FormField id="newPassword" label={t('SETTINGS.CHANGE_PASSWORD.NEW')} error={errors.password} t={t}>
<Input id="newPassword" type="password" autoComplete="new-password"
aria-invalid={!!errors.password} {...register('password')}/>
<FormField id="newPassword" label={t('SETTINGS.CHANGE_PASSWORD.NEW')} error={errors.password}
description={t('VALIDATION.PASSWORD_RULES')} t={t}>
<PasswordInput id="newPassword" autoComplete="new-password" aria-invalid={!!errors.password}
aria-describedby="newPassword-description" {...register('password')}/>
</FormField>
<FormField id="newPasswordRepeat" label={t('SETTINGS.CHANGE_PASSWORD.REPEAT')} error={errors.passwordConfirmation} t={t}>
<Input id="newPasswordRepeat" type="password" autoComplete="new-password"
aria-invalid={!!errors.passwordConfirmation} {...register('passwordConfirmation')}/>
<PasswordInput id="newPasswordRepeat" autoComplete="new-password"
aria-invalid={!!errors.passwordConfirmation} {...register('passwordConfirmation')}/>
</FormField>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="animate-spin"/>}
@@ -28,6 +28,14 @@ describe('ChangePasswordCard', () => {
vi.clearAllMocks();
});
it('showsToggleAndRulesOnTheNewPasswordFields', () => {
render(<ChangePasswordCard/>);
expect(screen.getAllByRole('button', {name: 'LOGIN.SHOW_PASSWORD'}).length).toBeGreaterThanOrEqual(2);
expect(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.NEW').getAttribute('aria-describedby'))
.toBe(screen.getByText('VALIDATION.PASSWORD_RULES').id);
});
it('sends the current and the new password and clears the form on success', async () => {
const mock = answers(json(204));
render(<ChangePasswordCard/>);
+5 -3
View File
@@ -8,6 +8,8 @@ import {canCorrect} from '../utils/corrections.js';
import {fetchLinkQueue} from '../utils/links.js';
export const AUTO_CLOSE_MS = 30_000;
// Getting-started hints stay until the user closes them.
const STICKY_HINTS = ['NO_STEAM', 'EMPTY_LIBRARY'];
const CLOSED_KEY = 'tbp_closed_hints';
// Hints closed in this tab, so going back to the dashboard does not show them again.
@@ -29,8 +31,8 @@ const rememberClosed = (hintId) => {
/**
* The hints on the dashboard, one at a time: the admin hints newest first, then the automatic ones. A DISMISS hint
* closed by the 30 s timer without "Do not show again" comes back in the next browser session. The X on an automatic
* hint hides it for 7 days, "Do not show again" for good.
* closed by the 30 s timer without "Do not show again" comes back in the next browser session; STICKY_HINTS have no
* timer. The X on an automatic hint hides it for 7 days, "Do not show again" for good.
*
* @param user the current user
* @param canSync a Steam ID or a PlayStation link: then the NO_STEAM hint has nothing to say; undefined while loading
@@ -105,7 +107,7 @@ export default function DashboardHint({user, canSync, summary, trend}) {
}, [hint, t]);
useEffect(() => {
if (hint?.kind !== 'DISMISS') return undefined;
if (hint?.kind !== 'DISMISS' || STICKY_HINTS.includes(hint.auto)) return undefined;
const timer = setTimeout(() => close(hideForever, false), AUTO_CLOSE_MS);
return () => clearTimeout(timer);
}, [hint, hideForever, close]);
+20 -6
View File
@@ -20,7 +20,8 @@ const GREW = {days: [{day: '2026-10-01', backlogGames: 10}, {day: '2026-10-02',
const hintRoutes = (list, done = json(204), more = {}) => fetchRoutes({
'GET /api/hints': list, 'PUT /api/hints/1/done': done, 'PUT /api/hints/2/done': done,
'PUT /api/hints/auto/NO_STEAM/snooze': done, 'PUT /api/hints/auto/NO_STEAM/done': done, ...more,
'PUT /api/hints/auto/NO_STEAM/snooze': done, 'PUT /api/hints/auto/NO_STEAM/done': done,
'PUT /api/hints/auto/PILE_GREW/done': done, 'PUT /api/hints/auto/EMPTY_LIBRARY/snooze': done, ...more,
});
const doneCalls = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([url]) => url);
@@ -193,7 +194,7 @@ describe('DashboardHint, automatic hints', () => {
});
it('does not store the auto close of an automatic hint and shows it again next session', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
await showAuto(['PILE_GREW'], {trend: GREW});
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
@@ -201,17 +202,30 @@ describe('DashboardHint, automatic hints', () => {
expect(doneCalls()).toEqual([]);
cleanup();
sessionStorage.clear();
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
expect(screen.getByText('HINT.AUTO_NO_STEAM')).toBeTruthy();
await showAuto(['PILE_GREW'], {trend: GREW});
expect(screen.getByText('HINT.AUTO_PILE_GREW:2')).toBeTruthy();
});
it('hides the auto closed hint for good when the tick is set', async () => {
await showAuto(['NO_STEAM'], {user: {...USER, steamId: null}, canSync: false});
await showAuto(['PILE_GREW'], {trend: GREW});
fireEvent.click(screen.getByLabelText('HINT.DO_NOT_SHOW_AGAIN'));
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS));
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/NO_STEAM/done')]);
expect(doneCalls()).toEqual([expect.stringContaining('/api/hints/auto/PILE_GREW/done')]);
});
it.each([
['NO_STEAM', {user: {...USER, steamId: null}, canSync: false}],
['EMPTY_LIBRARY', {summary: {total: 0}}],
])('keeps the %s hint after the auto close time, it closes only by the user', async (key, props) => {
await showAuto([key], props);
await act(async () => vi.advanceTimersByTime(AUTO_CLOSE_MS * 2));
expect(screen.getByText(`HINT.AUTO_${key}`)).toBeTruthy();
await act(async () => fireEvent.click(screen.getByLabelText('HINT.CLOSE')));
expect(screen.queryByRole('status')).toBeNull();
});
it('does not show an automatic hint that Auth did not send', async () => {
+3 -2
View File
@@ -15,7 +15,7 @@ import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {bannerEffect, lookClass} from '../utils/rewards.js';
// canSync: a Steam ID or a PlayStation link; the sync controls follow it, the Steam ID badge only the Steam ID.
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, canSync, editing, onCustomize, equipped}) {
export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forceSync, canSync, editing, onCustomize, equipped, returning}) {
const {t} = useTranslation();
const {user: userData} = useCurrentUserContext();
const hasSteamId = !!userData?.steamId;
@@ -36,7 +36,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
</span>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
<p className="text-sm font-medium text-muted-foreground">{t(returning ? 'DASHBOARD.WELCOME_BACK' : 'DASHBOARD.WELCOME')}</p>
<h1 className="@container mt-1 text-3xl md:text-4xl font-bold tracking-tight">
<FitText text={[t('DASHBOARD.HELLO'), userData?.displayName ?? userData?.email?.split('@')[0], '👋'].filter(Boolean).join(' ')}/>
</h1>
@@ -61,6 +61,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
queued={sync.queued}
stale={sync.stale}
/>
<Button onClick={forceSync.trigger} disabled={syncBusy}>
+35
View File
@@ -0,0 +1,35 @@
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Library} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import HintText from './HintText.jsx';
/**
* The dashboard's answer to an empty library.
*
* @param reason 'NO_SOURCE' (nothing linked yet) or 'STEAM_PRIVATE' (Steam is linked and synced, but found no games)
*/
export default function EmptyLibraryCard({reason}) {
const {t} = useTranslation();
return (
<Card className="items-center gap-3 p-6 text-center">
<Library className="size-10 text-muted-foreground"/>
<h2 className="font-semibold">{t('DASHBOARD.EMPTY.TITLE')}</h2>
<p className="max-w-md text-sm text-muted-foreground">
{reason === 'STEAM_PRIVATE'
? <HintText text={t('LIBRARY.EMPTY_PRIVATE')}/>
: t('DASHBOARD.EMPTY.NO_SOURCE')}
</p>
<div className="flex flex-wrap justify-center gap-2">
{reason === 'NO_SOURCE' && (
<Button asChild><Link to="/settings">{t('DASHBOARD.EMPTY.LINK')}</Link></Button>
)}
<Button asChild variant={reason === 'NO_SOURCE' ? 'outline' : 'default'}>
<Link to="/import">{t('DASHBOARD.EMPTY.IMPORT')}</Link>
</Button>
</div>
</Card>
);
}
+34
View File
@@ -0,0 +1,34 @@
// @vitest-environment jsdom
import {readFileSync} from 'node:fs';
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import EmptyLibraryCard from './EmptyLibraryCard.jsx';
// The real English text, so the test sees the privacy link.
const texts = JSON.parse(readFileSync('public/i18n/en.json', 'utf-8'));
vi.mock('react-i18next', () => ({
useTranslation: () => ({t: (key) => key.split('.').reduce((node, part) => node?.[part], texts) ?? key}),
}));
const renderCard = (reason) => render(<MemoryRouter><EmptyLibraryCard reason={reason}/></MemoryRouter>);
describe('EmptyLibraryCard', () => {
afterEach(cleanup);
it('NO_SOURCE offers to link a platform and to import', () => {
renderCard('NO_SOURCE');
expect(screen.getByRole('link', {name: 'Link Steam / PlayStation'}).getAttribute('href')).toBe('/settings');
expect(screen.getByRole('link', {name: 'Import games'}).getAttribute('href')).toBe('/import');
});
it('STEAM_PRIVATE links the Steam privacy page and still offers the import', () => {
renderCard('STEAM_PRIVATE');
const link = screen.getByRole('link', {name: 'Steam privacy settings'});
expect(link.getAttribute('href')).toBe('https://steamcommunity.com/my/edit/settings');
expect(screen.getByRole('link', {name: 'Import games'})).toBeTruthy();
expect(screen.queryByRole('link', {name: 'Link Steam / PlayStation'})).toBeNull();
});
});
+4 -2
View File
@@ -2,13 +2,15 @@ import {Label} from '@/components/ui/label';
/**
* Label, input and the translated validation error of one form field.
* `error` is a react-hook-form field error whose message is an i18n key.
* `error` is a react-hook-form field error whose message is an i18n key. `description` is helper text under the input;
* the input points to it with aria-describedby="<id>-description".
*/
export default function FormField({id, label, error, t, children}) {
export default function FormField({id, label, error, description, t, children}) {
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
{children}
{description && <p id={`${id}-description`} className="text-xs text-muted-foreground">{description}</p>}
{error && <p className="text-sm text-destructive">{t(error.message)}</p>}
</div>
);
+8 -7
View File
@@ -1,19 +1,20 @@
import {Input} from '@/components/ui/input';
import FormField from './FormField.jsx';
import PasswordInput from './PasswordInput.jsx';
/**
* The new password and its repeat, for a form on confirmEmailSchema.
* The new password and its repeat, for a form on confirmEmailSchema. The rules are visible from the start.
*/
export default function NewPasswordFields({register, errors, t}) {
return <>
<FormField id="password" label={t('REGISTRATION.PASSWORD')} error={errors.password} t={t}>
<Input id="password" type="password" autoComplete="new-password" aria-invalid={!!errors.password}
{...register('password')}/>
<FormField id="password" label={t('REGISTRATION.PASSWORD')} error={errors.password}
description={t('VALIDATION.PASSWORD_RULES')} t={t}>
<PasswordInput id="password" autoComplete="new-password" aria-invalid={!!errors.password}
aria-describedby="password-description" {...register('password')}/>
</FormField>
<FormField id="passwordConfirmation" label={t('REGISTRATION.PASSWORD_CONFIRMATION')}
error={errors.passwordConfirmation} t={t}>
<Input id="passwordConfirmation" type="password" autoComplete="new-password"
aria-invalid={!!errors.passwordConfirmation} {...register('passwordConfirmation')}/>
<PasswordInput id="passwordConfirmation" autoComplete="new-password"
aria-invalid={!!errors.passwordConfirmation} {...register('passwordConfirmation')}/>
</FormField>
</>;
}
+26
View File
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import NewPasswordFields from './NewPasswordFields.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const register = (name) => ({name});
describe('NewPasswordFields', () => {
afterEach(cleanup);
it('shows the password rules from the start and links them to the password input', () => {
render(<NewPasswordFields register={register} errors={{}} t={(key) => key}/>);
const rules = screen.getByText('VALIDATION.PASSWORD_RULES');
const input = screen.getByLabelText('REGISTRATION.PASSWORD');
expect(input.getAttribute('aria-describedby')).toBe(rules.id);
});
it('has a show-password toggle on both fields', () => {
render(<NewPasswordFields register={register} errors={{}} t={(key) => key}/>);
expect(screen.getAllByRole('button', {name: 'LOGIN.SHOW_PASSWORD'})).toHaveLength(2);
});
});
+26
View File
@@ -0,0 +1,26 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Eye, EyeOff} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {cn} from '@/lib/utils';
/**
* A password input with a button that shows or hides the text. Every other prop, also a react-hook-form `register()`
* result with its ref, goes to the input.
*/
export default function PasswordInput({className, ...props}) {
const {t} = useTranslation();
const [shown, setShown] = useState(false);
return (
<div className="relative">
<Input type={shown ? 'text' : 'password'} className={cn('pr-10', className)} {...props}/>
<Button type="button" variant="ghost" size="icon" aria-pressed={shown}
aria-label={t(shown ? 'LOGIN.HIDE_PASSWORD' : 'LOGIN.SHOW_PASSWORD')}
className="absolute right-1 top-1/2 size-7 -translate-y-1/2" onClick={() => setShown(!shown)}>
{shown ? <EyeOff/> : <Eye/>}
</Button>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import PasswordInput from './PasswordInput.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
describe('PasswordInput', () => {
afterEach(cleanup);
it('hides the text at first and toggles it with aria-pressed', () => {
render(<PasswordInput aria-label="pw"/>);
const input = screen.getByLabelText('pw');
expect(input.type).toBe('password');
expect(screen.getByRole('button', {name: 'LOGIN.SHOW_PASSWORD'}).getAttribute('aria-pressed')).toBe('false');
fireEvent.click(screen.getByRole('button', {name: 'LOGIN.SHOW_PASSWORD'}));
expect(input.type).toBe('text');
expect(screen.getByRole('button', {name: 'LOGIN.HIDE_PASSWORD'}).getAttribute('aria-pressed')).toBe('true');
});
it('does not submit the form with the toggle', () => {
const onSubmit = vi.fn((event) => event.preventDefault());
render(<form onSubmit={onSubmit}><PasswordInput aria-label="pw"/></form>);
fireEvent.click(screen.getByRole('button', {name: 'LOGIN.SHOW_PASSWORD'}));
expect(onSubmit).not.toHaveBeenCalled();
});
it('passes the other props to the input', () => {
render(<PasswordInput aria-label="pw" autoComplete="new-password" defaultValue="abc"/>);
const input = screen.getByLabelText('pw');
expect(input.getAttribute('autocomplete')).toBe('new-password');
expect(input.value).toBe('abc');
});
});
+1 -1
View File
@@ -43,7 +43,7 @@ const SteamGameCount = ({targetCount}) => {
{targetCount > 0
? <Link to="/library" className={`${COUNT_CLASS} hover:text-primary`}>{count}</Link>
: <span className={COUNT_CLASS}>{count}</span>}
<div className="mt-1 text-sm text-muted-foreground">{t('DASHBOARD.STEAM_GAMES')}</div>
<div className="mt-1 text-sm text-muted-foreground">{t('DASHBOARD.GAMES')}</div>
</div>
<Link to="/library" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline">
{t('DASHBOARD.TO_LIBRARY')} <ArrowRight className="size-4"/>
+1 -1
View File
@@ -18,7 +18,7 @@ describe('SteamGameCount', () => {
rerender(<MemoryRouter><SteamGameCount targetCount={5}/></MemoryRouter>);
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2);
expect(screen.getByText('DASHBOARD.STEAM_GAMES')).toBeTruthy();
expect(screen.getByText('DASHBOARD.GAMES')).toBeTruthy();
});
it('showsTheFinalCountAtOnceUnderReducedMotion', () => {
+21 -5
View File
@@ -12,14 +12,30 @@ const formatRelative = (iso, locale) => {
return rtf.format(-Math.round(diffSec / 86400), 'day');
};
export default function SyncStatusBadge({status, lastSyncedAt, isRunning, stale}) {
export default function SyncStatusBadge({status, lastSyncedAt, isRunning, queued, stale}) {
const {t, i18n} = useTranslation();
const durationHint = <span className="text-xs text-muted-foreground">{t('LIBRARY.SYNC_DURATION_HINT')}</span>;
if (isRunning) {
return (
<div className="flex items-center gap-2 text-sm text-primary">
<RefreshCw className="size-4 animate-spin"/>
<span>{t('LIBRARY.SYNCING')}</span>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm text-primary">
<RefreshCw className="size-4 animate-spin"/>
<span>{t('LIBRARY.SYNCING')}</span>
</div>
{!lastSyncedAt && durationHint}
</div>
);
}
if (queued) {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="size-4"/>
<span>{t('LIBRARY.SYNC_QUEUED')}</span>
</div>
{durationHint}
</div>
);
}
@@ -28,7 +44,7 @@ export default function SyncStatusBadge({status, lastSyncedAt, isRunning, stale}
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="size-4"/>
<span>{t('LIBRARY.SYNC_STALE')}</span>
<span>{t(status == null ? 'LIBRARY.SYNC_NOT_STARTED' : 'LIBRARY.SYNC_STALE')}</span>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import SyncStatusBadge from './SyncStatusBadge.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
describe('SyncStatusBadge', () => {
afterEach(cleanup);
it('says the first sync is queued, with the duration hint', () => {
render(<SyncStatusBadge status={null} lastSyncedAt={null} isRunning={false} queued/>);
expect(screen.getByText('LIBRARY.SYNC_QUEUED')).toBeTruthy();
expect(screen.getByText('LIBRARY.SYNC_DURATION_HINT')).toBeTruthy();
});
it('shows the duration hint while the first sync runs', () => {
render(<SyncStatusBadge status="RUNNING" lastSyncedAt={null} isRunning/>);
expect(screen.getByText('LIBRARY.SYNCING')).toBeTruthy();
expect(screen.getByText('LIBRARY.SYNC_DURATION_HINT')).toBeTruthy();
});
it('shows no duration hint for a later sync', () => {
render(<SyncStatusBadge status="RUNNING" lastSyncedAt="2026-10-21T08:00:00Z" isRunning/>);
expect(screen.queryByText('LIBRARY.SYNC_DURATION_HINT')).toBeNull();
});
it('shows the calm text when a run is still going after the poll limit', () => {
render(<SyncStatusBadge status="RUNNING" lastSyncedAt={null} isRunning={false} stale/>);
expect(screen.getByText('LIBRARY.SYNC_STALE')).toBeTruthy();
});
it('says the first sync did not start when no status row ever came', () => {
render(<SyncStatusBadge status={null} lastSyncedAt={null} isRunning={false} stale/>);
expect(screen.getByText('LIBRARY.SYNC_NOT_STARTED')).toBeTruthy();
expect(screen.queryByText('LIBRARY.SYNC_STALE')).toBeNull();
});
});
+13 -8
View File
@@ -3,12 +3,16 @@ import {apiFetch} from '../utils/api.js';
import {hasSession} from '../utils/auth.js';
const POLL_INTERVAL_MS = 3000;
const MAX_POLL_DURATION_MS = 120_000;
const SLOW_POLL_INTERVAL_MS = 15_000;
const MAX_POLL_DURATION_MS = 600_000;
const INITIAL_SYNC_GRACE_MS = 15_000;
/**
* Polls /api/library/sync-status while a sync is running (or expected), and fires onComplete once when the run ends.
* A run that still runs after 2 minutes is shown as `stale` and polling stops. `watch()` polls again after the
* While nothing runs yet (first sync queued, or a started sync not seen), it polls every 3 s for 15 s, then every
* 15 s, for 10 minutes in all; a run that is going polls every 15 s after the first 15 s, also up to 10 minutes. A run
* that still runs, or a first sync still without a row, after 10 minutes is shown as `stale` and polling stops.
* `queued` is true while a first sync is expected but Core has no status row yet. `watch()` polls again after the
* user started a sync: that run counts as ended when `lastSyncedAt` changes, also when no poll saw it running.
*
* @param {Object} opts
@@ -16,8 +20,8 @@ const INITIAL_SYNC_GRACE_MS = 15_000;
* @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the
* gap between async sync dispatch and first DB write)
* @param {() => void} opts.onComplete - called once per run that ends
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, stale: boolean, error: string|null,
* watch: () => void}}
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, queued: boolean, stale: boolean,
* error: string|null, watch: () => void}}
*/
export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) {
const [state, setState] = useState({status: null, lastSyncedAt: null, error: null, stale: false});
@@ -68,7 +72,8 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
lastSyncedAtRef.current = lastSyncedAt;
const isRunning = status === 'RUNNING';
const elapsed = Date.now() - startedAt;
const stale = isRunning && elapsed >= MAX_POLL_DURATION_MS;
const waitingForInitial = expectInitialSync && status == null;
const stale = (isRunning || waitingForInitial) && elapsed >= MAX_POLL_DURATION_MS;
setState({status, lastSyncedAt, error: null, stale});
if (since === undefined) since = lastSyncedAt;
@@ -80,10 +85,9 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
}
wasRunning = isRunning;
const waitingForInitial = expectInitialSync && status == null;
const waitingForStart = (watching || waitingForInitial) && elapsed < INITIAL_SYNC_GRACE_MS;
const waitingForStart = (watching || waitingForInitial) && elapsed < MAX_POLL_DURATION_MS;
if ((isRunning && !stale) || waitingForStart) {
timer = setTimeout(fetchOnce, POLL_INTERVAL_MS);
timer = setTimeout(fetchOnce, elapsed < INITIAL_SYNC_GRACE_MS ? POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS);
}
} catch (err) {
if (cancelled) return;
@@ -104,6 +108,7 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
status: state.status,
lastSyncedAt: state.lastSyncedAt,
isRunning: state.status === 'RUNNING' && !state.stale,
queued: enabled && expectInitialSync && state.status == null && state.error == null && !state.stale,
stale: state.stale,
error: state.error,
watch,
+65 -1
View File
@@ -24,7 +24,7 @@ describe('useLibrarySync', () => {
await act(() => vi.advanceTimersByTimeAsync(0));
expect(result.current.isRunning).toBe(true);
await act(() => vi.advanceTimersByTimeAsync(125_000));
await act(() => vi.advanceTimersByTimeAsync(605_000));
expect(result.current.isRunning).toBe(false);
expect(result.current.stale).toBe(true);
@@ -33,6 +33,70 @@ describe('useLibrarySync', () => {
expect(fetch.mock.calls.length).toBe(calls);
});
it('isNotQueuedWhenDisabled', async () => {
fetchRoutes({});
const {result} = renderHook(() => useLibrarySync({enabled: false, expectInitialSync: true, onComplete: () => {}}));
await act(() => vi.advanceTimersByTimeAsync(0));
expect(result.current.queued).toBe(false);
});
it('is queued while a first sync is expected and Core has no status row', async () => {
fetchRoutes({'GET /api/library/sync-status': status(null, null)});
const {result} = renderHook(() => useLibrarySync({enabled: true, expectInitialSync: true, onComplete: () => {}}));
await act(() => vi.advanceTimersByTimeAsync(0));
expect(result.current.queued).toBe(true);
});
it('is not queued without expectInitialSync, or once a row exists', async () => {
fetchRoutes({'GET /api/library/sync-status': status(null, null)});
const first = renderHook(() => useLibrarySync({enabled: true, onComplete: () => {}}));
await act(() => vi.advanceTimersByTimeAsync(0));
expect(first.result.current.queued).toBe(false);
cleanup();
fetchRoutes({'GET /api/library/sync-status': status('RUNNING', null)});
const second = renderHook(() => useLibrarySync({enabled: true, expectInitialSync: true, onComplete: () => {}}));
await act(() => vi.advanceTimersByTimeAsync(0));
expect(second.result.current.queued).toBe(false);
});
it('polls every 3 s for 15 s while queued, then every 15 s, and stops after 10 minutes', async () => {
const fetch = fetchRoutes({'GET /api/library/sync-status': status(null, null)});
renderHook(() => useLibrarySync({enabled: true, expectInitialSync: true, onComplete: () => {}}));
await act(() => vi.advanceTimersByTimeAsync(0));
expect(fetch.mock.calls.length).toBe(1);
await act(() => vi.advanceTimersByTimeAsync(15_000));
expect(fetch.mock.calls.length).toBe(6);
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(fetch.mock.calls.length).toBe(10);
await act(() => vi.advanceTimersByTimeAsync(600_000));
const calls = fetch.mock.calls.length;
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(fetch.mock.calls.length).toBe(calls);
});
it('still sees a first sync that starts after one minute', async () => {
let answer = status(null, null);
fetchRoutes({'GET /api/library/sync-status': () => answer});
const onComplete = vi.fn();
const {result} = renderHook(() => useLibrarySync({enabled: true, expectInitialSync: true, onComplete}));
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(result.current.isRunning).toBe(false);
answer = status('RUNNING', null);
await act(() => vi.advanceTimersByTimeAsync(15_000));
expect(result.current.isRunning).toBe(true);
answer = status('OK', '2026-10-21T08:00:00Z');
await act(() => vi.advanceTimersByTimeAsync(15_000));
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('after watch() reports the end of a sync once, also when it never saw it running', async () => {
let answer = status('OK', '2026-09-30T08:00:00Z');
fetchRoutes({'GET /api/library/sync-status': () => answer});
+2 -2
View File
@@ -1,13 +1,13 @@
import i18n from 'i18next';
import {initReactI18next} from 'react-i18next';
import HttpBackend from 'i18next-http-backend';
import {cacheLanguage, readCachedLanguage} from './utils/settings.js';
import {browserLanguage, cacheLanguage, readCachedLanguage} from './utils/settings.js';
i18n.use(HttpBackend)
.use(initReactI18next)
.init({
lng: readCachedLanguage() ?? 'de',
lng: readCachedLanguage() ?? browserLanguage(),
fallbackLng: 'en',
backend: {
// A browser kept an old file without its Cache-Control and showed raw keys; a new URL per version avoids that.
+6
View File
@@ -69,6 +69,12 @@ describe('translation keys', () => {
}]));
expect(hints('de.json')).toEqual(hints('en.json'));
});
it('emptyPrivateText_HasTheSameSteamPrivacyLinkInDeAndEn', () => {
const targets = (file) => [...readTexts(file).LIBRARY.EMPTY_PRIVATE.matchAll(/\]\(([^)]*)\)/g)].map((match) => match[1]);
expect(targets('en.json')).toEqual(['https://steamcommunity.com/my/edit/settings']);
expect(targets('de.json')).toEqual(targets('en.json'));
});
});
describe('points texts', () => {
+6 -8
View File
@@ -1,5 +1,5 @@
import {useState} from 'react';
import {Link, useLocation} from 'react-router-dom';
import {useLocation} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
@@ -10,6 +10,7 @@ import AuthLayout from '../components/AuthLayout.jsx';
import FeedbackAlert from '../components/FeedbackAlert.jsx';
import NewPasswordFields from '../components/NewPasswordFields.jsx';
import ResendConfirmation from '../components/ResendConfirmation.jsx';
import {isInvalidLinkAnswer} from '../utils/auth.js';
import {confirmEmailSchema} from '../utils/validation.js';
import {clientFetch} from '../utils/api.js';
@@ -42,8 +43,10 @@ function ConfirmEmail({token}) {
});
if (response.ok) {
setState('done');
} else if (response.status === 410 || response.status === 400) {
} else if (isInvalidLinkAnswer(response.status, await response.json().catch(() => ({})))) {
setState('invalid');
} else if (response.status === 400) {
setError(t('CONFIRM_EMAIL.ERROR_REJECTED'));
} else if (response.status === 429) {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
setError(t('REGISTRATION.ERROR_TOO_MANY_ATTEMPTS', {minutes}));
@@ -67,12 +70,7 @@ function ConfirmEmail({token}) {
</Button>
<FeedbackAlert message={error} isError/>
</form>}
{state === 'done' && <>
<FeedbackAlert message={t('CONFIRM_EMAIL.DONE')} isError={false}/>
<Link to="/login" className="block text-center text-sm font-medium text-primary hover:underline">
{t('CONFIRM_EMAIL.TO_LOGIN')}
</Link>
</>}
{state === 'done' && <FeedbackAlert message={t('CONFIRM_EMAIL.DONE')} isError={false}/>}
{state === 'invalid' && <>
<FeedbackAlert message={t('CONFIRM_EMAIL.INVALID')} isError/>
<ResendConfirmation/>
+34
View File
@@ -54,6 +54,40 @@ describe('ConfirmEmailPage', () => {
expect(screen.getByRole('button', {name: 'REGISTRATION.RESEND'})).toBeTruthy();
});
it('keeps the form and the typed password when Auth refuses the password, and does not say the link is invalid', async () => {
fetchRoutes({'POST /api/registration/confirm': json(400, {
code: 'VALIDATION_FAILED', fields: [{field: 'password', message: 'size must be between 8 and 72'}],
})});
renderPage('#tok-123');
submitPasswords('secret123');
expect(await screen.findByText('CONFIRM_EMAIL.ERROR_REJECTED')).toBeTruthy();
expect(screen.queryByText('CONFIRM_EMAIL.INVALID')).toBeNull();
expect(screen.getByLabelText('REGISTRATION.PASSWORD').value).toBe('secret123');
});
it('shows the invalid text when Auth refuses the token field', async () => {
fetchRoutes({'POST /api/registration/confirm': json(400, {
code: 'VALIDATION_FAILED', fields: [{field: 'token', message: 'must not be blank'}],
})});
renderPage('#tok-123');
submitPasswords('secret123');
expect(await screen.findByText('CONFIRM_EMAIL.INVALID')).toBeTruthy();
});
it('offers no plain login link once confirmed, only the wait for the admin', async () => {
fetchRoutes({'POST /api/registration/confirm': json(204, null)});
renderPage('#tok-123');
submitPasswords('secret123');
await screen.findByText('CONFIRM_EMAIL.DONE');
expect(screen.queryByRole('link')).toBeNull();
});
it('a new link in the same tab shows the password form again', async () => {
fetchRoutes({'POST /api/registration/confirm': json(410, {code: 'CONFIRMATION_INVALID'})});
render(
+12 -1
View File
@@ -11,6 +11,7 @@ import ActivityCard from '../components/ActivityCard.jsx';
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
import FollowingCard from '../components/FollowingCard.jsx';
import DashboardHint from '../components/DashboardHint.jsx';
import EmptyLibraryCard from '../components/EmptyLibraryCard.jsx';
import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx';
import MostPlayedCard from '../components/MostPlayedCard.jsx';
import PileOfShameCard from '../components/PileOfShameCard.jsx';
@@ -78,6 +79,14 @@ const Dashboard = () => {
stats.reload();
},
});
const synced = sync.lastSyncedAt != null;
// No games: say why, or what to do. Not while a first sync is expected, and a PlayStation-only user has no
// Steam privacy to explain.
let emptyReason = null;
if (summary?.total === 0 && !sync.isRunning && !sync.queued) {
if (!canSync) emptyReason = 'NO_SOURCE';
else if (hasSteamId && synced) emptyReason = 'STEAM_PRIVATE';
}
const reloadAfterCompleted = () => {
reloadLibrary();
stats.reload();
@@ -144,10 +153,12 @@ const Dashboard = () => {
<StatDrillProvider source="own">
<PageContainer className="p-4 md:p-8 space-y-6">
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
equipped={equipped}
equipped={equipped} returning={steamData.count > 0}
sync={sync} forceSync={forceSync} canSync={canSync} editing={editing} onCustomize={() => setEditing(true)}/>
{canSync && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt} onChanged={reloadAfterCompleted}/>}
{emptyReason && <EmptyLibraryCard reason={emptyReason}/>}
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
<WhatsNewDialog/>
+51 -1
View File
@@ -7,8 +7,9 @@ import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
const watchSync = vi.hoisted(() => vi.fn());
const syncState = vi.hoisted(() => ({lastSyncedAt: null, queued: false}));
vi.mock('../hooks/useLibrarySync.js', () => ({
default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false, watch: watchSync}),
default: () => ({status: 'OK', isRunning: false, watch: watchSync, ...syncState}),
}));
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
@@ -46,6 +47,7 @@ beforeEach(() => {
afterEach(() => {
watchSync.mockClear();
Object.assign(syncState, {lastSyncedAt: null, queued: false});
cleanup();
localStorage.clear();
});
@@ -225,6 +227,54 @@ describe('Dashboard', () => {
expect(button.closest('[data-slot="card"]')?.textContent ?? 'not in a card').toContain('DASHBOARD.WELCOME_BACK');
});
describe('empty library', () => {
const emptyLibrary = (user, count) => fetchRoutes({
...dashboardRoutes(),
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', ...user, settings: {}}),
'GET /api/library/dashboard-games': json(200, {top: [], playing: []}),
'GET /api/library/games-count': json(200, count),
});
it('offers to link a platform or import games to a new user without a source', async () => {
emptyLibrary({steamId: null}, {count: 0, hasSteamId: false, canSync: false});
renderDashboard();
expect(await screen.findByText('DASHBOARD.EMPTY.NO_SOURCE')).toBeTruthy();
expect(screen.getByRole('link', {name: 'DASHBOARD.EMPTY.LINK'}).getAttribute('href')).toBe('/settings');
expect(screen.getByRole('link', {name: 'DASHBOARD.EMPTY.IMPORT'}).getAttribute('href')).toBe('/import');
expect(screen.queryByText('DASHBOARD.WELCOME_BACK')).toBeNull();
expect(screen.getByText('DASHBOARD.WELCOME')).toBeTruthy();
});
it('explains the Steam privacy setting when Steam is synced and found no games', async () => {
syncState.lastSyncedAt = '2026-10-21T08:00:00Z';
emptyLibrary({steamId: '76561198000000000'}, {count: 0, hasSteamId: true, canSync: true});
renderDashboard();
expect(await screen.findByText(/LIBRARY.EMPTY_PRIVATE/)).toBeTruthy();
expect(screen.queryByText('DASHBOARD.EMPTY.NO_SOURCE')).toBeNull();
expect(screen.getByText('DASHBOARD.WELCOME')).toBeTruthy();
});
it('shows no empty card while the first sync is queued', async () => {
syncState.queued = true;
emptyLibrary({steamId: '76561198000000000'}, {count: 0, hasSteamId: true, canSync: true});
renderDashboard();
await screen.findByText('DASHBOARD.WELCOME');
expect(screen.queryByText('DASHBOARD.EMPTY.TITLE')).toBeNull();
});
it('says nothing about Steam privacy to a PlayStation-only user', async () => {
syncState.lastSyncedAt = '2026-10-21T08:00:00Z';
emptyLibrary({steamId: null}, {count: 0, hasSteamId: false, canSync: true});
renderDashboard();
await screen.findByText('DASHBOARD.WELCOME');
expect(screen.queryByText('DASHBOARD.EMPTY.TITLE')).toBeNull();
});
});
it('shows the achievement summary', async () => {
mockDashboard();
+9 -1
View File
@@ -23,8 +23,10 @@ import LibraryTabs from '../components/LibraryTabs.jsx';
import ComingBadge from '../components/ComingBadge.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import PageContainer from '../components/PageContainer.jsx';
import HintText from '../components/HintText.jsx';
import PlatformIcons from '../components/PlatformIcons.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {coverEffect} from '../utils/rewards.js';
import {apiFetch, request} from '../utils/api.js';
import useAbortPrevious from '../hooks/useAbortPrevious.js';
@@ -134,6 +136,7 @@ export default function GameLibrary() {
}, [loadCounts]);
const expectInitialSync = !isLoading && !error && !filtered && data.totalElements === 0;
const {user} = useCurrentUserContext();
const sync = useLibrarySync({
enabled: canSync,
expectInitialSync,
@@ -143,6 +146,9 @@ export default function GameLibrary() {
},
});
// Steam is linked and synced, yet no game came: the profile's game details are private.
const privateProfile = !!user?.steamId && sync.lastSyncedAt != null;
const updateParams = useCallback((next, options) => {
const params = new URLSearchParams(searchParams);
Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
@@ -207,6 +213,7 @@ export default function GameLibrary() {
status={sync.status}
lastSyncedAt={sync.lastSyncedAt}
isRunning={sync.isRunning}
queued={sync.queued}
stale={sync.stale}
/>
<Button variant="outline" size="sm" onClick={triggerSync} disabled={syncing}>
@@ -334,7 +341,8 @@ export default function GameLibrary() {
{filtered ? <SearchX className="size-10"/> : <Library className="size-10"/>}
<p className="max-w-sm text-sm">
{hiddenOnly ? t('LIBRARY.NO_HIDDEN') : filtered ? t('LIBRARY.NO_MATCH')
: sync.isRunning ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
: sync.isRunning || sync.queued ? t('LIBRARY.SYNCING_FIRST_TIME')
: privateProfile ? <HintText text={t('LIBRARY.EMPTY_PRIVATE')}/> : t('LIBRARY.EMPTY')}
</p>
</Card>
)}
+37 -1
View File
@@ -14,9 +14,10 @@ import {toast} from 'sonner';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const syncOptions = vi.hoisted(() => ({current: null}));
const syncState = vi.hoisted(() => ({lastSyncedAt: null, queued: false, isRunning: false}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: (opts) => {
syncOptions.current = opts;
return {status: 'OK', lastSyncedAt: null, isRunning: false};
return {status: 'OK', ...syncState};
}}));
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
@@ -67,6 +68,41 @@ beforeEach(() => {
afterEach(() => {
cleanup();
localStorage.clear();
Object.assign(syncState, {lastSyncedAt: null, queued: false, isRunning: false});
});
describe('GameLibrary empty state', () => {
const emptyFor = (steamId) => fetchRoutes({
'GET /api/users/me': json(200, {steamId, settings: {}}),
'GET /api/library/games': json(200, {...page, content: [], totalElements: 0, totalPages: 0}),
'GET /api/library/games-count': json(200, {count: 0, hasSteamId: !!steamId, canSync: true, sources: []}),
});
it('explains the Steam privacy setting to a Steam user whose sync found no games', async () => {
syncState.lastSyncedAt = '2026-10-21T08:00:00Z';
emptyFor('76561198000000000');
renderLibrary();
expect(await screen.findByText('LIBRARY.EMPTY_PRIVATE')).toBeTruthy();
expect(screen.queryByText('LIBRARY.EMPTY')).toBeNull();
});
it('shows the neutral empty text to a user without Steam', async () => {
syncState.lastSyncedAt = '2026-10-21T08:00:00Z';
emptyFor(null);
renderLibrary();
expect(await screen.findByText('LIBRARY.EMPTY')).toBeTruthy();
expect(screen.queryByText('LIBRARY.EMPTY_PRIVATE')).toBeNull();
});
it('says the first sync is on its way while it is queued', async () => {
syncState.queued = true;
emptyFor('76561198000000000');
renderLibrary();
expect(await screen.findByText('LIBRARY.SYNCING_FIRST_TIME')).toBeTruthy();
});
});
describe('GameLibrary page cache', () => {
+32 -39
View File
@@ -1,15 +1,20 @@
import {useTranslation} from 'react-i18next';
import {useState} from 'react';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {Link, Navigate, useLocation, useNavigate} from 'react-router-dom';
import {Clock, Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import AuthLayout from '../components/AuthLayout.jsx';
import FeedbackAlert from '../components/FeedbackAlert.jsx';
import FormField from '../components/FormField.jsx';
import PasswordInput from '../components/PasswordInput.jsx';
import ResendConfirmation from '../components/ResendConfirmation.jsx';
import {hasSession, markSession, safeReturnPath} from '../utils/auth.js';
import {clientFetch} from '../utils/api.js';
import {loginSchema} from '../utils/validation.js';
const LOGIN_ERROR_KEY = {
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
@@ -24,19 +29,18 @@ export default function LoginPage() {
const {state} = useLocation();
const target = safeReturnPath(state?.from) ?? '/dashboard';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [feedback, setFeedback] = useState({message: '', isError: false});
const [isLoading, setIsLoading] = useState(false);
// The typed address after a 401: Auth answers the same for a wrong password and an unconfirmed address, so the
// page offers a new confirmation mail next to the error.
const [resendEmail, setResendEmail] = useState(null);
const {register, handleSubmit, formState: {errors, isSubmitting}} = useForm({
resolver: zodResolver(loginSchema),
defaultValues: {email: '', password: ''},
});
/**
* Handles the form submission and API call to the backend.
* * @param {Event} e - The form submission event
*/
const handleLogin = async (e) => {
e.preventDefault();
setIsLoading(true);
const handleLogin = async ({email, password}) => {
setFeedback({message: '', isError: false});
setResendEmail(null);
try {
// Build the API URL using the Vite environment variable
@@ -58,6 +62,7 @@ export default function LoginPage() {
navigate(target, {replace: true});
} else {
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
if (response.status === 401) setResendEmail(email);
setFeedback({
message: t(LOGIN_ERROR_KEY[data.code] ?? 'LOGIN.ERROR_GENERIC', {minutes}),
isError: true
@@ -70,8 +75,6 @@ export default function LoginPage() {
message: t('INFO.ERROR.NETWORK_FAILURE'),
isError: true
});
} finally {
setIsLoading(false);
}
};
@@ -93,41 +96,31 @@ export default function LoginPage() {
<FeedbackAlert message={t('LOGIN.SESSION_EXPIRED')} isError={false} icon={Clock}/>
)}
<form onSubmit={handleLogin} className="space-y-4" noValidate>
<div className="space-y-2">
<Label htmlFor="email">{t('LOGIN.EMAIL')}</Label>
<Input
id="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<form onSubmit={handleSubmit(handleLogin)} className="space-y-4" noValidate>
<FormField id="email" label={t('LOGIN.EMAIL')} error={errors.email} t={t}>
<Input id="email" type="email" autoComplete="email" aria-invalid={!!errors.email}
{...register('email')}/>
</FormField>
<div className="space-y-2">
<Label htmlFor="password">{t('LOGIN.PASSWORD')}</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<FormField id="password" label={t('LOGIN.PASSWORD')} error={errors.password} t={t}>
<PasswordInput id="password" autoComplete="current-password" aria-invalid={!!errors.password}
{...register('password')}/>
<Link to="/reset-password" className="block text-sm text-muted-foreground hover:underline">
{t('LOGIN.FORGOT_PASSWORD')}
</Link>
</div>
</FormField>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading && <Loader2 className="animate-spin"/>}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="animate-spin"/>}
{t('LOGIN.SUBMIT')}
</Button>
</form>
<FeedbackAlert message={feedback.message} isError={feedback.isError}/>
{resendEmail !== null && <div className="mt-4 space-y-2">
<p className="text-sm text-muted-foreground">{t('LOGIN.NOT_CONFIRMED_HINT')}</p>
<ResendConfirmation defaultEmail={resendEmail}/>
</div>}
</AuthLayout>
);
}
}
+70
View File
@@ -100,6 +100,76 @@ describe('LoginPage', () => {
expect(await screen.findByText('LOGIN.ERROR_ACCOUNT_PENDING')).toBeTruthy();
});
it('sendsAnAddressWithoutADotToAuth', async () => {
const mock = fetchRoutes({'POST /api/auth/login': refused(401, 'INVALID_CREDENTIALS')});
renderLogin();
fireEvent.change(screen.getByLabelText('LOGIN.EMAIL'), {target: {value: 'dev@localhost'}});
fireEvent.change(screen.getByLabelText('LOGIN.PASSWORD'), {target: {value: 'password123'}});
fireEvent.click(screen.getByRole('button', {name: 'LOGIN.SUBMIT'}));
await screen.findByText('LOGIN.ERROR_INVALID_CREDENTIALS');
expect(mock).toHaveBeenCalledTimes(1);
});
it('sends nothing for an empty form and shows both field errors', async () => {
const mock = fetchRoutes({});
renderLogin();
fireEvent.click(screen.getByRole('button', {name: 'LOGIN.SUBMIT'}));
expect(await screen.findByText('LOGIN.ERROR_EMAIL')).toBeTruthy();
expect(screen.getByText('LOGIN.ERROR_PASSWORD')).toBeTruthy();
expect(mock).not.toHaveBeenCalled();
});
it('sends nothing for an address without an at sign', async () => {
const mock = fetchRoutes({});
renderLogin();
fireEvent.change(screen.getByLabelText('LOGIN.EMAIL'), {target: {value: 'nobody'}});
fireEvent.change(screen.getByLabelText('LOGIN.PASSWORD'), {target: {value: 'secret'}});
fireEvent.click(screen.getByRole('button', {name: 'LOGIN.SUBMIT'}));
expect(await screen.findByText('LOGIN.ERROR_EMAIL')).toBeTruthy();
expect(mock).not.toHaveBeenCalled();
});
it('offers a new confirmation mail for the typed address next to the error after a 401', async () => {
fetchRoutes({'POST /api/auth/login': refused(401, 'INVALID_CREDENTIALS')});
renderLogin();
submit();
expect(await screen.findByText('LOGIN.ERROR_INVALID_CREDENTIALS')).toBeTruthy();
expect(screen.getByText('LOGIN.NOT_CONFIRMED_HINT')).toBeTruthy();
expect(screen.getByLabelText('REGISTRATION.RESEND_EMAIL').value).toBe('a@b.de');
expect(screen.getByRole('button', {name: 'REGISTRATION.RESEND'})).toBeTruthy();
});
it('offers no resend before a login attempt or after other errors', async () => {
fetchRoutes({'POST /api/auth/login': refused(403, 'ACCOUNT_PENDING')});
renderLogin();
expect(screen.queryByRole('button', {name: 'REGISTRATION.RESEND'})).toBeNull();
submit();
await screen.findByText('LOGIN.ERROR_ACCOUNT_PENDING');
expect(screen.queryByRole('button', {name: 'REGISTRATION.RESEND'})).toBeNull();
});
it('shows and hides the password with the toggle', () => {
renderLogin();
const input = screen.getByLabelText('LOGIN.PASSWORD');
expect(input.type).toBe('password');
fireEvent.click(screen.getByRole('button', {name: 'LOGIN.SHOW_PASSWORD'}));
expect(input.type).toBe('text');
fireEvent.click(screen.getByRole('button', {name: 'LOGIN.HIDE_PASSWORD'}));
expect(input.type).toBe('password');
});
it('says that a blocked account is blocked', async () => {
fetchRoutes({'POST /api/auth/login': refused(403, 'ACCOUNT_BLOCKED')});
renderLogin();
+14 -3
View File
@@ -14,6 +14,7 @@ import FormField from '../components/FormField.jsx';
import ResendConfirmation from '../components/ResendConfirmation.jsx';
import {registrationSchema} from '../utils/validation.js';
import {clientFetch} from '../utils/api.js';
import {browserLanguage} from '../utils/settings.js';
import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
const BACKEND_ERROR_KEY = {
@@ -41,6 +42,11 @@ export default function RegistrationPage() {
},
});
const startOver = () => {
setRequestedEmail(null);
setFeedback({message: '', isError: false});
};
const onSubmit = async (values) => {
setFeedback({message: '', isError: false});
try {
@@ -52,12 +58,12 @@ export default function RegistrationPage() {
email: values.email,
displayName: values.displayName,
// Auth's mails use it: a German browser gets German, every other one English.
language: navigator.language?.toLowerCase().startsWith('de') ? 'de' : 'en',
language: browserLanguage(),
}),
});
if (response.ok) {
setFeedback({message: t('REGISTRATION.SUCCESS'), isError: false});
setFeedback({message: t('REGISTRATION.SENT_TO', {email: values.email}), isError: false});
setRequestedEmail(values.email);
return;
}
@@ -116,7 +122,12 @@ export default function RegistrationPage() {
</form>}
<FeedbackAlert message={feedback.message} isError={feedback.isError}/>
{requestedEmail !== null && <ResendConfirmation defaultEmail={requestedEmail}/>}
{requestedEmail !== null && <div className="space-y-4">
<ResendConfirmation defaultEmail={requestedEmail}/>
<Button type="button" variant="ghost" className="w-full" onClick={startOver}>
{t('REGISTRATION.START_OVER')}
</Button>
</div>}
</AuthLayout>
);
}
+20 -5
View File
@@ -5,10 +5,13 @@ import {MemoryRouter} from 'react-router-dom';
import RegistrationPage from './RegistrationPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
// Own mock: the shared one drops the {{minutes}} value this test checks.
// Own mock: the shared one drops the {{minutes}} and {{email}} values this test checks.
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key, options) => (options?.minutes === undefined ? key : `${key}:${options.minutes}`),
t: (key, options) => {
const value = options?.minutes ?? options?.email;
return value === undefined ? key : `${key}:${value}`;
},
i18n: {language: 'de'},
}),
}));
@@ -43,7 +46,7 @@ describe('RegistrationPage', () => {
submitValidForm();
expect(await screen.findByText('REGISTRATION.SUCCESS')).toBeTruthy();
expect(await screen.findByText('REGISTRATION.SENT_TO:a@b.de')).toBeTruthy();
expect(screen.getByLabelText('REGISTRATION.RESEND_EMAIL').value).toBe('a@b.de');
});
@@ -68,7 +71,7 @@ describe('RegistrationPage', () => {
expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('steamId');
});
it.each([['de-DE', 'de'], ['de', 'de'], ['en-US', 'en'], ['fr-FR', 'en'], [undefined, 'en']])(
it.each([['de-DE', 'de'], ['de', 'de'], ['en-US', 'en'], ['fr-FR', 'en'], [undefined, 'de']])(
'sends the browser language %s as %s, for the mails', async (browser, sent) => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue(browser);
answerRegistration(409, 'EMAIL_ALREADY_IN_USE');
@@ -102,11 +105,23 @@ describe('RegistrationPage', () => {
submitValidForm();
expect(await screen.findByText('REGISTRATION.SUCCESS')).toBeTruthy();
expect(await screen.findByText('REGISTRATION.SENT_TO:a@b.de')).toBeTruthy();
expect(screen.queryByRole('button', {name: 'REGISTRATION.SUBMIT'})).toBeNull();
expect(screen.getByText('REGISTRATION.INTRO')).toBeTruthy();
});
it('brings the form back with the typed address when the user starts over', async () => {
fetchRoutes({'POST /api/registration/new': json(200, {userId: 5})});
submitValidForm();
await screen.findByText('REGISTRATION.SENT_TO:a@b.de');
fireEvent.click(screen.getByRole('button', {name: 'REGISTRATION.START_OVER'}));
expect(screen.getByLabelText('REGISTRATION.EMAIL').value).toBe('a@b.de');
expect(screen.getByRole('button', {name: 'REGISTRATION.SUBMIT'})).toBeTruthy();
expect(screen.queryByText('REGISTRATION.SENT_TO:a@b.de')).toBeNull();
});
it('says so when too many registrations wait', async () => {
answerRegistration(409, 'PENDING_LIMIT_REACHED');
+4 -1
View File
@@ -11,6 +11,7 @@ import AuthLayout from '../components/AuthLayout.jsx';
import FeedbackAlert from '../components/FeedbackAlert.jsx';
import FormField from '../components/FormField.jsx';
import NewPasswordFields from '../components/NewPasswordFields.jsx';
import {isInvalidLinkAnswer} from '../utils/auth.js';
import {confirmEmailSchema} from '../utils/validation.js';
import {clientFetch} from '../utils/api.js';
@@ -112,8 +113,10 @@ function NewPasswordForm({token, onDone, onInvalid}) {
const response = await post('/api/auth/password-reset/confirm', {token, password});
if (response.ok) {
onDone();
} else if (response.status === 410 || response.status === 400) {
} else if (isInvalidLinkAnswer(response.status, await response.json().catch(() => ({})))) {
onInvalid();
} else if (response.status === 400) {
setError(t('RESET_PASSWORD.ERROR_REJECTED'));
} else {
setError(failureMessage(t, response));
}
+24
View File
@@ -79,6 +79,30 @@ describe('ResetPasswordPage', () => {
expect(screen.getByRole('button', {name: 'RESET_PASSWORD.SEND'})).toBeTruthy();
});
it('keeps the form and the typed password when Auth refuses the password, and does not say the link is invalid', async () => {
fetchRoutes({'POST /api/auth/password-reset/confirm': json(400, {
code: 'VALIDATION_FAILED', fields: [{field: 'password', message: 'size must be between 8 and 72'}],
})});
renderPage('#tok-123');
submitPasswords('secret123');
expect(await screen.findByText('RESET_PASSWORD.ERROR_REJECTED')).toBeTruthy();
expect(screen.queryByText('RESET_PASSWORD.INVALID')).toBeNull();
expect(screen.getByLabelText('REGISTRATION.PASSWORD').value).toBe('secret123');
});
it('shows the invalid text when Auth refuses the token field', async () => {
fetchRoutes({'POST /api/auth/password-reset/confirm': json(400, {
code: 'VALIDATION_FAILED', fields: [{field: 'token', message: 'must not be blank'}],
})});
renderPage('#tok-123');
submitPasswords('secret123');
expect(await screen.findByText('RESET_PASSWORD.INVALID')).toBeTruthy();
});
it('shows the lock text with the minutes when the confirm is limited', async () => {
fetchRoutes({
'POST /api/auth/password-reset/confirm': {...json(429, {}), headers: new Headers({'Retry-After': '900'})},
+9 -2
View File
@@ -1,5 +1,5 @@
import {useEffect, useRef, useState} from 'react';
import {useSearchParams} from 'react-router-dom';
import {useNavigate, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
@@ -28,6 +28,7 @@ const STEAM_LINK_TOASTS = {
export default function SettingsPage() {
const {t} = useTranslation();
const {user: profile, updateUser: updateProfile} = useCurrentUserContext();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
// Read once: removing the parameter below must not load the profile again.
const [steamOutcome] = useState(() => searchParams.get('steam'));
@@ -38,13 +39,19 @@ export default function SettingsPage() {
useEffect(() => {
const outcome = STEAM_LINK_TOASTS[steamOutcome];
if (!outcome || steamToastShown.current) return;
// After a link the dashboard starts the first sync: it must open with the refreshed session and user.
if (steamOutcome === 'linked' && isLoading) return;
steamToastShown.current = true;
outcome.show(t(outcome.key));
if (steamOutcome === 'linked') {
navigate('/dashboard', {replace: true});
return;
}
setSearchParams((params) => {
params.delete('steam');
return params;
}, {replace: true});
}, [steamOutcome, setSearchParams, t]);
}, [steamOutcome, isLoading, navigate, setSearchParams, t]);
if (isLoading) {
return (
+41 -4
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, screen} from '@testing-library/react';
import {act, cleanup, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import SettingsPage from './SettingsPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
@@ -47,14 +47,51 @@ describe('SettingsPage Steam toast', () => {
vi.clearAllMocks();
});
it('shows the linked toast once, also after a language change', async () => {
renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings?steam=linked']});
await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED');
it('after a link shows the toast once, also after a language change, and opens the dashboard', async () => {
const {router} = renderWithProviders(<SettingsPage/>, {
path: '/settings', entries: ['/settings?steam=linked'], routes: {'/dashboard': <p>dashboard</p>},
});
await screen.findByText('dashboard');
expect(router.state.location.pathname).toBe('/dashboard');
expect(toast.success).toHaveBeenCalledTimes(1);
expect(fetch.mock.calls.some(([url]) => String(url).includes('/api/auth/refresh'))).toBe(true);
const {i18n} = await import('react-i18next');
await act(async () => { i18n.changeLanguage('en'); });
expect(toast.success).toHaveBeenCalledTimes(1);
});
it('waits for the refreshed profile before it leaves the settings page', async () => {
const pending = [];
const answerMe = () => pending.forEach((resolve) => resolve(json(200, profile)));
fetchRoutes({
'GET /api/users/me': () => new Promise((resolve) => { pending.push(resolve); }),
'POST /api/auth/refresh': {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }},
'GET /api/version': json(404, {}),
'GET /api/follows': json(200, {following: [], followers: []}),
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
});
const {router} = renderWithProviders(<SettingsPage/>, {
path: '/settings', entries: ['/settings?steam=linked'], routes: {'/dashboard': <p>dashboard</p>},
});
await waitFor(() => expect(pending.length).toBeGreaterThan(0));
expect(router.state.location.pathname).toBe('/settings');
expect(toast.success).not.toHaveBeenCalled();
await act(async () => answerMe());
await waitFor(() => expect(router.state.location.pathname).toBe('/dashboard'));
});
it('stays on the settings page for a failed link and shows the error toast', async () => {
const {router} = renderWithProviders(<SettingsPage/>, {
path: '/settings', entries: ['/settings?steam=failed'], routes: {'/dashboard': <p>dashboard</p>},
});
await screen.findByText('SETTINGS.TITLE');
await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1));
expect(router.state.location.pathname).toBe('/settings');
expect(router.state.location.search).toBe('');
});
});
+35 -22
View File
@@ -10,7 +10,7 @@ import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {request} from '../utils/api.js';
import {markWelcomeSeen} from '../utils/settings.js';
// The dashboard card has no link: /dashboard passes the gate, "Let's go" opens it.
// The dashboard card has no link: "Go to dashboard" opens it.
const FEATURES = [
{key: 'LIBRARY', to: '/library', icon: Library},
{key: 'STATUS', to: '/library', icon: ListChecks},
@@ -26,25 +26,38 @@ export default function WelcomePage() {
const navigate = useNavigate();
const {user, updateUser} = useCurrentUserContext();
const [summary, setSummary] = useState(null);
// A PlayStation link also lets the library sync, like on the dashboard.
const [canSync, setCanSync] = useState(false);
useEffect(() => {
(async () => {
const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary`);
if (ok && data != null) setSummary(data);
})();
(async () => {
const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`);
if (ok && data != null) setCanSync(!!data.canSync);
})();
}, []);
const steps = [
{key: 'STEAM', to: '/settings', done: !!user?.steamId},
{key: 'STEAM', to: '/settings', done: !!user?.steamId || canSync},
{key: 'IMPORT', to: '/import', done: summary?.total > 0},
];
// Wait for the PUT: the dashboard reloads /me and would get the old null stamp. A failed PUT is ignored: the
const next = steps.find((step) => !step.done);
// Every way out of this page counts as seen, so the gate does not send the user back from the page they chose.
// Wait for the PUT: the target reloads /me and would get the old null stamp. A failed PUT is ignored: the
// page may come back at the next login.
const finish = async () => {
const leave = async (to) => {
updateUser((u) => ({...u, settings: {...u.settings, welcomeSeenAt: new Date().toISOString()}}));
await markWelcomeSeen().catch(() => undefined);
navigate('/dashboard');
navigate(to);
};
const leaveBy = (to) => (event) => {
event.preventDefault();
leave(to);
};
return (
@@ -53,20 +66,6 @@ export default function WelcomePage() {
<h1 className="text-2xl font-bold">{t('WELCOME.TITLE')}</h1>
<p className="text-muted-foreground">{t('WELCOME.INTRO')}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{FEATURES.map(({key, to, icon: Icon}) => (
<Card key={key} className="p-4">
<Icon className="size-5 text-primary"/>
<h2 className="font-semibold">{t(`WELCOME.FEATURE_${key}`)}</h2>
<p className="text-sm text-muted-foreground">{t(`WELCOME.FEATURE_${key}_TEXT`)}</p>
{to && (
<Button asChild variant="link" className="h-auto justify-start p-0">
<Link to={to}>{t('WELCOME.OPEN')}</Link>
</Button>
)}
</Card>
))}
</div>
<Card>
<CardHeader>
<CardTitle>{t('WELCOME.NEXT_STEPS')}</CardTitle>
@@ -78,7 +77,7 @@ export default function WelcomePage() {
{done
? <CircleCheck className="size-5 text-primary"/>
: <Circle className="size-5 text-muted-foreground"/>}
<Link to={to} className="hover:underline">{t(`WELCOME.STEP_${key}`)}</Link>
<Link to={to} onClick={leaveBy(to)} className="hover:underline">{t(`WELCOME.STEP_${key}`)}</Link>
{done && <span className="sr-only">{t('WELCOME.DONE')}</span>}
</li>
))}
@@ -86,8 +85,22 @@ export default function WelcomePage() {
</CardContent>
</Card>
<div className="flex flex-wrap gap-2">
<Button onClick={finish}>{t('WELCOME.GO')}</Button>
<Button variant="outline" onClick={finish}>{t('WELCOME.SKIP')}</Button>
{next && <Button onClick={() => leave(next.to)}>{t(`WELCOME.ACTION_${next.key}`)}</Button>}
<Button variant={next ? 'outline' : 'default'} onClick={() => leave('/dashboard')}>{t('WELCOME.GO')}</Button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{FEATURES.map(({key, to, icon: Icon}) => (
<Card key={key} className="p-4">
<Icon className="size-5 text-primary"/>
<h2 className="font-semibold">{t(`WELCOME.FEATURE_${key}`)}</h2>
<p className="text-sm text-muted-foreground">{t(`WELCOME.FEATURE_${key}_TEXT`)}</p>
{to && (
<Button asChild variant="link" className="h-auto justify-start p-0">
<Link to={to} onClick={leaveBy(to)}>{t('WELCOME.OPEN')}</Link>
</Button>
)}
</Card>
))}
</div>
</PageContainer>
);
+78 -4
View File
@@ -20,10 +20,11 @@ const renderPage = async ({user = me(), summary = json(200, {total: 0}), seen =
fetchRoutes({
'GET /api/users/me': json(200, user),
'GET /api/library/summary': summary,
'GET /api/library/games-count': json(200, {count: 0, canSync: false}),
'PUT /api/users/settings/welcome-seen': seen,
});
const result = renderWithProviders(<WelcomePage/>, {
path: '/welcome', entries: ['/welcome'], routes: {'/dashboard': <p>dashboard</p>},
path: '/welcome', entries: ['/welcome'], routes: {'/dashboard': <p>dashboard</p>, '/settings': <p>settings</p>, '/import': <p>import</p>, '/stats': <p>stats</p>},
});
await act(async () => undefined);
return result;
@@ -65,13 +66,85 @@ describe('WelcomePage', () => {
expect(screen.queryByText('WELCOME.DONE')).toBeNull();
});
it.each(['WELCOME.GO', 'WELCOME.SKIP'])('"%s" stamps once and opens the dashboard', async (label) => {
it('"WELCOME.GO" stamps once and opens the dashboard', async () => {
const {router} = await renderPage();
fireEvent.click(screen.getByText('WELCOME.GO'));
await waitFor(() => expect(router.state.location.pathname).toBe('/dashboard'));
expect(putCalls().map(([url]) => url)).toEqual([expect.stringContaining('/api/users/settings/welcome-seen')]);
});
it('makes linking Steam the main button and the dashboard the only other one', async () => {
await renderPage();
const buttons = screen.getAllByRole('button').map((button) => button.textContent);
expect(buttons).toEqual(['WELCOME.ACTION_STEAM', 'WELCOME.GO']);
expect(screen.queryByText('WELCOME.SKIP')).toBeNull();
});
it('doesNotTellAPsnOnlyUserToLinkSteamFirst', async () => {
fetchRoutes({
'GET /api/users/me': json(200, me()),
'GET /api/library/summary': json(200, {total: 0}),
'GET /api/library/games-count': json(200, {count: 0, hasSteamId: false, canSync: true, syncStarted: false}),
'GET /api/library/psn-account': json(200, {enabled: true, linked: true}),
'PUT /api/users/settings/welcome-seen': json(204),
});
renderWithProviders(<WelcomePage/>, {path: '/welcome', entries: ['/welcome']});
await act(async () => undefined);
await act(async () => undefined);
expect(screen.getAllByRole('button')[0].textContent).not.toBe('WELCOME.ACTION_STEAM');
});
it('makes the import the main button once Steam is linked and no game is there', async () => {
await renderPage({user: me({steamId: '7656'})});
expect(screen.getAllByRole('button').map((button) => button.textContent))
.toEqual(['WELCOME.ACTION_IMPORT', 'WELCOME.GO']);
});
it('has only the dashboard button when every step is done', async () => {
await renderPage({user: me({steamId: '7656'}), summary: json(200, {total: 1})});
await screen.findAllByText('WELCOME.DONE');
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual(['WELCOME.GO']);
});
it('stamps and opens the settings with the main button, so the gate does not send the user back', async () => {
const {router} = await renderPage();
fireEvent.click(screen.getByText('WELCOME.ACTION_STEAM'));
await waitFor(() => expect(router.state.location.pathname).toBe('/settings'));
expect(putCalls()).toHaveLength(1);
});
it.each(['WELCOME.STEP_STEAM', 'WELCOME.STEP_IMPORT'])('stamps before the step link "%s" opens its page', async (label) => {
const {router} = await renderPage();
fireEvent.click(screen.getByText(label));
await waitFor(() => expect(router.state.location.pathname).toBe('/dashboard'));
expect(putCalls().map(([url]) => url)).toEqual([expect.stringContaining('/api/users/settings/welcome-seen')]);
await waitFor(() => expect(router.state.location.pathname).toBe(label.endsWith('STEAM') ? '/settings' : '/import'));
expect(putCalls()).toHaveLength(1);
});
it('stamps before a feature link opens its page', async () => {
const {router} = await renderPage();
fireEvent.click(screen.getAllByRole('link', {name: 'WELCOME.OPEN'})[2]);
await waitFor(() => expect(router.state.location.pathname).toBe('/stats'));
expect(putCalls()).toHaveLength(1);
});
it('shows the next steps before the feature cards', async () => {
await renderPage();
const steps = screen.getByText('WELCOME.NEXT_STEPS');
const feature = screen.getByText('WELCOME.FEATURE_LIBRARY');
expect(steps.compareDocumentPosition(feature) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('still opens the dashboard, silently, when the stamp fails', async () => {
@@ -93,6 +166,7 @@ describe('WelcomePage', () => {
fetchRoutes({
'GET /api/users/me': () => json(200, me({settings: {welcomeSeenAt: stamped ? '2026-10-01T10:00:00Z' : null}})),
'GET /api/library/summary': json(200, {total: 0}),
'GET /api/library/games-count': json(200, {count: 0, canSync: false}),
'PUT /api/users/settings/welcome-seen': () => new Promise((resolve) => setTimeout(() => {
stamped = true;
resolve(json(204));
+7
View File
@@ -60,6 +60,13 @@ export function subscribeSession(listener) {
};
}
/**
* Whether an Auth answer to a confirm or reset request says "this link is unknown, used or expired": 410, or a 400
* that refuses the `token` field. Any other 400 is about the password and the form stays.
*/
export const isInvalidLinkAnswer = (status, body) =>
status === 410 || (status === 400 && (body?.fields ?? []).some((f) => f.field === 'token'));
/**
* Returns the path when it is a path inside this app, else null. The login page uses it to go back after a
* session expired; "//host", "/\host" and tabs or line breaks (browsers drop them, so "/\t/host" is "//host")
+17 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {clearSession, hasSession, markSession, safeReturnPath, sessionStamp} from './auth.js';
import {clearSession, hasSession, isInvalidLinkAnswer, markSession, safeReturnPath, sessionStamp} from './auth.js';
afterEach(() => localStorage.clear());
@@ -35,3 +35,19 @@ describe('safeReturnPath', () => {
expect(safeReturnPath(path)).toBeNull();
});
});
describe('isInvalidLinkAnswer', () => {
it.each([
[410, {code: 'CONFIRMATION_INVALID'}, true],
[410, undefined, true],
[400, {fields: [{field: 'token', message: 'blank'}]}, true],
[400, {fields: [{field: 'password', message: 'size'}]}, false],
[400, {fields: [{field: 'password', message: 'size'}, {field: 'token', message: 'blank'}]}, true],
[400, {}, false],
[400, undefined, false],
[429, {}, false],
[500, {}, false],
])('status %j with body %j is %j', (status, body, expected) => {
expect(isInvalidLinkAnswer(status, body)).toBe(expected);
});
});
+6
View File
@@ -11,6 +11,12 @@ export const readCachedLanguage = () => {
return LANGUAGES.includes(stored) ? stored : null;
};
/** The browser's language when none is cached: German for de-*, English for any other, German when unknown. */
export const browserLanguage = () => {
const language = navigator.language?.toLowerCase();
return !language || language.startsWith('de') ? 'de' : 'en';
};
export const cacheLanguage = (language) => storage.set(LANGUAGE_STORAGE_KEY, language);
export const ME_URL = () => `${import.meta.env.VITE_API_URL}/api/users/me`;
+15 -3
View File
@@ -1,8 +1,11 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cacheLanguage, readCachedLanguage} from './settings.js';
import {afterEach, describe, expect, it, vi} from 'vitest';
import {browserLanguage, cacheLanguage, readCachedLanguage} from './settings.js';
afterEach(() => localStorage.clear());
afterEach(() => {
localStorage.clear();
vi.unstubAllGlobals();
});
describe('language cache', () => {
it('gives back the cached language, and nothing for an unknown one', () => {
@@ -13,3 +16,12 @@ describe('language cache', () => {
expect(readCachedLanguage()).toBeNull();
});
});
describe('browserLanguage', () => {
it.each([['de-DE', 'de'], ['DE', 'de'], ['en-US', 'en'], ['fr', 'en'], [undefined, 'de'], ['', 'de']])(
'maps the browser language %j to %s', (language, expected) => {
vi.stubGlobal('navigator', {language});
expect(browserLanguage()).toBe(expected);
});
});
+1 -1
View File
@@ -44,7 +44,7 @@ export const PROFILE_TILES = {
const STAT_TITLES = Object.fromEntries(Object.keys(STAT_TILES).map((id) => [id, `STATS.${statGroup(id)}.TITLE`]));
export const DASHBOARD_TITLES = {
GAME_COUNT: 'DASHBOARD.STEAM_GAMES', MOST_PLAYED: 'DASHBOARD.MOST_PLAYED', PLAYING: 'DASHBOARD.CURRENTLY_PLAYING',
GAME_COUNT: 'DASHBOARD.GAMES', MOST_PLAYED: 'DASHBOARD.MOST_PLAYED', PLAYING: 'DASHBOARD.CURRENTLY_PLAYING',
UP_NEXT: 'DASHBOARD.UP_NEXT',
PILE_OF_SHAME: 'DASHBOARD.PILE_OF_SHAME', ACTIVITY: 'DASHBOARD.ACTIVITY', ACHIEVEMENTS: 'DASHBOARD.ACHIEVEMENTS.TITLE',
FOLLOWING: 'FOLLOW.TITLE',
+6
View File
@@ -41,6 +41,12 @@ const newPasswordSchema = (fields = {}) => z
message: 'VALIDATION.PASSWORD_MISMATCH',
});
export const loginSchema = z.object({
// Auth decides what an address is; local dev users have none with a dot (dev@localhost).
email: z.string().min(1, 'LOGIN.ERROR_EMAIL').refine((v) => v.includes('@'), 'LOGIN.ERROR_EMAIL'),
password: z.string().min(1, 'LOGIN.ERROR_PASSWORD'),
});
export const confirmEmailSchema = newPasswordSchema();
export const changePasswordSchema = newPasswordSchema({