From ca75e6a530edfbfcf1f0867af30bb79773e1d18a Mon Sep 17 00:00:00 2001 From: lobo Date: Sat, 26 Sep 2026 11:20:04 +0200 Subject: [PATCH] feat(settings): link the Steam account through Steam A Steam card in the settings links, verifies or unlinks the Steam account through "Sign in through Steam". After the way back the page shows the outcome and refreshes the session, so Core gets the new Steam ID at once. Registration and settings no longer ask for a typed Steam ID. --- README.md | 5 + public/i18n/de.json | 26 +++-- public/i18n/en.json | 26 +++-- src/components/SteamAccountCard.jsx | 98 ++++++++++++++++++ src/hooks/useSteamProfile.js | 2 +- src/pages/RegistrationPage.jsx | 13 --- src/pages/RegistrationPage.test.jsx | 11 +- src/pages/SettingsPage.jsx | 61 +++++++---- src/pages/SettingsPage.steam.test.jsx | 114 +++++++++++++++++++++ src/pages/SettingsPage.steamToast.test.jsx | 56 ++++++++++ src/utils/steamLink.js | 26 +++++ src/utils/validation.js | 9 -- src/utils/validation.test.js | 32 ++---- 13 files changed, 393 insertions(+), 86 deletions(-) create mode 100644 src/components/SteamAccountCard.jsx create mode 100644 src/pages/SettingsPage.steam.test.jsx create mode 100644 src/pages/SettingsPage.steamToast.test.jsx create mode 100644 src/utils/steamLink.js diff --git a/README.md b/README.md index e870517..0ec1153 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ To Be Played is split into three repositories: game still in the backlog, and one to set the status to 100 % when every achievement is unlocked. - **Wishlist** with store price and discount. +- **Steam account** linked in the settings with "Sign in through Steam": the user signs in on Steam and Auth gets + the proved Steam ID, never a typed-in one. A Steam ID typed in before shows as "not verified" with a button to + verify it; unlinking is possible. - **Public profile** at `/u/`, opt-in in the settings, with perfect games and average completion. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. Saved in Auth, so it follows you to every device. @@ -68,6 +71,8 @@ flowchart LR Core -->|"OAuth token"| Twitch["Twitch"] Core --> Speedrun["speedrun.com API"] Browser -.->|"game art"| Images["Steam and IGDB image hosts"] + Browser -.->|"Sign in through Steam"| SteamLogin["steamcommunity.com (OpenID)"] + Auth -->|"check_authentication"| SteamLogin ``` - nginx serves the built app. It has no backend of its own: the browser calls Auth and Core directly diff --git a/public/i18n/de.json b/public/i18n/de.json index a9456ed..9ce340d 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -23,14 +23,12 @@ "PASSWORD": "Passwort", "PASSWORD_CONFIRMATION": "Passwort bestätigen", "INVITE_CODE": "Einladungscode", - "STEAM_ID": "Steam-ID", "SUBMIT": "Konto erstellen", "SUCCESS": "Konto erstellt. Weiterleitung zum Login …", "HAVE_ACCOUNT": "Bereits ein Konto?", "ERROR_GENERIC": "Registrierung fehlgeschlagen. Bitte erneut versuchen.", "ERROR_EMAIL_IN_USE": "Diese Email-Adresse ist bereits registriert.", "ERROR_INVALID_INVITE_CODE": "Der Einladungscode ist ungültig oder wurde bereits verwendet.", - "ERROR_STEAM_ID_IN_USE": "Diese Steam-ID ist bereits mit einem anderen Konto verknüpft.", "DISPLAY_NAME": "Anzeigename (optional)", "ERROR_DISPLAY_NAME_IN_USE": "Dieser Anzeigename ist schon vergeben.", "ERROR_TOO_MANY_ATTEMPTS": "Zu viele fehlgeschlagene Versuche. Bitte in {{minutes}} Minuten erneut versuchen." @@ -51,8 +49,6 @@ "EN": "Englisch", "DE": "Deutsch" }, - "STEAM_ID": "Steam-ID", - "SAVE_STEAM_ID": "Steam-ID speichern", "SAVED": "Einstellungen gespeichert.", "ERROR_SAVE": "Einstellungen konnten nicht gespeichert werden.", "PROFILE": "Profil", @@ -89,7 +85,23 @@ "BACKGROUND_REMOVE": "Hintergrund entfernen", "BANNER": "Dashboard-Banner", "BANNER_HINT": "Zeigt dein meistgespieltes Spiel der letzten zwei Wochen. Öffne ein Spiel und wähle „Als Banner“, um eins festzulegen.", - "BANNER_AUTO": "Wieder automatisch" + "BANNER_AUTO": "Wieder automatisch", + "STEAM_LINK": { + "LINK": "Steam-Konto verknüpfen", + "VERIFY": "Mit Steam bestätigen", + "CHANGE": "Anderes Steam-Konto verknüpfen", + "UNLINK": "Verknüpfung lösen", + "VERIFIED": "Bestätigt", + "NOT_VERIFIED": "Nicht bestätigt", + "NONE": "Noch kein Steam-Konto verknüpft. Du meldest dich bei Steam an, und Steam nennt uns deine Steam-ID; dein Steam-Passwort sehen wir nie.", + "VERIFY_HINT": "Diese Steam-ID wurde eingetippt und ist noch nicht bestätigt. Bestätige sie mit Steam, sonst übernimmt sie ein anderes Konto, das sie bestätigt.", + "LINKED": "Steam-Konto verknüpft.", + "IN_USE": "Dieses Steam-Konto ist schon mit einem anderen Konto verknüpft.", + "CANCELLED": "Steam-Verknüpfung abgebrochen.", + "FAILED": "Steam konnte die Verknüpfung nicht bestätigen. Bitte versuch es noch einmal.", + "ERROR": "Die Steam-Verknüpfung konnte nicht starten.", + "UNLINKED": "Steam-Verknüpfung gelöst." + } }, "VALIDATION": { "EMAIL_REQUIRED": "Email ist erforderlich.", @@ -98,8 +110,6 @@ "PASSWORD_CONFIRMATION_REQUIRED": "Bitte Passwort bestätigen.", "PASSWORD_MISMATCH": "Die Passwörter stimmen nicht überein.", "INVITE_CODE_REQUIRED": "Einladungscode ist erforderlich.", - "STEAM_ID_REQUIRED": "Steam-ID ist erforderlich.", - "STEAM_ID_INVALID": "Steam-ID muss eine positive Zahl sein.", "LANGUAGE_INVALID": "Nicht unterstützte Sprache.", "DISPLAY_NAME_INVALID": "3–24 Zeichen: Buchstaben, Zahlen, Leerzeichen, _ . - (kein Leerzeichen am Anfang oder Ende)." }, @@ -113,7 +123,7 @@ "LOADING": "Lade Dashboard ...", "HELLO": "Hallo", "STEAM_GAMES": "Steam Spiele", - "NO_STEAM_ID": "Keine Steam-ID hinterlegt – jetzt in den Einstellungen eintragen", + "NO_STEAM_ID": "Noch kein Steam-Konto verknüpft – verknüpfe es in den Einstellungen", "MOST_PLAYED": "Meistgespielt", "SHOW_ALL": "Alle anzeigen", "TO_LIBRARY": "Zur Bibliothek", diff --git a/public/i18n/en.json b/public/i18n/en.json index 3f8e879..787d8d8 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -23,14 +23,12 @@ "PASSWORD": "Password", "PASSWORD_CONFIRMATION": "Confirm password", "INVITE_CODE": "Invite code", - "STEAM_ID": "Steam ID", "SUBMIT": "Create account", "SUCCESS": "Account created. Redirecting to login …", "HAVE_ACCOUNT": "Already have an account?", "ERROR_GENERIC": "Registration failed. Please try again.", "ERROR_EMAIL_IN_USE": "This email address is already registered.", "ERROR_INVALID_INVITE_CODE": "The invite code is invalid or has already been used.", - "ERROR_STEAM_ID_IN_USE": "This Steam ID is already linked to another account.", "DISPLAY_NAME": "Display name (optional)", "ERROR_DISPLAY_NAME_IN_USE": "This display name is already taken.", "ERROR_TOO_MANY_ATTEMPTS": "Too many failed attempts. Please try again in {{minutes}} minutes." @@ -51,8 +49,6 @@ "EN": "English", "DE": "German" }, - "STEAM_ID": "Steam ID", - "SAVE_STEAM_ID": "Save Steam ID", "SAVED": "Settings saved.", "ERROR_SAVE": "Could not save settings.", "PROFILE": "Profile", @@ -89,7 +85,23 @@ "BACKGROUND_REMOVE": "Remove background", "BANNER": "Dashboard banner", "BANNER_HINT": "Shows your most played game of the last two weeks. Open a game and choose \"Use as banner\" to pick one.", - "BANNER_AUTO": "Back to automatic" + "BANNER_AUTO": "Back to automatic", + "STEAM_LINK": { + "LINK": "Link Steam account", + "VERIFY": "Verify with Steam", + "CHANGE": "Link another Steam account", + "UNLINK": "Unlink", + "VERIFIED": "Verified", + "NOT_VERIFIED": "Not verified", + "NONE": "No Steam account linked yet. You sign in on Steam, and Steam tells us your Steam ID; we never see your Steam password.", + "VERIFY_HINT": "This Steam ID was typed in and is not proved yet. Verify it with Steam, or another account that proves it takes it over.", + "LINKED": "Steam account linked.", + "IN_USE": "This Steam account is already linked to another account.", + "CANCELLED": "Steam link cancelled.", + "FAILED": "Steam could not confirm the link. Please try again.", + "ERROR": "Could not start the Steam link.", + "UNLINKED": "Steam account unlinked." + } }, "VALIDATION": { "EMAIL_REQUIRED": "Email is required.", @@ -98,8 +110,6 @@ "PASSWORD_CONFIRMATION_REQUIRED": "Please confirm your password.", "PASSWORD_MISMATCH": "Passwords do not match.", "INVITE_CODE_REQUIRED": "Invite code is required.", - "STEAM_ID_REQUIRED": "Steam ID is required.", - "STEAM_ID_INVALID": "Steam ID must be a positive number.", "LANGUAGE_INVALID": "Unsupported language.", "DISPLAY_NAME_INVALID": "3–24 characters: letters, digits, spaces, _ . - (no space at the start or end)." }, @@ -113,7 +123,7 @@ "LOADING": "Loading dashboard ...", "HELLO": "Hello", "STEAM_GAMES": "Steam Games", - "NO_STEAM_ID": "No Steam ID saved yet – add it in the settings", + "NO_STEAM_ID": "No Steam account linked yet – link it in the settings", "MOST_PLAYED": "Most played", "SHOW_ALL": "Show all", "TO_LIBRARY": "Go to library", diff --git a/src/components/SteamAccountCard.jsx b/src/components/SteamAccountCard.jsx new file mode 100644 index 0000000..8b4c31e --- /dev/null +++ b/src/components/SteamAccountCard.jsx @@ -0,0 +1,98 @@ +import {useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {BadgeCheck, CircleAlert, Gamepad2, Link2, Loader2, Unlink} from 'lucide-react'; +import {toast} from 'sonner'; + +import {Badge} from '@/components/ui/badge'; +import {Button} from '@/components/ui/button'; +import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import useSessionExpired from '../hooks/useSessionExpired.js'; +import {markSession} from '../utils/auth.js'; +import {startSteamLink, unlinkSteam} from '../utils/steamLink.js'; + +/** + * The linked Steam account. A Steam ID comes only from "Sign in through Steam"; one typed in before that is shown + * as not verified. + */ +export default function SteamAccountCard({steamId, verified, onUnlinked}) { + const {t} = useTranslation(); + const expireSession = useSessionExpired(); + const [busy, setBusy] = useState(false); + + const link = async () => { + setBusy(true); + try { + const response = await startSteamLink(); + if (response.ok) return; // on the way to Steam: the button stays busy + if (response.status === 401) { + expireSession(); + } else { + toast.error(t('SETTINGS.STEAM_LINK.ERROR')); + } + } catch (err) { + console.error('Steam link error:', err); + toast.error(t('SETTINGS.STEAM_LINK.ERROR')); + } + setBusy(false); + }; + + const unlink = async () => { + setBusy(true); + try { + const response = await unlinkSteam(); + if (response.status === 401) { + expireSession(); + return; + } + if (!response.ok) { + toast.error(t('SETTINGS.STEAM_LINK.ERROR')); + return; + } + // The new access cookie has no Steam ID; the new stamp makes the header load again. + markSession(); + toast.success(t('SETTINGS.STEAM_LINK.UNLINKED')); + onUnlinked(); + } catch (err) { + console.error('Steam unlink error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + return ( + + + {t('SETTINGS.STEAM')} + + + {steamId ? ( +
+ {steamId} + {verified ? ( + {t('SETTINGS.STEAM_LINK.VERIFIED')} + ) : ( + {t('SETTINGS.STEAM_LINK.NOT_VERIFIED')} + )} +
+ ) : ( +

{t('SETTINGS.STEAM_LINK.NONE')}

+ )} + {steamId && !verified && ( +

{t('SETTINGS.STEAM_LINK.VERIFY_HINT')}

+ )} +
+ + {steamId && ( + + )} +
+
+
+ ); +} diff --git a/src/hooks/useSteamProfile.js b/src/hooks/useSteamProfile.js index d9157e4..a81e62f 100644 --- a/src/hooks/useSteamProfile.js +++ b/src/hooks/useSteamProfile.js @@ -5,7 +5,7 @@ import {sessionStamp} from '../utils/auth.js'; /** * The user's Steam name and avatar from Core, or null while loading, without a Steam ID or on any error. Only * decoration, so a failed call shows the default icon and never ends the session. Loads again after a new login - * or a settings save (see sessionStamp) on the next render, e.g. the next navigation. + * or a Steam link or unlink (see sessionStamp) on the next render, e.g. the next navigation. */ export default function useSteamProfile(enabled) { const [profile, setProfile] = useState(null); diff --git a/src/pages/RegistrationPage.jsx b/src/pages/RegistrationPage.jsx index 70e8f89..ead87e3 100644 --- a/src/pages/RegistrationPage.jsx +++ b/src/pages/RegistrationPage.jsx @@ -17,7 +17,6 @@ import {clientFetch} from '../utils/api.js'; const BACKEND_ERROR_KEY = { EMAIL_ALREADY_IN_USE: 'REGISTRATION.ERROR_EMAIL_IN_USE', INVALID_INVITATION_CODE: 'REGISTRATION.ERROR_INVALID_INVITE_CODE', - STEAM_ID_ALREADY_IN_USE: 'REGISTRATION.ERROR_STEAM_ID_IN_USE', DISPLAY_NAME_ALREADY_IN_USE: 'REGISTRATION.ERROR_DISPLAY_NAME_IN_USE', TOO_MANY_ATTEMPTS: 'REGISTRATION.ERROR_TOO_MANY_ATTEMPTS', }; @@ -38,7 +37,6 @@ export default function RegistrationPage() { password: '', passwordConfirmation: '', inviteCode: '', - steamId: '', displayName: '', }, }); @@ -54,7 +52,6 @@ export default function RegistrationPage() { email: values.email, password: values.password, inviteCode: values.inviteCode, - steamId: values.steamId, displayName: values.displayName, }), }); @@ -140,16 +137,6 @@ export default function RegistrationPage() { /> - - - -