Public Access
Compare commits
3
Commits
f71e8f7aff
...
ae904d946a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae904d946a | ||
|
|
0c300c455d | ||
|
|
6627a8db66 |
No files matched your search
@@ -81,7 +81,7 @@ To Be Played is split into three repositories:
|
|||||||
- **Finished?**: Core marks a game as finished by itself when the ending achievement is unlocked and the status was
|
- **Finished?**: Core marks a game as finished by itself when the ending achievement is unlocked and the status was
|
||||||
never set by hand. Otherwise the game page asks: "You unlocked the ending, finished?" or "You played longer than
|
never set by hand. Otherwise the game page asks: "You unlocked the ending, finished?" or "You played longer than
|
||||||
most players need, finished?", with "Mark as finished" and "Not yet".
|
most players need, finished?", with "Mark as finished" and "Not yet".
|
||||||
- **Hide games**: a Steam game (e.g. a playtest or a tool) can be hidden on its detail page. It leaves the lists,
|
- **Hide games**: a game, Steam or added by hand (e.g. a playtest or a tool), can be hidden on its detail page. It leaves the lists,
|
||||||
the dashboard, the stats and the public profile; the library's "Hidden" filter shows it again for unhiding.
|
the dashboard, the stats and the public profile; the library's "Hidden" filter shows it again for unhiding.
|
||||||
- **Wishlist** with store price and discount.
|
- **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
|
- **Steam account** linked in the settings with "Sign in through Steam": the user signs in on Steam and Auth gets
|
||||||
@@ -102,6 +102,11 @@ To Be Played is split into three repositories:
|
|||||||
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
|
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
|
link and the page after the send offer "send the link
|
||||||
again" (same answer for any address).
|
again" (same answer for any address).
|
||||||
|
- **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.
|
||||||
|
**Change password** is a card in the settings (current password and the new one twice); the other logins end, this
|
||||||
|
one stays. The admin users page has "Send password reset" for an active, unblocked user.
|
||||||
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
|
- **Broken pictures**: a Steam cover or achievement icon that does not load shows a placeholder and is reported
|
||||||
to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night;
|
to Core once per page load (`POST /api/library/broken-images`, only with a session). Core repairs it at night;
|
||||||
a repaired cover comes back as `imageUrl`.
|
a repaired cover comes back as `imageUrl`.
|
||||||
@@ -124,12 +129,15 @@ To Be Played is split into three repositories:
|
|||||||
`/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page
|
`/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.
|
links to it again.
|
||||||
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
|
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
|
||||||
speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own.
|
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
|
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
|
||||||
every user.
|
every user; an auditor can undo a merge for 30 days from the audit log ("Undo"). An old link of a merged game opens the kept game.
|
||||||
|
- **Game catalog** (`/audit/games`, AUDITOR or higher): search every game any user has by name (cover, platforms,
|
||||||
|
key), paged; "Correct" opens the correction dialog, also for a game the auditor does not own. The audit log links
|
||||||
|
its games here.
|
||||||
- **Link queue** (`/audit/links`, AUDITOR or higher): the pairs IGDB suggests and users asked for, with "requested by N
|
- **Link queue** (`/audit/links`, AUDITOR or higher): the pairs IGDB suggests and users asked for, with "requested by N
|
||||||
players"; Link or Dismiss; paged, 20 pairs a page. **Audit log** (`/audit/log`): the newest merges, links, unlinks and dismissals with who
|
players"; Link or Dismiss; paged, 20 pairs a page. **Audit log** (`/audit/log`): the newest merges, merge undos, links, unlinks, dismissals, corrections and resets with who
|
||||||
did it, filter by action, paged.
|
did it, filter by action, paged; "Undo" on a merge made in the last 30 days.
|
||||||
- **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the
|
- **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the
|
||||||
account at once and removes it after 30 days; the page ends the session and goes to the landing page.
|
account at once and removes it after 30 days; the page ends the session and goes to the landing page.
|
||||||
- **Build version** in the settings: commit and build time of Frontend, Auth and Core, each hash links to its
|
- **Build version** in the settings: commit and build time of Frontend, Auth and Core, each hash links to its
|
||||||
|
|||||||
+66
-5
@@ -10,6 +10,7 @@
|
|||||||
"PASSWORD": "Passwort",
|
"PASSWORD": "Passwort",
|
||||||
"SUBMIT": "Spiel betreten",
|
"SUBMIT": "Spiel betreten",
|
||||||
"NO_ACCOUNT": "Noch kein Konto?",
|
"NO_ACCOUNT": "Noch kein Konto?",
|
||||||
|
"FORGOT_PASSWORD": "Passwort vergessen?",
|
||||||
"ERROR_EMAIL": "Gib eine gültige Email-Adresse ein mit '@' und mit '.'.",
|
"ERROR_EMAIL": "Gib eine gültige Email-Adresse ein mit '@' und mit '.'.",
|
||||||
"ERROR_PASSWORD": "Das Passwort darf nicht leer sein.",
|
"ERROR_PASSWORD": "Das Passwort darf nicht leer sein.",
|
||||||
"ERROR_INVALID_CREDENTIALS": "E-Mail oder Passwort ist falsch.",
|
"ERROR_INVALID_CREDENTIALS": "E-Mail oder Passwort ist falsch.",
|
||||||
@@ -38,6 +39,19 @@
|
|||||||
"INTRO": "Du bekommst eine Mail mit einem Link: dort bestätigst du deine Adresse und wählst dein Passwort. Danach prüft ein Admin dein Konto und du bekommst eine Mail mit der Entscheidung.",
|
"INTRO": "Du bekommst eine Mail mit einem Link: dort bestätigst du deine Adresse und wählst dein Passwort. Danach prüft ein Admin dein Konto und du bekommst eine Mail mit der Entscheidung.",
|
||||||
"ERROR_PENDING_LIMIT": "Gerade warten zu viele Registrierungen auf den Admin. Bitte versuch es später noch einmal."
|
"ERROR_PENDING_LIMIT": "Gerade warten zu viele Registrierungen auf den Admin. Bitte versuch es später noch einmal."
|
||||||
},
|
},
|
||||||
|
"RESET_PASSWORD": {
|
||||||
|
"TITLE": "Passwort zurücksetzen",
|
||||||
|
"EMAIL": "E-Mail",
|
||||||
|
"SEND": "Link zum Zurücksetzen senden",
|
||||||
|
"SENT": "Wenn ein aktives Konto diese Adresse hat, ist ein Link unterwegs. Er ist 1 Stunde gültig.",
|
||||||
|
"INTRO": "Wähle dein neues Passwort. Danach enden alle deine Anmeldungen.",
|
||||||
|
"SUBMIT": "Passwort speichern",
|
||||||
|
"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_TOO_MANY": "Zu viele Versuche. Bitte versuche es in {{minutes}} Minuten noch einmal.",
|
||||||
|
"ERROR_GENERIC": "Das hat nicht geklappt. Bitte versuche es noch einmal."
|
||||||
|
},
|
||||||
"CONFIRM_EMAIL": {
|
"CONFIRM_EMAIL": {
|
||||||
"TITLE": "E-Mail-Adresse bestätigen",
|
"TITLE": "E-Mail-Adresse bestätigen",
|
||||||
"INTRO": "Wähle ein Passwort, um die Adresse deiner Registrierung zu bestätigen.",
|
"INTRO": "Wähle ein Passwort, um die Adresse deiner Registrierung zu bestätigen.",
|
||||||
@@ -126,6 +140,17 @@
|
|||||||
},
|
},
|
||||||
"VERSION": "Version",
|
"VERSION": "Version",
|
||||||
"VERSION_UNKNOWN": "unbekannt",
|
"VERSION_UNKNOWN": "unbekannt",
|
||||||
|
"CHANGE_PASSWORD": {
|
||||||
|
"TITLE": "Passwort ändern",
|
||||||
|
"CURRENT": "Aktuelles Passwort",
|
||||||
|
"NEW": "Neues Passwort",
|
||||||
|
"REPEAT": "Neues Passwort wiederholen",
|
||||||
|
"SUBMIT": "Passwort ändern",
|
||||||
|
"DONE": "Passwort geändert. Deine anderen Anmeldungen sind beendet.",
|
||||||
|
"WRONG_PASSWORD": "Das aktuelle Passwort ist falsch.",
|
||||||
|
"TOO_MANY": "Zu viele falsche Passwörter. Versuche es in {{minutes}} Minuten noch einmal.",
|
||||||
|
"ERROR": "Das Passwort konnte nicht geändert werden."
|
||||||
|
},
|
||||||
"DELETE_ACCOUNT": {
|
"DELETE_ACCOUNT": {
|
||||||
"TITLE": "Konto löschen",
|
"TITLE": "Konto löschen",
|
||||||
"EXPLAIN": "Dein Konto wird sofort gesperrt: kein Login, kein öffentliches Profil. Nach 30 Tagen wird es endgültig entfernt, mit Bibliothek, Backlog und Statistiken.",
|
"EXPLAIN": "Dein Konto wird sofort gesperrt: kein Login, kein öffentliches Profil. Nach 30 Tagen wird es endgültig entfernt, mit Bibliothek, Backlog und Statistiken.",
|
||||||
@@ -151,6 +176,7 @@
|
|||||||
"PASSWORD_TOO_SHORT": "Passwort muss mindestens 8 Zeichen haben.",
|
"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_TOO_LONG": "Passwort ist zu lang: höchstens 72 Zeichen, mit Umlauten oder Emoji weniger.",
|
||||||
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
|
"PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.",
|
||||||
|
"CURRENT_PASSWORD_REQUIRED": "Gib dein aktuelles Passwort ein.",
|
||||||
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
|
"PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.",
|
||||||
"DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
|
"DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)."
|
||||||
},
|
},
|
||||||
@@ -243,7 +269,7 @@
|
|||||||
"OPEN_IN_STEAM": "Auf Steam ansehen",
|
"OPEN_IN_STEAM": "Auf Steam ansehen",
|
||||||
"HIDE": "Spiel ausblenden",
|
"HIDE": "Spiel ausblenden",
|
||||||
"UNHIDE": "Spiel wieder anzeigen",
|
"UNHIDE": "Spiel wieder anzeigen",
|
||||||
"HIDDEN_NOTE": "Ausgeblendet: Das Spiel fehlt in deinen Listen, Statistiken und im öffentlichen Profil. Nur seine Spielzeit wird weiter aktualisiert.",
|
"HIDDEN_NOTE": "Ausgeblendet: Das Spiel fehlt in deinen Listen, Statistiken und im öffentlichen Profil.",
|
||||||
"HIDDEN_TOAST": "Spiel ausgeblendet",
|
"HIDDEN_TOAST": "Spiel ausgeblendet",
|
||||||
"SHOWN_TOAST": "Spiel wird wieder angezeigt",
|
"SHOWN_TOAST": "Spiel wird wieder angezeigt",
|
||||||
"HIDE_ERROR": "Das Spiel konnte nicht geändert werden.",
|
"HIDE_ERROR": "Das Spiel konnte nicht geändert werden.",
|
||||||
@@ -323,7 +349,8 @@
|
|||||||
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet",
|
"PENDING_REQUESTS_one": "{{count}} Registrierung wartet",
|
||||||
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten",
|
"PENDING_REQUESTS_other": "{{count}} Registrierungen warten",
|
||||||
"LINK_QUEUE": "Verknüpfungen",
|
"LINK_QUEUE": "Verknüpfungen",
|
||||||
"AUDIT_LOG": "Protokoll"
|
"AUDIT_LOG": "Protokoll",
|
||||||
|
"GAME_CATALOG": "Spielekatalog"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "Mein Backlog",
|
"TITLE": "Mein Backlog",
|
||||||
@@ -559,6 +586,7 @@
|
|||||||
"DONE_DELETE": "Nutzer gelöscht.",
|
"DONE_DELETE": "Nutzer gelöscht.",
|
||||||
"ERROR_SELF_ACTION": "Das geht nicht mit dem eigenen Konto.",
|
"ERROR_SELF_ACTION": "Das geht nicht mit dem eigenen Konto.",
|
||||||
"ERROR_TARGET_IS_ADMIN": "Das geht nicht mit einem anderen Admin.",
|
"ERROR_TARGET_IS_ADMIN": "Das geht nicht mit einem anderen Admin.",
|
||||||
|
"ERROR_NOT_ACTIVE": "Nur ein aktives Konto kann eine Reset-Mail bekommen.",
|
||||||
"ERROR_ALREADY_DELETED": "Das Konto ist schon gelöscht.",
|
"ERROR_ALREADY_DELETED": "Das Konto ist schon gelöscht.",
|
||||||
"ERROR": "Die Aktion ist fehlgeschlagen.",
|
"ERROR": "Die Aktion ist fehlgeschlagen.",
|
||||||
"PAGE_OF": "Seite {{current}} von {{total}}",
|
"PAGE_OF": "Seite {{current}} von {{total}}",
|
||||||
@@ -580,6 +608,10 @@
|
|||||||
"ERROR_TARGET_IS_UNCONFIRMED": "Die Adresse ist nicht bestätigt: lösche die Anfrage.",
|
"ERROR_TARGET_IS_UNCONFIRMED": "Die Adresse ist nicht bestätigt: lösche die Anfrage.",
|
||||||
"ERROR_NOT_UNCONFIRMED": "Die Anfrage ist schon bestätigt.",
|
"ERROR_NOT_UNCONFIRMED": "Die Anfrage ist schon bestätigt.",
|
||||||
"DONE_DENY": "Registrierung abgelehnt",
|
"DONE_DENY": "Registrierung abgelehnt",
|
||||||
|
"SEND_PASSWORD_RESET": "Passwort-Reset senden",
|
||||||
|
"PASSWORD_RESET_TITLE": "Passwort-Reset an {{email}} senden?",
|
||||||
|
"PASSWORD_RESET_TEXT": "Die Person bekommt eine Mail mit einem Link, um ein neues Passwort zu wählen (1 Stunde gültig). Du siehst das Passwort nie.",
|
||||||
|
"DONE_PASSWORD_RESET": "Reset-Mail gesendet.",
|
||||||
"ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.",
|
"ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.",
|
||||||
"ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab."
|
"ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab."
|
||||||
},
|
},
|
||||||
@@ -595,6 +627,9 @@
|
|||||||
"NO_ACCESS": "Nur Auditoren können Spieldaten korrigieren.",
|
"NO_ACCESS": "Nur Auditoren können Spieldaten korrigieren.",
|
||||||
"ERROR": "Die Korrektur konnte nicht gespeichert werden.",
|
"ERROR": "Die Korrektur konnte nicht gespeichert werden.",
|
||||||
"IMAGE_HINT": "https://… (leer: das Steam-Headerbild)",
|
"IMAGE_HINT": "https://… (leer: das Steam-Headerbild)",
|
||||||
|
"HOURS": "Stunden",
|
||||||
|
"MINUTES": "Minuten",
|
||||||
|
"NEGATIVE": "Nicht gespeichert: Stunden und Minuten dürfen nicht negativ sein.",
|
||||||
"SPEEDRUN_HINT": "https://www.speedrun.com/… (leer: kein Speedrun)",
|
"SPEEDRUN_HINT": "https://www.speedrun.com/… (leer: kein Speedrun)",
|
||||||
"FIELD": {
|
"FIELD": {
|
||||||
"NAME": "Name",
|
"NAME": "Name",
|
||||||
@@ -602,7 +637,8 @@
|
|||||||
"SPEEDRUN": "Speedrun-Link",
|
"SPEEDRUN": "Speedrun-Link",
|
||||||
"MOODS": "Stimmungen",
|
"MOODS": "Stimmungen",
|
||||||
"ENDLESS": "Endloses Spiel",
|
"ENDLESS": "Endloses Spiel",
|
||||||
"GENRES": "Genres"
|
"GENRES": "Genres",
|
||||||
|
"TIME_TO_BEAT": "Spielzeit"
|
||||||
},
|
},
|
||||||
"ENDLESS": {
|
"ENDLESS": {
|
||||||
"UNKNOWN": "Unbekannt",
|
"UNKNOWN": "Unbekannt",
|
||||||
@@ -614,7 +650,7 @@
|
|||||||
"OPEN": "Duplikat zusammenführen",
|
"OPEN": "Duplikat zusammenführen",
|
||||||
"TITLE": "Mit demselben Spiel zusammenführen",
|
"TITLE": "Mit demselben Spiel zusammenführen",
|
||||||
"HINT": "Suche den anderen Eintrag von „{{name}}“, z. B. das Steam-Spiel oder ein von Hand hinzugefügtes Spiel. Das Zusammenführen gilt für alle Nutzer.",
|
"HINT": "Suche den anderen Eintrag von „{{name}}“, z. B. das Steam-Spiel oder ein von Hand hinzugefügtes Spiel. Das Zusammenführen gilt für alle Nutzer.",
|
||||||
"CONFIRM": "„{{name}}“ und „{{other}}“ für alle Nutzer zu einem Spiel zusammenführen? Bibliotheken mit beiden behalten einen Eintrag mit dem zuletzt gesetzten Status, der Bewertung und den Notizen. Das lässt sich nicht rückgängig machen.",
|
"CONFIRM": "„{{name}}“ und „{{other}}“ für alle Nutzer zu einem Spiel zusammenführen? Bibliotheken mit beiden behalten einen Eintrag mit dem zuletzt gesetzten Status, der Bewertung und den Notizen. Das lässt sich 30 Tage lang im Audit-Log rückgängig machen.",
|
||||||
"CONFIRM_BUTTON": "Zusammenführen",
|
"CONFIRM_BUTTON": "Zusammenführen",
|
||||||
"BACK": "Zurück",
|
"BACK": "Zurück",
|
||||||
"DONE": "Spiele zusammengeführt",
|
"DONE": "Spiele zusammengeführt",
|
||||||
@@ -658,20 +694,45 @@
|
|||||||
"GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek."
|
"GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CATALOG": {
|
||||||
|
"TITLE": "Spielekatalog",
|
||||||
|
"SEARCH": "Nach Name suchen",
|
||||||
|
"EMPTY": "Keine Spiele gefunden.",
|
||||||
|
"NO_ACCESS": "Nur Auditoren können diese Seite öffnen.",
|
||||||
|
"LOAD_ERROR": "Der Spielekatalog konnte nicht geladen werden.",
|
||||||
|
"CORRECT": "Korrigieren",
|
||||||
|
"KEY": "Schlüssel {{appid}}",
|
||||||
|
"GONE": "Dieses Spiel gibt es nicht mehr."
|
||||||
|
},
|
||||||
"AUDIT": {
|
"AUDIT": {
|
||||||
"TITLE": "Protokoll",
|
"TITLE": "Protokoll",
|
||||||
"EMPTY": "Keine Einträge.",
|
"EMPTY": "Keine Einträge.",
|
||||||
"NO_ACCESS": "Nur Auditoren können diese Seite öffnen.",
|
"NO_ACCESS": "Nur Auditoren können diese Seite öffnen.",
|
||||||
"LOAD_ERROR": "Das Protokoll konnte nicht geladen werden.",
|
"LOAD_ERROR": "Das Protokoll konnte nicht geladen werden.",
|
||||||
"USER_ID": "Nutzer {{id}}",
|
"USER_ID": "Nutzer {{id}}",
|
||||||
|
"UNDO": {
|
||||||
|
"BUTTON": "Rückgängig",
|
||||||
|
"CONFIRM": "Die Zusammenführung von „{{name}}“ und „{{other}}“ rückgängig machen? Beide Spiele kommen für alle Nutzer zurück. Status, Bewertung und Notizen, die seitdem gesetzt wurden, bleiben erhalten.",
|
||||||
|
"DONE": "Zusammenführung rückgängig gemacht.",
|
||||||
|
"ERROR": "Die Zusammenführung konnte nicht rückgängig gemacht werden.",
|
||||||
|
"ERROR_CODES": {
|
||||||
|
"MERGE_NOT_FOUND": "Diese Zusammenführung gibt es nicht.",
|
||||||
|
"UNDO_NOT_AVAILABLE": "Diese Zusammenführung wurde schon rückgängig gemacht oder ist zu alt.",
|
||||||
|
"NEWER_MERGE": "Mache zuerst die neuere Zusammenführung dieses Spiels rückgängig.",
|
||||||
|
"UNDO_EXPIRED": "Die Frist von 30 Tagen für diese Zusammenführung ist abgelaufen."
|
||||||
|
}
|
||||||
|
},
|
||||||
"PAGE_OF": "Seite {{current}} von {{total}}",
|
"PAGE_OF": "Seite {{current}} von {{total}}",
|
||||||
"ACTION": {
|
"ACTION": {
|
||||||
"LABEL": "Aktion",
|
"LABEL": "Aktion",
|
||||||
"ALL": "Alle Aktionen",
|
"ALL": "Alle Aktionen",
|
||||||
"GAME_MERGE": "Zusammengeführt",
|
"GAME_MERGE": "Zusammengeführt",
|
||||||
|
"GAME_MERGE_UNDO": "Zusammenführung rückgängig",
|
||||||
"GAME_LINK": "Verknüpft",
|
"GAME_LINK": "Verknüpft",
|
||||||
"GAME_UNLINK": "Verknüpfung gelöst",
|
"GAME_UNLINK": "Verknüpfung gelöst",
|
||||||
"GAME_LINK_DISMISS": "Verworfen"
|
"GAME_LINK_DISMISS": "Verworfen",
|
||||||
|
"GAME_CORRECT": "Korrigiert",
|
||||||
|
"GAME_CORRECTION_RESET": "Zurück zur Quelle"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"HINT": {
|
"HINT": {
|
||||||
|
|||||||
+66
-5
@@ -10,6 +10,7 @@
|
|||||||
"PASSWORD": "Password",
|
"PASSWORD": "Password",
|
||||||
"SUBMIT": "Enter the game",
|
"SUBMIT": "Enter the game",
|
||||||
"NO_ACCOUNT": "No account yet?",
|
"NO_ACCOUNT": "No account yet?",
|
||||||
|
"FORGOT_PASSWORD": "Forgot password?",
|
||||||
"ERROR_EMAIL": "Enter a valid email address with '@' and with '.'.",
|
"ERROR_EMAIL": "Enter a valid email address with '@' and with '.'.",
|
||||||
"ERROR_PASSWORD": "The password must have at least one character.",
|
"ERROR_PASSWORD": "The password must have at least one character.",
|
||||||
"ERROR_INVALID_CREDENTIALS": "Email or password is wrong.",
|
"ERROR_INVALID_CREDENTIALS": "Email or password is wrong.",
|
||||||
@@ -38,6 +39,19 @@
|
|||||||
"INTRO": "You get a mail with a link: there you confirm your address and choose your password. Then an admin checks your account and you get a mail with the decision.",
|
"INTRO": "You get a mail with a link: there you confirm your address and choose your password. Then an admin checks your account and you get a mail with the decision.",
|
||||||
"ERROR_PENDING_LIMIT": "Too many registrations wait for the admin right now. Please try again later."
|
"ERROR_PENDING_LIMIT": "Too many registrations wait for the admin right now. Please try again later."
|
||||||
},
|
},
|
||||||
|
"RESET_PASSWORD": {
|
||||||
|
"TITLE": "Reset password",
|
||||||
|
"EMAIL": "Email",
|
||||||
|
"SEND": "Send reset link",
|
||||||
|
"SENT": "If an active account has this address, a link is on its way. It is valid for 1 hour.",
|
||||||
|
"INTRO": "Choose your new password. All your logins end after that.",
|
||||||
|
"SUBMIT": "Save password",
|
||||||
|
"DONE": "Your password is 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_TOO_MANY": "Too many attempts. Please try again in {{minutes}} minutes.",
|
||||||
|
"ERROR_GENERIC": "That did not work. Please try again."
|
||||||
|
},
|
||||||
"CONFIRM_EMAIL": {
|
"CONFIRM_EMAIL": {
|
||||||
"TITLE": "Confirm your email address",
|
"TITLE": "Confirm your email address",
|
||||||
"INTRO": "Choose a password to confirm the address of your registration.",
|
"INTRO": "Choose a password to confirm the address of your registration.",
|
||||||
@@ -126,6 +140,17 @@
|
|||||||
},
|
},
|
||||||
"VERSION": "Version",
|
"VERSION": "Version",
|
||||||
"VERSION_UNKNOWN": "unknown",
|
"VERSION_UNKNOWN": "unknown",
|
||||||
|
"CHANGE_PASSWORD": {
|
||||||
|
"TITLE": "Change password",
|
||||||
|
"CURRENT": "Current password",
|
||||||
|
"NEW": "New password",
|
||||||
|
"REPEAT": "Repeat new password",
|
||||||
|
"SUBMIT": "Change password",
|
||||||
|
"DONE": "Password changed. Your other logins are ended.",
|
||||||
|
"WRONG_PASSWORD": "The current password is wrong.",
|
||||||
|
"TOO_MANY": "Too many wrong passwords. Try again in {{minutes}} minutes.",
|
||||||
|
"ERROR": "The password could not be changed."
|
||||||
|
},
|
||||||
"DELETE_ACCOUNT": {
|
"DELETE_ACCOUNT": {
|
||||||
"TITLE": "Delete account",
|
"TITLE": "Delete account",
|
||||||
"EXPLAIN": "Your account is blocked at once: no login, no public profile. After 30 days it is removed for good, with your library, backlog and stats.",
|
"EXPLAIN": "Your account is blocked at once: no login, no public profile. After 30 days it is removed for good, with your library, backlog and stats.",
|
||||||
@@ -151,6 +176,7 @@
|
|||||||
"PASSWORD_TOO_SHORT": "Password must be at least 8 characters.",
|
"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_TOO_LONG": "Password is too long: at most 72 characters, fewer with umlauts or emoji.",
|
||||||
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
|
"PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.",
|
||||||
|
"CURRENT_PASSWORD_REQUIRED": "Enter your current password.",
|
||||||
"PASSWORD_MISMATCH": "Passwords do not match.",
|
"PASSWORD_MISMATCH": "Passwords do not match.",
|
||||||
"DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
|
"DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)."
|
||||||
},
|
},
|
||||||
@@ -243,7 +269,7 @@
|
|||||||
"OPEN_IN_STEAM": "View on Steam",
|
"OPEN_IN_STEAM": "View on Steam",
|
||||||
"HIDE": "Hide game",
|
"HIDE": "Hide game",
|
||||||
"UNHIDE": "Show game again",
|
"UNHIDE": "Show game again",
|
||||||
"HIDDEN_NOTE": "Hidden: this game is not in your library lists, stats or public profile. Only its playtime is still updated.",
|
"HIDDEN_NOTE": "Hidden: this game is not in your library lists, stats or public profile.",
|
||||||
"HIDDEN_TOAST": "Game hidden",
|
"HIDDEN_TOAST": "Game hidden",
|
||||||
"SHOWN_TOAST": "Game shown again",
|
"SHOWN_TOAST": "Game shown again",
|
||||||
"HIDE_ERROR": "Could not change the game.",
|
"HIDE_ERROR": "Could not change the game.",
|
||||||
@@ -323,7 +349,8 @@
|
|||||||
"PENDING_REQUESTS_one": "{{count}} registration waits",
|
"PENDING_REQUESTS_one": "{{count}} registration waits",
|
||||||
"PENDING_REQUESTS_other": "{{count}} registrations wait",
|
"PENDING_REQUESTS_other": "{{count}} registrations wait",
|
||||||
"LINK_QUEUE": "Link queue",
|
"LINK_QUEUE": "Link queue",
|
||||||
"AUDIT_LOG": "Audit log"
|
"AUDIT_LOG": "Audit log",
|
||||||
|
"GAME_CATALOG": "Game catalog"
|
||||||
},
|
},
|
||||||
"BACKLOG": {
|
"BACKLOG": {
|
||||||
"TITLE": "My backlog",
|
"TITLE": "My backlog",
|
||||||
@@ -559,6 +586,7 @@
|
|||||||
"DONE_DELETE": "User deleted.",
|
"DONE_DELETE": "User deleted.",
|
||||||
"ERROR_SELF_ACTION": "You cannot do this to your own account.",
|
"ERROR_SELF_ACTION": "You cannot do this to your own account.",
|
||||||
"ERROR_TARGET_IS_ADMIN": "You cannot do this to another admin.",
|
"ERROR_TARGET_IS_ADMIN": "You cannot do this to another admin.",
|
||||||
|
"ERROR_NOT_ACTIVE": "Only an active account can get a reset mail.",
|
||||||
"ERROR_ALREADY_DELETED": "The account is already deleted.",
|
"ERROR_ALREADY_DELETED": "The account is already deleted.",
|
||||||
"ERROR": "The action failed.",
|
"ERROR": "The action failed.",
|
||||||
"PAGE_OF": "Page {{current}} of {{total}}",
|
"PAGE_OF": "Page {{current}} of {{total}}",
|
||||||
@@ -580,6 +608,10 @@
|
|||||||
"ERROR_TARGET_IS_UNCONFIRMED": "The address is not confirmed: delete the request.",
|
"ERROR_TARGET_IS_UNCONFIRMED": "The address is not confirmed: delete the request.",
|
||||||
"ERROR_NOT_UNCONFIRMED": "The request is confirmed already.",
|
"ERROR_NOT_UNCONFIRMED": "The request is confirmed already.",
|
||||||
"DONE_DENY": "Registration denied",
|
"DONE_DENY": "Registration denied",
|
||||||
|
"SEND_PASSWORD_RESET": "Send password reset",
|
||||||
|
"PASSWORD_RESET_TITLE": "Send a password reset to {{email}}?",
|
||||||
|
"PASSWORD_RESET_TEXT": "The user gets a mail with a link to choose a new password (valid 1 hour). You never see the password.",
|
||||||
|
"DONE_PASSWORD_RESET": "Reset mail sent.",
|
||||||
"ERROR_NOT_PENDING": "This registration is decided already.",
|
"ERROR_NOT_PENDING": "This registration is decided already.",
|
||||||
"ERROR_TARGET_IS_PENDING": "Accept or deny the registration first."
|
"ERROR_TARGET_IS_PENDING": "Accept or deny the registration first."
|
||||||
},
|
},
|
||||||
@@ -595,6 +627,9 @@
|
|||||||
"NO_ACCESS": "Only auditors can correct game data.",
|
"NO_ACCESS": "Only auditors can correct game data.",
|
||||||
"ERROR": "The correction could not be saved.",
|
"ERROR": "The correction could not be saved.",
|
||||||
"IMAGE_HINT": "https://… (empty: the Steam header)",
|
"IMAGE_HINT": "https://… (empty: the Steam header)",
|
||||||
|
"HOURS": "hours",
|
||||||
|
"MINUTES": "minutes",
|
||||||
|
"NEGATIVE": "Not saved: hours and minutes cannot be negative.",
|
||||||
"SPEEDRUN_HINT": "https://www.speedrun.com/… (empty: no speedrun)",
|
"SPEEDRUN_HINT": "https://www.speedrun.com/… (empty: no speedrun)",
|
||||||
"FIELD": {
|
"FIELD": {
|
||||||
"NAME": "Name",
|
"NAME": "Name",
|
||||||
@@ -602,7 +637,8 @@
|
|||||||
"SPEEDRUN": "Speedrun link",
|
"SPEEDRUN": "Speedrun link",
|
||||||
"MOODS": "Moods",
|
"MOODS": "Moods",
|
||||||
"ENDLESS": "Endless game",
|
"ENDLESS": "Endless game",
|
||||||
"GENRES": "Genres"
|
"GENRES": "Genres",
|
||||||
|
"TIME_TO_BEAT": "Time to beat"
|
||||||
},
|
},
|
||||||
"ENDLESS": {
|
"ENDLESS": {
|
||||||
"UNKNOWN": "Unknown",
|
"UNKNOWN": "Unknown",
|
||||||
@@ -614,7 +650,7 @@
|
|||||||
"OPEN": "Merge duplicate",
|
"OPEN": "Merge duplicate",
|
||||||
"TITLE": "Merge with the same game",
|
"TITLE": "Merge with the same game",
|
||||||
"HINT": "Find the other entry of \"{{name}}\", e.g. the Steam game or a game added by hand. The merge applies to every user.",
|
"HINT": "Find the other entry of \"{{name}}\", e.g. the Steam game or a game added by hand. The merge applies to every user.",
|
||||||
"CONFIRM": "Merge \"{{name}}\" and \"{{other}}\" into one game for every user? Libraries with both keep one entry with the status, rating and notes set last. This cannot be undone.",
|
"CONFIRM": "Merge \"{{name}}\" and \"{{other}}\" into one game for every user? Libraries with both keep one entry with the status, rating and notes set last. This can be undone for 30 days in the audit log.",
|
||||||
"CONFIRM_BUTTON": "Merge",
|
"CONFIRM_BUTTON": "Merge",
|
||||||
"BACK": "Back",
|
"BACK": "Back",
|
||||||
"DONE": "Games merged",
|
"DONE": "Games merged",
|
||||||
@@ -658,20 +694,45 @@
|
|||||||
"GAME_NOT_FOUND": "This game is not in your library."
|
"GAME_NOT_FOUND": "This game is not in your library."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CATALOG": {
|
||||||
|
"TITLE": "Game catalog",
|
||||||
|
"SEARCH": "Search by name",
|
||||||
|
"EMPTY": "No games found.",
|
||||||
|
"NO_ACCESS": "Only auditors can open this page.",
|
||||||
|
"LOAD_ERROR": "The game catalog could not be loaded.",
|
||||||
|
"CORRECT": "Correct",
|
||||||
|
"KEY": "Key {{appid}}",
|
||||||
|
"GONE": "This game no longer exists."
|
||||||
|
},
|
||||||
"AUDIT": {
|
"AUDIT": {
|
||||||
"TITLE": "Audit log",
|
"TITLE": "Audit log",
|
||||||
"EMPTY": "No entries.",
|
"EMPTY": "No entries.",
|
||||||
"NO_ACCESS": "Only auditors can open this page.",
|
"NO_ACCESS": "Only auditors can open this page.",
|
||||||
"LOAD_ERROR": "The audit log could not be loaded.",
|
"LOAD_ERROR": "The audit log could not be loaded.",
|
||||||
"USER_ID": "user {{id}}",
|
"USER_ID": "user {{id}}",
|
||||||
|
"UNDO": {
|
||||||
|
"BUTTON": "Undo",
|
||||||
|
"CONFIRM": "Undo the merge of \"{{name}}\" and \"{{other}}\"? Both games come back for every user. Status, rating and notes set since the merge are kept.",
|
||||||
|
"DONE": "Merge undone.",
|
||||||
|
"ERROR": "The merge could not be undone.",
|
||||||
|
"ERROR_CODES": {
|
||||||
|
"MERGE_NOT_FOUND": "This merge does not exist.",
|
||||||
|
"UNDO_NOT_AVAILABLE": "This merge was already undone or is too old to undo.",
|
||||||
|
"NEWER_MERGE": "Undo the newer merge of this game first.",
|
||||||
|
"UNDO_EXPIRED": "The undo of this merge expired after 30 days."
|
||||||
|
}
|
||||||
|
},
|
||||||
"PAGE_OF": "Page {{current}} of {{total}}",
|
"PAGE_OF": "Page {{current}} of {{total}}",
|
||||||
"ACTION": {
|
"ACTION": {
|
||||||
"LABEL": "Action",
|
"LABEL": "Action",
|
||||||
"ALL": "All actions",
|
"ALL": "All actions",
|
||||||
"GAME_MERGE": "Merge",
|
"GAME_MERGE": "Merge",
|
||||||
|
"GAME_MERGE_UNDO": "Merge undone",
|
||||||
"GAME_LINK": "Link",
|
"GAME_LINK": "Link",
|
||||||
"GAME_UNLINK": "Unlink",
|
"GAME_UNLINK": "Unlink",
|
||||||
"GAME_LINK_DISMISS": "Dismissed"
|
"GAME_LINK_DISMISS": "Dismissed",
|
||||||
|
"GAME_CORRECT": "Corrected",
|
||||||
|
"GAME_CORRECTION_RESET": "Back to source"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"HINT": {
|
"HINT": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ImprintPage from './pages/ImprintPage';
|
|||||||
import Dashboard from "./pages/Dashboard.jsx";
|
import Dashboard from "./pages/Dashboard.jsx";
|
||||||
import RegistrationPage from './pages/RegistrationPage.jsx';
|
import RegistrationPage from './pages/RegistrationPage.jsx';
|
||||||
import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx';
|
import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx';
|
||||||
|
import ResetPasswordPage from './pages/ResetPasswordPage.jsx';
|
||||||
import SettingsPage from './pages/SettingsPage.jsx';
|
import SettingsPage from './pages/SettingsPage.jsx';
|
||||||
import AdminHintsPage from './pages/AdminHintsPage.jsx';
|
import AdminHintsPage from './pages/AdminHintsPage.jsx';
|
||||||
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
|
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
|
||||||
@@ -17,6 +18,7 @@ import WelcomePage from './pages/WelcomePage.jsx';
|
|||||||
import WelcomeGate from './components/WelcomeGate.jsx';
|
import WelcomeGate from './components/WelcomeGate.jsx';
|
||||||
import AdminUsersPage from './pages/AdminUsersPage.jsx';
|
import AdminUsersPage from './pages/AdminUsersPage.jsx';
|
||||||
import AuditLogPage from './pages/AuditLogPage.jsx';
|
import AuditLogPage from './pages/AuditLogPage.jsx';
|
||||||
|
import GameCatalogPage from './pages/GameCatalogPage.jsx';
|
||||||
import LinkQueuePage from './pages/LinkQueuePage.jsx';
|
import LinkQueuePage from './pages/LinkQueuePage.jsx';
|
||||||
import GameLibrary from './pages/GameLibrary.jsx';
|
import GameLibrary from './pages/GameLibrary.jsx';
|
||||||
import GameDetail from './pages/GameDetail.jsx';
|
import GameDetail from './pages/GameDetail.jsx';
|
||||||
@@ -75,6 +77,7 @@ function App() {
|
|||||||
<Route path="/login" element={<LoginPage/>}/>
|
<Route path="/login" element={<LoginPage/>}/>
|
||||||
<Route path="/register" element={<RegistrationPage/>}/>
|
<Route path="/register" element={<RegistrationPage/>}/>
|
||||||
<Route path="/confirm-email" element={<ConfirmEmailPage/>}/>
|
<Route path="/confirm-email" element={<ConfirmEmailPage/>}/>
|
||||||
|
<Route path="/reset-password" element={<ResetPasswordPage/>}/>
|
||||||
<Route element={<RequireSession/>}>
|
<Route element={<RequireSession/>}>
|
||||||
<Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
|
<Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
|
||||||
<Route path="/library" element={<GameLibrary/>}/>
|
<Route path="/library" element={<GameLibrary/>}/>
|
||||||
@@ -87,6 +90,7 @@ function App() {
|
|||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
<Route path="/whats-new" element={<WhatsNewPage/>}/>
|
<Route path="/whats-new" element={<WhatsNewPage/>}/>
|
||||||
<Route path="/welcome" element={<WelcomePage/>}/>
|
<Route path="/welcome" element={<WelcomePage/>}/>
|
||||||
|
<Route path="/audit/games" element={<GameCatalogPage/>}/>
|
||||||
<Route path="/audit/links" element={<LinkQueuePage/>}/>
|
<Route path="/audit/links" element={<LinkQueuePage/>}/>
|
||||||
<Route path="/audit/log" element={<AuditLogPage/>}/>
|
<Route path="/audit/log" element={<AuditLogPage/>}/>
|
||||||
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
<Route path="/admin/users" element={<AdminUsersPage/>}/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Settings, Shield, Sun} from 'lucide-react';
|
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
@@ -158,6 +158,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{canCorrect(currentUser) && (
|
{canCorrect(currentUser) && (
|
||||||
<>
|
<>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/audit/games"><Search/>{t('NAV.GAME_CATALOG')}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
|
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -88,12 +88,13 @@ describe('AppHeader', () => {
|
|||||||
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offersTheLinkQueueAndAuditLogToAnAuditorOnly', async () => {
|
it('offersTheCatalogLinkQueueAndAuditLogToAnAuditorOnly', async () => {
|
||||||
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
|
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
|
||||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||||
openMenu();
|
openMenu();
|
||||||
|
|
||||||
expect(await screen.findByText('NAV.LINK_QUEUE')).toBeTruthy();
|
expect(await screen.findByText('NAV.GAME_CATALOG')).toBeTruthy();
|
||||||
|
expect(screen.getByText('NAV.LINK_QUEUE')).toBeTruthy();
|
||||||
expect(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy();
|
expect(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy();
|
||||||
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -108,6 +109,7 @@ describe('AppHeader', () => {
|
|||||||
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
|
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
|
||||||
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
||||||
expect(screen.queryByText('NAV.LINK_QUEUE')).toBeNull();
|
expect(screen.queryByText('NAV.LINK_QUEUE')).toBeNull();
|
||||||
|
expect(screen.queryByText('NAV.GAME_CATALOG')).toBeNull();
|
||||||
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
|
||||||
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
||||||
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
|
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {useForm} from 'react-hook-form';
|
||||||
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
|
import {KeyRound, Loader2} from 'lucide-react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
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 {changePassword} from '../utils/account.js';
|
||||||
|
import {changePasswordSchema} from '../utils/validation.js';
|
||||||
|
|
||||||
|
const EMPTY = {currentPassword: '', password: '', passwordConfirmation: ''};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Change password": Auth ends every other login and keeps this one.
|
||||||
|
*/
|
||||||
|
export default function ChangePasswordCard() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const {register, handleSubmit, reset, formState: {errors, isSubmitting}} = useForm({
|
||||||
|
resolver: zodResolver(changePasswordSchema),
|
||||||
|
defaultValues: EMPTY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = async ({currentPassword, password}) => {
|
||||||
|
try {
|
||||||
|
const response = await changePassword(currentPassword, password);
|
||||||
|
if (response.status === 204) {
|
||||||
|
toast.success(t('SETTINGS.CHANGE_PASSWORD.DONE'));
|
||||||
|
reset(EMPTY);
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
toast.error(t('SETTINGS.CHANGE_PASSWORD.WRONG_PASSWORD'));
|
||||||
|
} else if (response.status === 429) {
|
||||||
|
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
||||||
|
toast.error(t('SETTINGS.CHANGE_PASSWORD.TOO_MANY', {minutes}));
|
||||||
|
} else if (response.status !== 401) {
|
||||||
|
toast.error(t('SETTINGS.CHANGE_PASSWORD.ERROR'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Change password error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2"><KeyRound className="size-4"/>{t('SETTINGS.CHANGE_PASSWORD.TITLE')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(submit)} className="space-y-4" noValidate>
|
||||||
|
<FormField id="currentPassword" label={t('SETTINGS.CHANGE_PASSWORD.CURRENT')} error={errors.currentPassword} t={t}>
|
||||||
|
<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>
|
||||||
|
<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')}/>
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="animate-spin"/>}
|
||||||
|
{t('SETTINGS.CHANGE_PASSWORD.SUBMIT')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import ChangePasswordCard from './ChangePasswordCard.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
|
const answers = (answer) => fetchRoutes({'POST /api/auth/password': answer});
|
||||||
|
|
||||||
|
function fillAndSubmit(current = 'old password', next = 'new password 1', repeat = next) {
|
||||||
|
fireEvent.change(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.CURRENT'), {target: {value: current}});
|
||||||
|
fireEvent.change(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.NEW'), {target: {value: next}});
|
||||||
|
fireEvent.change(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.REPEAT'), {target: {value: repeat}});
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'SETTINGS.CHANGE_PASSWORD.SUBMIT'}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChangePasswordCard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the current and the new password and clears the form on success', async () => {
|
||||||
|
const mock = answers(json(204));
|
||||||
|
render(<ChangePasswordCard/>);
|
||||||
|
|
||||||
|
fillAndSubmit();
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('SETTINGS.CHANGE_PASSWORD.DONE'));
|
||||||
|
expect(mock.mock.calls[0][0]).toMatch(/\/api\/auth\/password$/);
|
||||||
|
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({currentPassword: 'old password', newPassword: 'new password 1'});
|
||||||
|
expect(screen.getByLabelText('SETTINGS.CHANGE_PASSWORD.CURRENT').value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells a wrong current password', async () => {
|
||||||
|
answers(json(403, {code: 'INVALID_PASSWORD'}));
|
||||||
|
render(<ChangePasswordCard/>);
|
||||||
|
|
||||||
|
fillAndSubmit();
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.CHANGE_PASSWORD.WRONG_PASSWORD'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the lock with the minutes', async () => {
|
||||||
|
answers({...json(429, {}), headers: new Headers({'Retry-After': '900'})});
|
||||||
|
render(<ChangePasswordCard/>);
|
||||||
|
|
||||||
|
fillAndSubmit();
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.CHANGE_PASSWORD.TOO_MANY'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends nothing when the repeat differs or the current password is empty', async () => {
|
||||||
|
const mock = fetchRoutes({});
|
||||||
|
render(<ChangePasswordCard/>);
|
||||||
|
|
||||||
|
fillAndSubmit('old password', 'new password 1', 'new password 2');
|
||||||
|
expect(await screen.findByText('VALIDATION.PASSWORD_MISMATCH')).toBeTruthy();
|
||||||
|
fillAndSubmit('', 'new password 1');
|
||||||
|
expect(await screen.findByText('VALIDATION.CURRENT_PASSWORD_REQUIRED')).toBeTruthy();
|
||||||
|
|
||||||
|
expect(mock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,12 +10,19 @@ import {Input} from '@/components/ui/input';
|
|||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
|
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
|
||||||
import {genreKey} from '../utils/stats.js';
|
import {genreKey} from '../utils/stats.js';
|
||||||
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js';
|
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH, MAX_TIME_TO_BEAT_MINUTES} from '../utils/limits.js';
|
||||||
|
|
||||||
const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'};
|
const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'};
|
||||||
|
|
||||||
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
|
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
|
||||||
|
|
||||||
|
const toHoursAndMinutes = (minutes) => (minutes ? {h: String(Math.floor(minutes / 60)), m: String(minutes % 60)} : {h: '', m: ''});
|
||||||
|
|
||||||
|
const toMinutes = ({h, m}) => (h.trim() === '' && m.trim() === '' ? null
|
||||||
|
: Math.round(Number(h || 0) * 60 + Number(m || 0)));
|
||||||
|
|
||||||
|
const hasNegativePart = ({normally, completely}) => [normally.h, normally.m, completely.h, completely.m].some((part) => Number(part) < 0);
|
||||||
|
|
||||||
const initialValues = (game) => ({
|
const initialValues = (game) => ({
|
||||||
name: game.name ?? '',
|
name: game.name ?? '',
|
||||||
image: game.imageUrl ?? '',
|
image: game.imageUrl ?? '',
|
||||||
@@ -23,10 +30,19 @@ const initialValues = (game) => ({
|
|||||||
moods: game.moods ?? [],
|
moods: game.moods ?? [],
|
||||||
endless: game.endless ?? null,
|
endless: game.endless ?? null,
|
||||||
genres: game.genres ?? [],
|
genres: game.genres ?? [],
|
||||||
|
time_to_beat: {
|
||||||
|
normally: toHoursAndMinutes(game.timeToBeat?.normallyMinutes),
|
||||||
|
completely: toHoursAndMinutes(game.timeToBeat?.completelyMinutes),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// An empty text field means "none": the Steam header, or no speedrun.
|
// An empty text field means "none": the Steam header, or no speedrun.
|
||||||
const toValue = (value) => (typeof value === 'string' ? value.trim() || null : value);
|
const toValue = (field, value) => {
|
||||||
|
if (field === 'time_to_beat') {
|
||||||
|
return {normallyMinutes: toMinutes(value.normally), completelyMinutes: toMinutes(value.completely)};
|
||||||
|
}
|
||||||
|
return typeof value === 'string' ? value.trim() || null : value;
|
||||||
|
};
|
||||||
|
|
||||||
const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]);
|
const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]);
|
||||||
|
|
||||||
@@ -81,6 +97,14 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const save = (field) => {
|
||||||
|
if (field === 'time_to_beat' && hasNegativePart(values.time_to_beat)) {
|
||||||
|
toast.error(t('CORRECTION.NEGATIVE'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send(field, () => saveCorrection(game.appid, field, toValue(field, values[field])));
|
||||||
|
};
|
||||||
|
|
||||||
const row = (field, input) => (
|
const row = (field, input) => (
|
||||||
<div key={field} className="space-y-2 border-b pb-4 last:border-b-0">
|
<div key={field} className="space-y-2 border-b pb-4 last:border-b-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -90,7 +114,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
|||||||
{input}
|
{input}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="button" size="sm" disabled={busy !== null}
|
<Button type="button" size="sm" disabled={busy !== null}
|
||||||
onClick={() => send(field, () => saveCorrection(game.appid, field, toValue(values[field])))}>
|
onClick={() => save(field)}>
|
||||||
{busy === field ? <Loader2 className="animate-spin"/> : <Save/>}{t('CORRECTION.SAVE')}
|
{busy === field ? <Loader2 className="animate-spin"/> : <Save/>}{t('CORRECTION.SAVE')}
|
||||||
</Button>
|
</Button>
|
||||||
{corrected.has(field) && (
|
{corrected.has(field) && (
|
||||||
@@ -116,6 +140,25 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const timeInputs = (kind, label) => {
|
||||||
|
const setPart = (part, text) => set('time_to_beat', {
|
||||||
|
...values.time_to_beat, [kind]: {...values.time_to_beat[kind], [part]: text},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="w-20 text-sm">{label}</span>
|
||||||
|
<Input type="number" min="0" max={MAX_TIME_TO_BEAT_MINUTES / 60} step="1" className="w-24"
|
||||||
|
aria-label={`${label} ${t('CORRECTION.HOURS')}`} value={values.time_to_beat[kind].h}
|
||||||
|
onChange={(e) => setPart('h', e.target.value)}/>
|
||||||
|
<span className="text-sm text-muted-foreground">{t('CORRECTION.HOURS')}</span>
|
||||||
|
<Input type="number" min="0" max="59" step="1" className="w-20"
|
||||||
|
aria-label={`${label} ${t('CORRECTION.MINUTES')}`} value={values.time_to_beat[kind].m}
|
||||||
|
onChange={(e) => setPart('m', e.target.value)}/>
|
||||||
|
<span className="text-sm text-muted-foreground">{t('CORRECTION.MINUTES')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// A genre IGDB added later still shows, so it can be removed.
|
// A genre IGDB added later still shows, so it can be removed.
|
||||||
const genreOptions = [...IGDB_GENRES, ...values.genres.filter((g) => !IGDB_GENRES.includes(g))];
|
const genreOptions = [...IGDB_GENRES, ...values.genres.filter((g) => !IGDB_GENRES.includes(g))];
|
||||||
|
|
||||||
@@ -149,6 +192,12 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
|||||||
))}
|
))}
|
||||||
{row('genres', choices('genres', genreOptions,
|
{row('genres', choices('genres', genreOptions,
|
||||||
(genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_CORRECTION_GENRES))}
|
(genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_CORRECTION_GENRES))}
|
||||||
|
{row('time_to_beat', (
|
||||||
|
<div id="correction-time_to_beat" className="space-y-2">
|
||||||
|
{timeInputs('normally', t('GAME_DETAIL.TTB_NORMALLY'))}
|
||||||
|
{timeInputs('completely', t('GAME_DETAIL.TTB_COMPLETELY'))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ const game = {
|
|||||||
appid: 620, name: 'Portal 2', imageUrl: null, speedrunUrl: 'https://www.speedrun.com/portal_2',
|
appid: 620, name: 'Portal 2', imageUrl: null, speedrunUrl: 'https://www.speedrun.com/portal_2',
|
||||||
moods: ['STORY'], endless: null, genres: ['Puzzle'],
|
moods: ['STORY'], endless: null, genres: ['Puzzle'],
|
||||||
corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}],
|
corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}],
|
||||||
|
timeToBeat: {hastilyMinutes: 300, normallyMinutes: 750, completelyMinutes: null},
|
||||||
};
|
};
|
||||||
|
|
||||||
const GAME_URL = '/api/library/games/620/corrections';
|
const GAME_URL = '/api/library/games/620/corrections';
|
||||||
const corrections = (answer) => fetchRoutes({
|
const corrections = (answer) => fetchRoutes({
|
||||||
[`PUT ${GAME_URL}/name`]: answer, [`PUT ${GAME_URL}/image`]: answer, [`PUT ${GAME_URL}/speedrun`]: answer,
|
[`PUT ${GAME_URL}/name`]: answer, [`PUT ${GAME_URL}/image`]: answer, [`PUT ${GAME_URL}/speedrun`]: answer,
|
||||||
[`PUT ${GAME_URL}/moods`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
|
[`PUT ${GAME_URL}/moods`]: answer, [`PUT ${GAME_URL}/time_to_beat`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderDialog = (onChanged = vi.fn()) => {
|
const renderDialog = (onChanged = vi.fn()) => {
|
||||||
@@ -26,7 +27,7 @@ const renderDialog = (onChanged = vi.fn()) => {
|
|||||||
return onChanged;
|
return onChanged;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The rows in order: name, image, speedrun, moods, endless, genres.
|
// The rows in order: name, image, speedrun, moods, endless, genres, time_to_beat.
|
||||||
const saveButton = (row) => screen.getAllByRole('button', {name: 'CORRECTION.SAVE'})[row];
|
const saveButton = (row) => screen.getAllByRole('button', {name: 'CORRECTION.SAVE'})[row];
|
||||||
|
|
||||||
const sent = () => fetch.mock.calls.map(([url, init]) => ({url, method: init?.method, body: init?.body && JSON.parse(init.body)}));
|
const sent = () => fetch.mock.calls.map(([url, init]) => ({url, method: init?.method, body: init?.body && JSON.parse(init.body)}));
|
||||||
@@ -69,6 +70,49 @@ describe('GameCorrectionDialog', () => {
|
|||||||
expect(sent().map((call) => call.body)).toEqual([{value: null}, {value: ['COOP']}]);
|
expect(sent().map((call) => call.body)).toEqual([{value: null}, {value: ['COOP']}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the time to beat as hours and minutes and sends minutes, empty as null', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS').value).toBe('12');
|
||||||
|
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES').value).toBe('30');
|
||||||
|
expect(screen.getByLabelText('GAME_DETAIL.TTB_COMPLETELY CORRECTION.HOURS').value).toBe('');
|
||||||
|
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
|
||||||
|
fireEvent.click(saveButton(6));
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(sent()[0].body).toEqual({value: {normallyMinutes: 720, completelyMinutes: null}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendsFractionalHoursAsExactMinutes', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1.5'}});
|
||||||
|
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
|
||||||
|
fireEvent.click(saveButton(6));
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(sent()[0].body.value.normallyMinutes).toBe(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refusesANegativeTimePartBeforeSaving', () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1'}});
|
||||||
|
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: '-5'}});
|
||||||
|
fireEvent.click(saveButton(6));
|
||||||
|
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('CORRECTION.NEGATIVE');
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers back to source for a corrected time to beat', () => {
|
||||||
|
render(<MemoryRouter><GameCorrectionDialog open onOpenChange={vi.fn()} onChanged={vi.fn()}
|
||||||
|
game={{...game, corrections: [{field: 'TIME_TO_BEAT', correctedAt: '2026-09-28T10:00:00Z'}]}}/></MemoryRouter>);
|
||||||
|
|
||||||
|
expect(screen.getAllByText('CORRECTION.CORRECTED')).toHaveLength(1);
|
||||||
|
expect(screen.getByRole('button', {name: 'CORRECTION.BACK_TO_SOURCE'})).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('marks a corrected field and goes back to the source', async () => {
|
it('marks a corrected field and goes back to the source', async () => {
|
||||||
const onChanged = renderDialog();
|
const onChanged = renderDialog();
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
|
|||||||
// The spans of each completed effect decoration; effects.css places them.
|
// The spans of each completed effect decoration; effects.css places them.
|
||||||
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6};
|
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6};
|
||||||
|
|
||||||
export default function GameHeroCard({game, manual, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
|
export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const {appearance, equipped} = usePreferences();
|
const {appearance, equipped} = usePreferences();
|
||||||
const saveAppearance = useSaveAppearance();
|
const saveAppearance = useSaveAppearance();
|
||||||
@@ -102,43 +102,42 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Store page, background and banner need Steam art, so a game off Steam has none of them.
|
{/* Store page, background and banner need Steam art, so a game off Steam has none of them. */}
|
||||||
Hide is here too: only a Steam game can be hidden. */}
|
<div className="flex flex-wrap gap-2">
|
||||||
{onSteam && <div className="flex flex-wrap gap-2">
|
{onSteam && (<>
|
||||||
<Button asChild variant="outline" size="sm">
|
<Button asChild variant="outline" size="sm">
|
||||||
<a href={`https://store.steampowered.com/app/${game.appid}`} target="_blank" rel="noopener noreferrer">
|
<a href={`https://store.steampowered.com/app/${game.appid}`} target="_blank" rel="noopener noreferrer">
|
||||||
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
||||||
</a>
|
</a>
|
||||||
|
</Button>
|
||||||
|
{isBackground ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}>
|
||||||
|
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: game.appid})}>
|
||||||
|
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isBanner ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => saveAppearance({bannerAppid: null})}>
|
||||||
|
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm"
|
||||||
|
onClick={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
|
||||||
|
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{profileStyle && (
|
||||||
|
<ProfileArtMenu appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
|
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
|
||||||
|
{game.hidden ? <Eye/> : <EyeOff/>}
|
||||||
|
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
||||||
</Button>
|
</Button>
|
||||||
{isBackground ? (
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}>
|
|
||||||
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: game.appid})}>
|
|
||||||
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isBanner ? (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({bannerAppid: null})}>
|
|
||||||
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button variant="outline" size="sm"
|
|
||||||
onClick={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
|
|
||||||
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{profileStyle && (
|
|
||||||
<ProfileArtMenu appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
|
|
||||||
)}
|
|
||||||
{!manual && (
|
|
||||||
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
|
|
||||||
{game.hidden ? <Eye/> : <EyeOff/>}
|
|
||||||
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {Input} from '@/components/ui/input';
|
||||||
|
import FormField from './FormField.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The new password and its repeat, for a form on confirmEmailSchema.
|
||||||
|
*/
|
||||||
|
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>
|
||||||
|
<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')}/>
|
||||||
|
</FormField>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {useCallback, useEffect, useState} from 'react';
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Ban, Check, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2, X} from 'lucide-react';
|
import {Ban, Check, ChevronLeft, ChevronRight, KeyRound, Loader2, Search, ShieldCheck, Trash2, X} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
import {Input} from '@/components/ui/input';
|
import {Input} from '@/components/ui/input';
|
||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, sendPasswordReset, setUserBlocked} from '../utils/admin.js';
|
||||||
import {formatDate} from '../utils/format.js';
|
import {formatDate} from '../utils/format.js';
|
||||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ const REFUSAL_KEY = {
|
|||||||
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
|
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
|
||||||
TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED',
|
TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED',
|
||||||
NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED',
|
NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED',
|
||||||
|
NOT_ACTIVE: 'ADMIN.ERROR_NOT_ACTIVE',
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
|
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
|
||||||
@@ -47,7 +48,7 @@ export default function AdminUsersPage() {
|
|||||||
const [pendingOnly, setPendingOnly] = useState(false);
|
const [pendingOnly, setPendingOnly] = useState(false);
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [status, setStatus] = useState('loading');
|
const [status, setStatus] = useState('loading');
|
||||||
// {kind: 'block' | 'delete' | 'deleteUnconfirmed' | 'accept' | 'deny', user}
|
// {kind: 'block' | 'delete' | 'deleteUnconfirmed' | 'accept' | 'deny' | 'passwordReset', user}
|
||||||
const [pending, setPending] = useState(null);
|
const [pending, setPending] = useState(null);
|
||||||
const [typedEmail, setTypedEmail] = useState('');
|
const [typedEmail, setTypedEmail] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -105,6 +106,11 @@ export default function AdminUsersPage() {
|
|||||||
: {title: t('ADMIN.BLOCK_TITLE', {email}), text: t('ADMIN.BLOCK_TEXT'), action: 'ADMIN.BLOCK', done: 'ADMIN.DONE_BLOCK'}),
|
: {title: t('ADMIN.BLOCK_TITLE', {email}), text: t('ADMIN.BLOCK_TEXT'), action: 'ADMIN.BLOCK', done: 'ADMIN.DONE_BLOCK'}),
|
||||||
run: () => setUserBlocked(pending.user.id, !pending.user.blocked),
|
run: () => setUserBlocked(pending.user.id, !pending.user.blocked),
|
||||||
},
|
},
|
||||||
|
passwordReset: {
|
||||||
|
title: t('ADMIN.PASSWORD_RESET_TITLE', {email}), text: t('ADMIN.PASSWORD_RESET_TEXT'),
|
||||||
|
action: 'ADMIN.SEND_PASSWORD_RESET', done: 'ADMIN.DONE_PASSWORD_RESET',
|
||||||
|
run: () => sendPasswordReset(pending.user.id),
|
||||||
|
},
|
||||||
accept: {
|
accept: {
|
||||||
title: t('ADMIN.ACCEPT_TITLE', {email}), text: t('ADMIN.ACCEPT_TEXT'), action: 'ADMIN.ACCEPT',
|
title: t('ADMIN.ACCEPT_TITLE', {email}), text: t('ADMIN.ACCEPT_TEXT'), action: 'ADMIN.ACCEPT',
|
||||||
done: 'ADMIN.DONE_ACCEPT', run: () => decideUser(pending.user.id, true),
|
done: 'ADMIN.DONE_ACCEPT', run: () => decideUser(pending.user.id, true),
|
||||||
@@ -217,6 +223,12 @@ export default function AdminUsersPage() {
|
|||||||
)}
|
)}
|
||||||
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && (
|
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{user.status === 'ACTIVE' && !user.blocked && (
|
||||||
|
<Button type="button" variant="outline" size="sm"
|
||||||
|
onClick={() => setPending({kind: 'passwordReset', user})}>
|
||||||
|
<KeyRound/>{t('ADMIN.SEND_PASSWORD_RESET')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button type="button" variant="outline" size="sm"
|
<Button type="button" variant="outline" size="sm"
|
||||||
onClick={() => setPending({kind: 'block', user})}>
|
onClick={() => setPending({kind: 'block', user})}>
|
||||||
{user.blocked ? <ShieldCheck/> : <Ban/>}
|
{user.blocked ? <ShieldCheck/> : <Ban/>}
|
||||||
|
|||||||
@@ -87,6 +87,35 @@ describe('AdminUsersPage', () => {
|
|||||||
await waitFor(() => expect(calls('DELETE')[0][0]).toMatch(/\/api\/admin\/users\/7$/));
|
await waitFor(() => expect(calls('DELETE')[0][0]).toMatch(/\/api\/admin\/users\/7$/));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers the password reset only for an active, unblocked non-admin and posts it after the confirm', async () => {
|
||||||
|
withRoutes({
|
||||||
|
'GET /api/admin/users': json(200, page([ADMIN, PLAYER, NEWCOMER, {...PLAYER, id: 11, email: 'b@example.com', blocked: true}])),
|
||||||
|
'POST /api/admin/users/7/password-reset': json(204),
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('player@example.com');
|
||||||
|
expect(screen.getAllByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'})).toHaveLength(1);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'}));
|
||||||
|
fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'}).at(-1));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_PASSWORD_RESET'));
|
||||||
|
expect(calls('POST')[0][0]).toMatch(/\/api\/admin\/users\/7\/password-reset$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the reason when the user is not active any more', async () => {
|
||||||
|
withRoutes({
|
||||||
|
'GET /api/admin/users': json(200, page([PLAYER])),
|
||||||
|
'POST /api/admin/users/7/password-reset': json(409, {code: 'NOT_ACTIVE'}),
|
||||||
|
});
|
||||||
|
renderPage();
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'}));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole('button', {name: 'ADMIN.SEND_PASSWORD_RESET'}).at(-1));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_NOT_ACTIVE'));
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the reason when Auth refuses', async () => {
|
it('shows the reason when Auth refuses', async () => {
|
||||||
withRoutes({
|
withRoutes({
|
||||||
'GET /api/admin/users': json(200, page([PLAYER])),
|
'GET /api/admin/users': json(200, page([PLAYER])),
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import {useCallback, useEffect, useState} from 'react';
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {ChevronLeft, ChevronRight, Loader2} from 'lucide-react';
|
import {ChevronLeft, ChevronRight, Loader2, Undo2} from 'lucide-react';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {fetchAuditLog} from '../utils/links.js';
|
import {toast} from 'sonner';
|
||||||
|
import {fetchAuditLog, undoMerge} from '../utils/links.js';
|
||||||
import {formatDateTime} from '../utils/format.js';
|
import {formatDateTime} from '../utils/format.js';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const ACTIONS = ['GAME_MERGE', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS'];
|
const ACTIONS = ['GAME_MERGE', 'GAME_MERGE_UNDO', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS', 'GAME_CORRECT',
|
||||||
|
'GAME_CORRECTION_RESET'];
|
||||||
const ALL = 'ALL';
|
const ALL = 'ALL';
|
||||||
|
|
||||||
function GameRef({appid, name, fallback}) {
|
function GameRef({appid, name, fallback}) {
|
||||||
if (appid == null) return null;
|
if (appid == null) return null;
|
||||||
return <Link to={`/library/${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>;
|
return <Link to={`/audit/games?appid=${appid}`} className="font-medium hover:text-primary hover:underline">{name ?? fallback}</Link>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,6 +30,7 @@ export default function AuditLogPage() {
|
|||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [result, setResult] = useState({content: [], totalPages: 0});
|
const [result, setResult] = useState({content: [], totalPages: 0});
|
||||||
const [status, setStatus] = useState('loading');
|
const [status, setStatus] = useState('loading');
|
||||||
|
const [undoing, setUndoing] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -53,6 +56,30 @@ export default function AuditLogPage() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
const onUndo = async (entry) => {
|
||||||
|
// The merged key has no catalog row any more; its name is the first one in the detail.
|
||||||
|
const name = entry.gameName ?? /^"(.*?)" into "/.exec(entry.detail ?? '')?.[1] ?? entry.appid;
|
||||||
|
const other = entry.targetGameName ?? entry.targetAppid;
|
||||||
|
if (!window.confirm(t('AUDIT.UNDO.CONFIRM', {name, other}))) return;
|
||||||
|
setUndoing(true);
|
||||||
|
try {
|
||||||
|
const response = await undoMerge(entry.id);
|
||||||
|
if (response.status === 401) return;
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success(t('AUDIT.UNDO.DONE'));
|
||||||
|
} else {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
toast.error(t(`AUDIT.UNDO.ERROR_CODES.${data.code}`, {defaultValue: t('AUDIT.UNDO.ERROR')}));
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Undo merge error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
} finally {
|
||||||
|
setUndoing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onActionChange = (value) => {
|
const onActionChange = (value) => {
|
||||||
setAction(value);
|
setAction(value);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
@@ -99,6 +126,12 @@ export default function AuditLogPage() {
|
|||||||
<GameRef appid={entry.targetAppid} name={entry.targetGameName} fallback={entry.targetAppid}/>
|
<GameRef appid={entry.targetAppid} name={entry.targetGameName} fallback={entry.targetAppid}/>
|
||||||
</p>
|
</p>
|
||||||
{entry.detail && <p className="break-words text-xs text-muted-foreground">{entry.detail}</p>}
|
{entry.detail && <p className="break-words text-xs text-muted-foreground">{entry.detail}</p>}
|
||||||
|
{entry.action === 'GAME_MERGE' && entry.undoable && (
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={undoing}
|
||||||
|
onClick={() => onUndo(entry)}>
|
||||||
|
<Undo2/>{t('AUDIT.UNDO.BUTTON')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -5,16 +5,21 @@ import {MemoryRouter} from 'react-router-dom';
|
|||||||
import AuditLogPage from './AuditLogPage.jsx';
|
import AuditLogPage from './AuditLogPage.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
const entry = (over) => ({id: 1, actorUserId: 5, actorName: 'lobo', action: 'GAME_LINK', appid: 10, gameName: 'Dark Souls',
|
const entry = (over) => ({id: 1, actorUserId: 5, actorName: 'lobo', action: 'GAME_LINK', appid: 10, gameName: 'Dark Souls',
|
||||||
targetAppid: -7, targetGameName: 'Dark Souls Remastered', detail: '"Dark Souls" with "Dark Souls Remastered"',
|
targetAppid: -7, targetGameName: 'Dark Souls Remastered', detail: '"Dark Souls" with "Dark Souls Remastered"',
|
||||||
createdAt: '2026-10-01T10:00:00Z', ...over});
|
createdAt: '2026-10-01T10:00:00Z', ...over});
|
||||||
const answer = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages});
|
const answer = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages});
|
||||||
|
|
||||||
const renderPage = (route) => {
|
const merge = (over) => entry({id: 7, action: 'GAME_MERGE', gameName: null, targetGameName: 'Hades', undoable: true,
|
||||||
fetchRoutes({'GET /api/library/audit-log': route});
|
detail: '"Hades (Switch)" into "Hades": 1 moved, 0 combined', ...over});
|
||||||
|
|
||||||
|
const renderPage = (route, more = {}) => {
|
||||||
|
fetchRoutes({'GET /api/library/audit-log': route, ...more});
|
||||||
render(<MemoryRouter><PreferencesProvider><AuditLogPage/></PreferencesProvider></MemoryRouter>);
|
render(<MemoryRouter><PreferencesProvider><AuditLogPage/></PreferencesProvider></MemoryRouter>);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,6 +29,8 @@ beforeEach(() => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.clearAllMocks();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,7 +40,7 @@ describe('AuditLogPage', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('lobo')).toBeTruthy();
|
expect(await screen.findByText('lobo')).toBeTruthy();
|
||||||
expect(screen.getByText('AUDIT.ACTION.GAME_LINK')).toBeTruthy();
|
expect(screen.getByText('AUDIT.ACTION.GAME_LINK')).toBeTruthy();
|
||||||
expect(screen.getByRole('link', {name: 'Dark Souls'}).getAttribute('href')).toBe('/library/10');
|
expect(screen.getByRole('link', {name: 'Dark Souls'}).getAttribute('href')).toBe('/audit/games?appid=10');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fallsBackToTheUserIdAndTheDetailForAMergedKey', async () => {
|
it('fallsBackToTheUserIdAndTheDetailForAMergedKey', async () => {
|
||||||
@@ -58,4 +65,42 @@ describe('AuditLogPage', () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('AUDIT.NO_ACCESS')).toBeTruthy();
|
expect(await screen.findByText('AUDIT.NO_ACCESS')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('showsUndoOnlyOnAnUndoableMerge', async () => {
|
||||||
|
renderPage(answer([merge({}), merge({id: 8, undoable: false}), entry({id: 9, undoable: true})]));
|
||||||
|
await screen.findAllByText('AUDIT.ACTION.GAME_MERGE');
|
||||||
|
|
||||||
|
expect(screen.getAllByRole('button', {name: /AUDIT.UNDO.BUTTON/})).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undoesAfterConfirmAndReloads', async () => {
|
||||||
|
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
renderPage(answer([merge({})]), {'POST /api/library/merges/7/undo': json(204, null)});
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: /AUDIT.UNDO.BUTTON/}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('AUDIT.UNDO.DONE'));
|
||||||
|
expect(confirm).toHaveBeenCalledWith('AUDIT.UNDO.CONFIRM');
|
||||||
|
expect(fetch.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1);
|
||||||
|
expect(fetch.mock.calls.filter(([url]) => url.includes('/audit-log'))).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doesNothingWhenTheConfirmIsCancelled', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
renderPage(answer([merge({})]));
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: /AUDIT.UNDO.BUTTON/}));
|
||||||
|
|
||||||
|
expect(fetch.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(0);
|
||||||
|
expect(toast.success).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['NEWER_MERGE', 'UNDO_EXPIRED'])('showsTheMessageOf%s', async (code) => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
renderPage(answer([merge({})]), {'POST /api/library/merges/7/undo': json(409, {code})});
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: /AUDIT.UNDO.BUTTON/}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(`AUDIT.UNDO.ERROR_CODES.${code}`));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -6,10 +6,9 @@ import {zodResolver} from '@hookform/resolvers/zod';
|
|||||||
import {Loader2} from 'lucide-react';
|
import {Loader2} from 'lucide-react';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Input} from '@/components/ui/input';
|
|
||||||
import AuthLayout from '../components/AuthLayout.jsx';
|
import AuthLayout from '../components/AuthLayout.jsx';
|
||||||
import FeedbackAlert from '../components/FeedbackAlert.jsx';
|
import FeedbackAlert from '../components/FeedbackAlert.jsx';
|
||||||
import FormField from '../components/FormField.jsx';
|
import NewPasswordFields from '../components/NewPasswordFields.jsx';
|
||||||
import ResendConfirmation from '../components/ResendConfirmation.jsx';
|
import ResendConfirmation from '../components/ResendConfirmation.jsx';
|
||||||
import {confirmEmailSchema} from '../utils/validation.js';
|
import {confirmEmailSchema} from '../utils/validation.js';
|
||||||
import {clientFetch} from '../utils/api.js';
|
import {clientFetch} from '../utils/api.js';
|
||||||
@@ -61,24 +60,7 @@ function ConfirmEmail({token}) {
|
|||||||
<AuthLayout title={t('CONFIRM_EMAIL.TITLE')}>
|
<AuthLayout title={t('CONFIRM_EMAIL.TITLE')}>
|
||||||
{state === 'idle' && <form onSubmit={handleSubmit(confirm)} className="space-y-4" noValidate>
|
{state === 'idle' && <form onSubmit={handleSubmit(confirm)} className="space-y-4" noValidate>
|
||||||
<p className="text-sm text-muted-foreground">{t('CONFIRM_EMAIL.INTRO')}</p>
|
<p className="text-sm text-muted-foreground">{t('CONFIRM_EMAIL.INTRO')}</p>
|
||||||
<FormField id="password" label={t('REGISTRATION.PASSWORD')} error={errors.password} t={t}>
|
<NewPasswordFields register={register} errors={errors} t={t}/>
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
aria-invalid={!!errors.password}
|
|
||||||
{...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')}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||||
{isSubmitting && <Loader2 className="animate-spin"/>}
|
{isSubmitting && <Loader2 className="animate-spin"/>}
|
||||||
{t('CONFIRM_EMAIL.SUBMIT')}
|
{t('CONFIRM_EMAIL.SUBMIT')}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
|
import {useSearchParams} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {ChevronLeft, ChevronRight, Loader2, Search} from 'lucide-react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
|
import {Input} from '@/components/ui/input';
|
||||||
|
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
|
||||||
|
import GameCover from '../components/GameCover.jsx';
|
||||||
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
|
import {fetchCatalogGame, searchCatalog} from '../utils/corrections.js';
|
||||||
|
import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AUDITOR or higher: finds any game some user has and opens the correction dialog for it. ?appid= opens a game
|
||||||
|
* directly (the audit log links here). Core checks the role.
|
||||||
|
*/
|
||||||
|
export default function GameCatalogPage() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const openAppid = params.get('appid');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [term, setTerm] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [result, setResult] = useState({content: [], totalPages: 0});
|
||||||
|
const [status, setStatus] = useState('loading');
|
||||||
|
const [detail, setDetail] = useState(null);
|
||||||
|
const [reloads, setReloads] = useState(0);
|
||||||
|
// The answer for a game that is no longer the open one (Esc before it came) is dropped.
|
||||||
|
const openRef = useRef(openAppid);
|
||||||
|
useEffect(() => {
|
||||||
|
openRef.current = openAppid;
|
||||||
|
}, [openAppid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (query.trim() !== term) {
|
||||||
|
setTerm(query.trim());
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [query, term]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const response = await searchCatalog({q: term, page, size: PAGE_SIZE}, controller.signal);
|
||||||
|
if (response.status === 401) return;
|
||||||
|
if (response.status === 403) {
|
||||||
|
setStatus('forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
setStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResult(await response.json());
|
||||||
|
setStatus('ready');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') return;
|
||||||
|
console.error('Catalog error:', err);
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [term, page, reloads]);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setDetail(null);
|
||||||
|
setParams({}, {replace: true});
|
||||||
|
}, [setParams]);
|
||||||
|
|
||||||
|
const loadDetail = useCallback(async (appid) => {
|
||||||
|
try {
|
||||||
|
const response = await fetchCatalogGame(appid);
|
||||||
|
if (response.status === 401 || String(appid) !== openRef.current) return;
|
||||||
|
if (response.ok) {
|
||||||
|
setDetail(await response.json());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 404: the game was merged meanwhile.
|
||||||
|
toast.error(t(response.status === 404 ? 'CATALOG.GONE' : 'CATALOG.LOAD_ERROR'));
|
||||||
|
close();
|
||||||
|
setReloads((n) => n + 1);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Catalog game error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}, [close, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (openAppid) loadDetail(openAppid);
|
||||||
|
else setDetail(null);
|
||||||
|
}, [openAppid, loadDetail]);
|
||||||
|
|
||||||
|
const changed = () => {
|
||||||
|
loadDetail(detail.appid);
|
||||||
|
setReloads((n) => n + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === 'forbidden') {
|
||||||
|
return (
|
||||||
|
<PageContainer className="px-4 py-6 md:px-8">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('CATALOG.NO_ACCESS')}</p>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="space-y-3">
|
||||||
|
<CardTitle>{t('CATALOG.TITLE')}</CardTitle>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
|
||||||
|
<Input type="search" value={query} maxLength={MAX_LIBRARY_SEARCH_LENGTH} className="pl-8"
|
||||||
|
placeholder={t('CATALOG.SEARCH')} aria-label={t('CATALOG.SEARCH')}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||||
|
{status === 'error' && <p className="text-sm text-destructive">{t('CATALOG.LOAD_ERROR')}</p>}
|
||||||
|
{status === 'ready' && result.content.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('CATALOG.EMPTY')}</p>
|
||||||
|
)}
|
||||||
|
{status === 'ready' && result.content.length > 0 && (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{result.content.map((game) => (
|
||||||
|
<li key={game.appid} className="flex items-center gap-3 py-2">
|
||||||
|
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-20 shrink-0"/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">{game.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{game.platforms.join(', ')} · {t('CATALOG.KEY', {appid: game.appid})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" size="sm" variant="outline"
|
||||||
|
onClick={() => setParams({appid: String(game.appid)})}>
|
||||||
|
{t('CATALOG.CORRECT')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{status === 'ready' && result.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={page === 0}
|
||||||
|
onClick={() => setPage(page - 1)}>
|
||||||
|
<ChevronLeft/>{t('ADMIN.PREVIOUS')}
|
||||||
|
</Button>
|
||||||
|
<span>{t('AUDIT.PAGE_OF', {current: page + 1, total: result.totalPages})}</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={page + 1 >= result.totalPages}
|
||||||
|
onClick={() => setPage(page + 1)}>
|
||||||
|
{t('ADMIN.NEXT')}<ChevronRight/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{detail && (
|
||||||
|
<GameCorrectionDialog game={detail} open onOpenChange={(open) => !open && close()} onChanged={changed}/>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import GameCatalogPage from './GameCatalogPage.jsx';
|
||||||
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
|
const hit = {appid: 10, name: 'Hades', imageUrl: null, platforms: ['PS5', 'Steam']};
|
||||||
|
const detail = {appid: 10, name: 'Hades', imageUrl: null, speedrunUrl: null, moods: [], endless: null, genres: [],
|
||||||
|
timeToBeat: null, corrections: []};
|
||||||
|
const catalog = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages});
|
||||||
|
|
||||||
|
const renderPage = (routes, url = '/audit/games') => {
|
||||||
|
fetchRoutes(routes);
|
||||||
|
render(<MemoryRouter initialEntries={[url]}><PreferencesProvider><GameCatalogPage/></PreferencesProvider></MemoryRouter>);
|
||||||
|
};
|
||||||
|
|
||||||
|
const urls = () => fetch.mock.calls.map(([url]) => url);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GameCatalogPage', () => {
|
||||||
|
it('listsHitsWithPlatformsAndKey', async () => {
|
||||||
|
renderPage({'GET /api/library/catalog': catalog([hit])});
|
||||||
|
|
||||||
|
expect(await screen.findByText('Hades')).toBeTruthy();
|
||||||
|
expect(screen.getByText(/PS5, Steam/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/CATALOG.KEY/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searchesWithTheTypedName', async () => {
|
||||||
|
renderPage({'GET /api/library/catalog': catalog([hit])});
|
||||||
|
await screen.findByText('Hades');
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('CATALOG.SEARCH'), {target: {value: ' had '}});
|
||||||
|
|
||||||
|
await waitFor(() => expect(urls().some((u) => u.includes('q=had'))).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opensTheCorrectionDialogForTheChosenGame', async () => {
|
||||||
|
renderPage({'GET /api/library/catalog': catalog([hit]), 'GET /api/library/catalog/10': json(200, detail)});
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'CATALOG.CORRECT'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
|
||||||
|
expect(urls().some((u) => u.endsWith('/api/library/catalog/10'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opensTheGameOfTheAppidInTheUrl', async () => {
|
||||||
|
renderPage({'GET /api/library/catalog': catalog([]), 'GET /api/library/catalog/10': json(200, detail)},
|
||||||
|
'/audit/games?appid=10');
|
||||||
|
|
||||||
|
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saysSoWhenTheGameIsGone', async () => {
|
||||||
|
const {toast} = await import('sonner');
|
||||||
|
renderPage({'GET /api/library/catalog': catalog([]), 'GET /api/library/catalog/10': json(404, {})},
|
||||||
|
'/audit/games?appid=10');
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CATALOG.GONE'));
|
||||||
|
expect(screen.queryByText('CORRECTION.TITLE')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsNoAccessOn403', async () => {
|
||||||
|
renderPage({'GET /api/library/catalog': json(403, {})});
|
||||||
|
|
||||||
|
expect(await screen.findByText('CATALOG.NO_ACCESS')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pagesThroughTheCatalog', async () => {
|
||||||
|
renderPage({'GET /api/library/catalog': () => catalog([hit], 2)});
|
||||||
|
await screen.findByText('Hades');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: /ADMIN.NEXT/}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(urls().at(-1)).toContain('page=1'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,6 +47,11 @@ export default function GameDetail() {
|
|||||||
shownLevel.current = {appid: data.appid, level: celebrationLevel(data)};
|
shownLevel.current = {appid: data.appid, level: celebrationLevel(data)};
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
// An old key of a merged game: Core answers with the kept game.
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && String(data.appid) !== appid) navigate(`/library/${data.appid}`, {replace: true});
|
||||||
|
}, [data, appid, navigate]);
|
||||||
|
|
||||||
const manual = data?.source === 'MANUAL';
|
const manual = data?.source === 'MANUAL';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,7 +81,7 @@ export default function GameDetail() {
|
|||||||
<EyeOff className="size-4 shrink-0"/>{t('GAME_DETAIL.HIDDEN_NOTE')}
|
<EyeOff className="size-4 shrink-0"/>{t('GAME_DETAIL.HIDDEN_NOTE')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<GameHeroCard game={data} manual={manual} auditor={auditor} profileStyle={savedProfileStyle ?? currentUser?.profileStyle}
|
<GameHeroCard game={data} auditor={auditor} profileStyle={savedProfileStyle ?? currentUser?.profileStyle}
|
||||||
onCorrect={() => setCorrecting(true)} onMerge={() => setMerging(true)}
|
onCorrect={() => setCorrecting(true)} onMerge={() => setMerging(true)}
|
||||||
onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))}
|
onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))}
|
||||||
onProfileStyleSaved={setSavedProfileStyle}/>
|
onProfileStyleSaved={setSavedProfileStyle}/>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ describe('GameDetail', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the game shown when Core refuses', async () => {
|
it('keeps the game shown when Core refuses', async () => {
|
||||||
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(409, {code: 'MANUAL_GAME'})});
|
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(500, {})});
|
||||||
renderDetail();
|
renderDetail();
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
|
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
|
||||||
@@ -73,12 +73,19 @@ describe('GameDetail', () => {
|
|||||||
expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull();
|
expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers no hide for a game added by hand', async () => {
|
it('hides a game added by hand and shows it again', async () => {
|
||||||
answer({...game, source: 'MANUAL', platform: 'PS5', hidden: false});
|
answer({...game, appid: -1942, source: 'MANUAL', platform: 'PS5', hidden: false},
|
||||||
renderDetail();
|
{'PUT /api/library/games/-1942/hidden': echo});
|
||||||
|
renderDetail(-1942);
|
||||||
|
|
||||||
await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM');
|
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
|
||||||
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.HIDE'})).toBeNull();
|
|
||||||
|
expect(await screen.findByText('GAME_DETAIL.HIDDEN_NOTE')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('GAME_DETAIL.OPEN_IN_STEAM')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.UNHIDE'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports a broken achievement icon once and shows the empty tile', async () => {
|
it('reports a broken achievement icon once and shows the empty tile', async () => {
|
||||||
@@ -419,4 +426,16 @@ describe('GameDetail session', () => {
|
|||||||
expect(toast.error).not.toHaveBeenCalled();
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
|
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replacesTheUrlOfAMergedKeyWithTheKeptGame', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/library/games/-7': json(200, {...game, appid: 220, source: 'STEAM'}),
|
||||||
|
'GET /api/library/games/220': json(200, {...game, appid: 220, source: 'STEAM'}),
|
||||||
|
});
|
||||||
|
const {router} = renderDetail(-7);
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/library/220'));
|
||||||
|
expect(router.state.historyAction).toBe('REPLACE');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -116,6 +116,9 @@ export default function LoginPage() {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<Link to="/reset-password" className="block text-sm text-muted-foreground hover:underline">
|
||||||
|
{t('LOGIN.FORGOT_PASSWORD')}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={isLoading} className="w-full">
|
<Button type="submit" disabled={isLoading} className="w-full">
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ describe('LoginPage', () => {
|
|||||||
expect(screen.getByText('LOGIN.SESSION_EXPIRED')).toBeTruthy();
|
expect(screen.getByText('LOGIN.SESSION_EXPIRED')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('links to the forgot password page', () => {
|
||||||
|
renderLogin(undefined);
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', {name: 'LOGIN.FORGOT_PASSWORD'}).getAttribute('href')).toBe('/reset-password');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows no message on a normal visit', () => {
|
it('shows no message on a normal visit', () => {
|
||||||
renderLogin(undefined);
|
renderLogin(undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import {useState} from 'react';
|
||||||
|
import {Link, useLocation} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {useForm} from 'react-hook-form';
|
||||||
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
|
import {Loader2} from 'lucide-react';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Input} from '@/components/ui/input';
|
||||||
|
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 {confirmEmailSchema} from '../utils/validation.js';
|
||||||
|
import {clientFetch} from '../utils/api.js';
|
||||||
|
|
||||||
|
const post = (path, body) => clientFetch(`${import.meta.env.VITE_API_URL}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const failureMessage = (t, response) => {
|
||||||
|
if (response.status === 429) {
|
||||||
|
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
||||||
|
return t('RESET_PASSWORD.ERROR_TOO_MANY', {minutes});
|
||||||
|
}
|
||||||
|
return t('RESET_PASSWORD.ERROR_GENERIC');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both ends of "forgot password". Without a fragment: ask for the mail. With the fragment of the mail link: choose
|
||||||
|
* the new password. The token is in the fragment, so no server log sees it, and it is sent only on the click: a mail
|
||||||
|
* scanner that opens the link must not use it up.
|
||||||
|
*/
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const token = useLocation().hash.slice(1);
|
||||||
|
// A new link in the same tab only changes the fragment: key restarts the state.
|
||||||
|
return <ResetPassword key={token} token={token}/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResetPassword({token}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [state, setState] = useState(token ? 'idle' : 'request');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout title={t('RESET_PASSWORD.TITLE')}>
|
||||||
|
{state === 'idle' && (
|
||||||
|
<NewPasswordForm token={token} onDone={() => setState('done')} onInvalid={() => setState('invalid')}/>
|
||||||
|
)}
|
||||||
|
{state === 'done' && <>
|
||||||
|
<FeedbackAlert message={t('RESET_PASSWORD.DONE')} isError={false}/>
|
||||||
|
<Link to="/login" className="block text-center text-sm font-medium text-primary hover:underline">
|
||||||
|
{t('RESET_PASSWORD.TO_LOGIN')}
|
||||||
|
</Link>
|
||||||
|
</>}
|
||||||
|
{state === 'invalid' && <FeedbackAlert message={t('RESET_PASSWORD.INVALID')} isError/>}
|
||||||
|
{(state === 'request' || state === 'invalid') && <RequestForm/>}
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestForm() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [feedback, setFeedback] = useState({message: '', isError: false});
|
||||||
|
|
||||||
|
const send = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setFeedback({message: '', isError: false});
|
||||||
|
try {
|
||||||
|
const response = await post('/api/auth/password-reset', {email});
|
||||||
|
setFeedback(response.ok
|
||||||
|
? {message: t('RESET_PASSWORD.SENT'), isError: false}
|
||||||
|
: {message: failureMessage(t, response), isError: true});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password reset API error:', error);
|
||||||
|
setFeedback({message: t('INFO.ERROR.NETWORK_FAILURE'), isError: true});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={send} className="space-y-3" noValidate>
|
||||||
|
<FormField id="reset-email" label={t('RESET_PASSWORD.EMAIL')} t={t}>
|
||||||
|
<Input id="reset-email" type="email" autoComplete="email" value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}/>
|
||||||
|
</FormField>
|
||||||
|
<Button type="submit" disabled={isLoading} className="w-full">
|
||||||
|
{isLoading && <Loader2 className="animate-spin"/>}
|
||||||
|
{t('RESET_PASSWORD.SEND')}
|
||||||
|
</Button>
|
||||||
|
<FeedbackAlert message={feedback.message} isError={feedback.isError}/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewPasswordForm({token, onDone, onInvalid}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const {register, handleSubmit, formState: {errors, isSubmitting}} = useForm({
|
||||||
|
resolver: zodResolver(confirmEmailSchema),
|
||||||
|
defaultValues: {password: '', passwordConfirmation: ''},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = async ({password}) => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const response = await post('/api/auth/password-reset/confirm', {token, password});
|
||||||
|
if (response.ok) {
|
||||||
|
onDone();
|
||||||
|
} else if (response.status === 410 || response.status === 400) {
|
||||||
|
onInvalid();
|
||||||
|
} else {
|
||||||
|
setError(failureMessage(t, response));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Password reset confirm error:', err);
|
||||||
|
setError(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(submit)} className="space-y-4" noValidate>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('RESET_PASSWORD.INTRO')}</p>
|
||||||
|
<NewPasswordFields register={register} errors={errors} t={t}/>
|
||||||
|
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||||
|
{isSubmitting && <Loader2 className="animate-spin"/>}
|
||||||
|
{t('RESET_PASSWORD.SUBMIT')}
|
||||||
|
</Button>
|
||||||
|
<FeedbackAlert message={error} isError/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import ResetPasswordPage from './ResetPasswordPage.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
const renderPage = (hash = '') => render(
|
||||||
|
<MemoryRouter initialEntries={[`/reset-password${hash}`]}><ResetPasswordPage/></MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
function submitPasswords(password, repeat = password) {
|
||||||
|
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD'), {target: {value: password}});
|
||||||
|
fireEvent.change(screen.getByLabelText('REGISTRATION.PASSWORD_CONFIRMATION'), {target: {value: repeat}});
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'RESET_PASSWORD.SUBMIT'}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitEmail(email = 'a@b.de') {
|
||||||
|
fireEvent.change(screen.getByLabelText('RESET_PASSWORD.EMAIL'), {target: {value: email}});
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'RESET_PASSWORD.SEND'}));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ResetPasswordPage', () => {
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('without a fragment asks for the email and shows the same text for any address', async () => {
|
||||||
|
const mock = fetchRoutes({'POST /api/auth/password-reset': json(204, null)});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
submitEmail();
|
||||||
|
|
||||||
|
expect(await screen.findByText('RESET_PASSWORD.SENT')).toBeTruthy();
|
||||||
|
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({email: 'a@b.de'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the lock text with the minutes on a 429', async () => {
|
||||||
|
fetchRoutes({'POST /api/auth/password-reset': {...json(429, {}), headers: new Headers({'Retry-After': '900'})}});
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
submitEmail();
|
||||||
|
|
||||||
|
expect(await screen.findByText('RESET_PASSWORD.ERROR_TOO_MANY')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('with a fragment sends the token and the new password only on the click and then offers the login', async () => {
|
||||||
|
const mock = fetchRoutes({'POST /api/auth/password-reset/confirm': json(204, null)});
|
||||||
|
renderPage('#tok-123');
|
||||||
|
|
||||||
|
expect(mock).not.toHaveBeenCalled();
|
||||||
|
submitPasswords('secret123');
|
||||||
|
|
||||||
|
expect(await screen.findByText('RESET_PASSWORD.DONE')).toBeTruthy();
|
||||||
|
expect(JSON.parse(mock.mock.calls[0][1].body)).toEqual({token: 'tok-123', password: 'secret123'});
|
||||||
|
expect(screen.getByRole('link', {name: 'RESET_PASSWORD.TO_LOGIN'}).getAttribute('href')).toBe('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a short password', 'short', 'short', 'VALIDATION.PASSWORD_TOO_SHORT'],
|
||||||
|
['a different repeat', 'secret123', 'secret124', 'VALIDATION.PASSWORD_MISMATCH'],
|
||||||
|
])('sends nothing for %s', async (_name, password, repeat, message) => {
|
||||||
|
const mock = fetchRoutes({});
|
||||||
|
renderPage('#tok-123');
|
||||||
|
|
||||||
|
submitPasswords(password, repeat);
|
||||||
|
|
||||||
|
expect(await screen.findByText(message)).toBeTruthy();
|
||||||
|
expect(mock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the invalid text and the email form again for a used or expired link', async () => {
|
||||||
|
fetchRoutes({'POST /api/auth/password-reset/confirm': json(410, {code: 'RESET_INVALID'})});
|
||||||
|
renderPage('#old');
|
||||||
|
|
||||||
|
submitPasswords('secret123');
|
||||||
|
|
||||||
|
expect(await screen.findByText('RESET_PASSWORD.INVALID')).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', {name: 'RESET_PASSWORD.SEND'})).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'})},
|
||||||
|
});
|
||||||
|
renderPage('#tok');
|
||||||
|
|
||||||
|
submitPasswords('secret123');
|
||||||
|
|
||||||
|
expect(await screen.findByText('RESET_PASSWORD.ERROR_TOO_MANY')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
|||||||
import AccountInfoCard from '../components/AccountInfoCard.jsx';
|
import AccountInfoCard from '../components/AccountInfoCard.jsx';
|
||||||
import AppearanceCard from '../components/AppearanceCard.jsx';
|
import AppearanceCard from '../components/AppearanceCard.jsx';
|
||||||
import BuildVersionCard from '../components/BuildVersionCard.jsx';
|
import BuildVersionCard from '../components/BuildVersionCard.jsx';
|
||||||
|
import ChangePasswordCard from '../components/ChangePasswordCard.jsx';
|
||||||
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
|
import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
|
||||||
import PreferencesCard from '../components/PreferencesCard.jsx';
|
import PreferencesCard from '../components/PreferencesCard.jsx';
|
||||||
import ProfileCard from '../components/ProfileCard.jsx';
|
import ProfileCard from '../components/ProfileCard.jsx';
|
||||||
@@ -95,6 +96,8 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<BuildVersionCard/>
|
<BuildVersionCard/>
|
||||||
|
|
||||||
|
<ChangePasswordCard/>
|
||||||
|
|
||||||
<DeleteAccountCard email={profile?.email}/>
|
<DeleteAccountCard email={profile?.email}/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,3 +9,14 @@ export const deleteAccount = (password) =>
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({password}),
|
body: JSON.stringify({password}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/auth/password on Auth: 204 (the other logins end, this one stays), 403 INVALID_PASSWORD, 429 with
|
||||||
|
* Retry-After in seconds.
|
||||||
|
*/
|
||||||
|
export const changePassword = (currentPassword, newPassword) =>
|
||||||
|
apiFetch(`${import.meta.env.VITE_API_URL}/api/auth/password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({currentPassword, newPassword}),
|
||||||
|
});
|
||||||
@@ -42,6 +42,12 @@ export const deleteUnconfirmedUser = (userId) =>
|
|||||||
|
|
||||||
export const deleteUser = (userId) => apiFetch(`${AUTH()}/api/admin/users/${userId}`, {method: 'DELETE'});
|
export const deleteUser = (userId) => apiFetch(`${AUTH()}/api/admin/users/${userId}`, {method: 'DELETE'});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mails the user a link to choose a new password: 204, or 409 NOT_ACTIVE, SELF_ACTION or TARGET_IS_ADMIN.
|
||||||
|
*/
|
||||||
|
export const sendPasswordReset = (userId) =>
|
||||||
|
apiFetch(`${AUTH()}/api/admin/users/${userId}/password-reset`, {method: 'POST'});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "View as": new cookies that act with the role; null goes back to the own role. Under /api/auth, so it needs the
|
* "View as": new cookies that act with the role; null goes back to the own role. Under /api/auth, so it needs the
|
||||||
* refresh cookie and no access token; 403 ROLE_NOT_ALLOWED above the own role.
|
* refresh cookie and no access token; 403 ROLE_NOT_ALLOWED above the own role.
|
||||||
|
|||||||
@@ -41,3 +41,18 @@ export const resetCorrection = (appid, field) => apiFetch(url(appid, field), {me
|
|||||||
export const mergeGame = (appid, otherAppid) =>
|
export const mergeGame = (appid, otherAppid) =>
|
||||||
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/merge`,
|
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/merge`,
|
||||||
{method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({otherAppid})});
|
{method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({otherAppid})});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: 200 `{content: [{appid, name, imageUrl, platforms}], totalPages}` of every game some user has, 403 below
|
||||||
|
* AUDITOR. A blank q means all.
|
||||||
|
*/
|
||||||
|
export const searchCatalog = ({q, page, size}, signal) => {
|
||||||
|
const params = new URLSearchParams({page: String(page), size: String(size)});
|
||||||
|
if (q?.trim()) params.set('q', q.trim());
|
||||||
|
return apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/catalog?${params}`, {signal});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: 200 what GameCorrectionDialog reads (like the game details), 403 below AUDITOR, 404 GAME_NOT_FOUND.
|
||||||
|
*/
|
||||||
|
export const fetchCatalogGame = (appid) => apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/catalog/${appid}`);
|
||||||
@@ -23,6 +23,8 @@ export const MAX_GAME_SEARCH_LENGTH = 100;
|
|||||||
export const MAX_LIBRARY_SEARCH_LENGTH = 100;
|
export const MAX_LIBRARY_SEARCH_LENGTH = 100;
|
||||||
// Core GameCorrectionService.MAX_TEXT
|
// Core GameCorrectionService.MAX_TEXT
|
||||||
export const MAX_CORRECTION_LENGTH = 512;
|
export const MAX_CORRECTION_LENGTH = 512;
|
||||||
|
// Core GameCorrectionService.MAX_TIME_TO_BEAT_MINUTES
|
||||||
|
export const MAX_TIME_TO_BEAT_MINUTES = 60_000;
|
||||||
// Core GameCorrectionService.MAX_GENRES
|
// Core GameCorrectionService.MAX_GENRES
|
||||||
export const MAX_CORRECTION_GENRES = 10;
|
export const MAX_CORRECTION_GENRES = 10;
|
||||||
|
|
||||||
|
|||||||
@@ -47,3 +47,9 @@ export const fetchAuditLog = ({action, page, size}) => {
|
|||||||
if (action) params.set('action', action);
|
if (action) params.set('action', action);
|
||||||
return apiFetch(`${base()}/audit-log?${params}`);
|
return apiFetch(`${base()}/audit-log?${params}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: 204 = undone; 404 MERGE_NOT_FOUND, 409 UNDO_NOT_AVAILABLE / NEWER_MERGE, 410 UNDO_EXPIRED (body `{code}`),
|
||||||
|
* 403 below AUDITOR.
|
||||||
|
*/
|
||||||
|
export const undoMerge = (mergeId) => apiFetch(`${base()}/merges/${mergeId}/undo`, {method: 'POST'});
|
||||||
@@ -30,8 +30,9 @@ export const registrationSchema = z.object({
|
|||||||
displayName: displayNameField,
|
displayName: displayNameField,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const confirmEmailSchema = z
|
const newPasswordSchema = (fields = {}) => z
|
||||||
.object({
|
.object({
|
||||||
|
...fields,
|
||||||
password: passwordField,
|
password: passwordField,
|
||||||
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
|
passwordConfirmation: z.string().min(1, 'VALIDATION.PASSWORD_CONFIRMATION_REQUIRED'),
|
||||||
})
|
})
|
||||||
@@ -40,6 +41,12 @@ export const confirmEmailSchema = z
|
|||||||
message: 'VALIDATION.PASSWORD_MISMATCH',
|
message: 'VALIDATION.PASSWORD_MISMATCH',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const confirmEmailSchema = newPasswordSchema();
|
||||||
|
|
||||||
|
export const changePasswordSchema = newPasswordSchema({
|
||||||
|
currentPassword: z.string().min(1, 'VALIDATION.CURRENT_PASSWORD_REQUIRED'),
|
||||||
|
});
|
||||||
|
|
||||||
export const settingsSchema = z.object({
|
export const settingsSchema = z.object({
|
||||||
displayName: displayNameField,
|
displayName: displayNameField,
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {describe, expect, it} from 'vitest';
|
import {describe, expect, it} from 'vitest';
|
||||||
import {confirmEmailSchema, registrationSchema, settingsSchema} from './validation.js';
|
import {changePasswordSchema, confirmEmailSchema, registrationSchema, settingsSchema} from './validation.js';
|
||||||
|
|
||||||
const settings = () => ({useDarkTheme: true, language: 'de'});
|
const settings = () => ({useDarkTheme: true, language: 'de'});
|
||||||
|
|
||||||
@@ -46,6 +46,25 @@ describe('confirm page password', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('changePasswordSchema', () => {
|
||||||
|
const valid = {currentPassword: 'old', password: 'password1', passwordConfirmation: 'password1'};
|
||||||
|
|
||||||
|
it('takes a current password and a good new one', () => {
|
||||||
|
expect(changePasswordSchema.safeParse(valid).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires the current password', () => {
|
||||||
|
expect(changePasswordSchema.safeParse({...valid, currentPassword: ''}).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the rules of the new password and the repeat', () => {
|
||||||
|
expect(changePasswordSchema.safeParse({...valid, password: 'short', passwordConfirmation: 'short'}).success).toBe(false);
|
||||||
|
expect(changePasswordSchema.safeParse({...valid, passwordConfirmation: 'password2'}).success).toBe(false);
|
||||||
|
const long = 'é'.repeat(37);
|
||||||
|
expect(changePasswordSchema.safeParse({...valid, password: long, passwordConfirmation: long}).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('registration', () => {
|
describe('registration', () => {
|
||||||
it('has no password field', () => {
|
it('has no password field', () => {
|
||||||
expect(registrationSchema.parse({email: 'a@b.de', password: 'password1'})).not.toHaveProperty('password');
|
expect(registrationSchema.parse({email: 'a@b.de', password: 'password1'})).not.toHaveProperty('password');
|
||||||
|
|||||||
Reference in new issue
Block a user