From 599b0835916139ec7ec22ed0b2f3f043c9d75ecb Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 7 Oct 2026 13:30:48 +0200 Subject: [PATCH] feat(psn): add PlayStation link card and PSN import Settings card to link and unlink with the NPSSO, shows the expiry date. Import page gets a PSN source; blocked when the link has ended. English and German texts. --- public/i18n/de.json | 29 +++++ public/i18n/en.json | 29 +++++ src/components/PsnAccountCard.jsx | 136 +++++++++++++++++++++ src/components/PsnAccountCard.test.jsx | 115 +++++++++++++++++ src/components/import/ImportInput.jsx | 56 ++++++++- src/hooks/usePsnAccount.js | 28 +++++ src/hooks/useStartPsnImport.js | 41 +++++++ src/pages/GameDetail.jsx | 2 +- src/pages/GameDetail.test.jsx | 9 ++ src/pages/ImportPage.jsx | 7 +- src/pages/ImportPage.psn.test.jsx | 102 ++++++++++++++++ src/pages/SettingsPage.jsx | 3 + src/pages/SettingsPage.steam.test.jsx | 1 + src/pages/SettingsPage.steamToast.test.jsx | 1 + src/pages/SettingsPage.test.jsx | 2 + src/utils/platforms.js | 4 +- src/utils/platforms.test.js | 1 + src/utils/psn.js | 19 +++ 18 files changed, 573 insertions(+), 12 deletions(-) create mode 100644 src/components/PsnAccountCard.jsx create mode 100644 src/components/PsnAccountCard.test.jsx create mode 100644 src/hooks/usePsnAccount.js create mode 100644 src/hooks/useStartPsnImport.js create mode 100644 src/pages/ImportPage.psn.test.jsx create mode 100644 src/utils/psn.js diff --git a/public/i18n/de.json b/public/i18n/de.json index 5ddc77a..080d69d 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -143,6 +143,27 @@ "ERROR": "Die Steam-Verknüpfung konnte nicht starten.", "UNLINKED": "Steam-Verknüpfung gelöst." }, + "PSN": { + "TITLE": "PlayStation", + "OFF": "Die PlayStation-Verknüpfung ist auf diesem Server nicht eingerichtet.", + "STEPS_1": "Melde dich im Browser auf playstation.com an.", + "STEPS_2": "Öffne im selben Browser diese Adresse:", + "STEPS_3": "Kopiere den Wert von „npsso“ und füge ihn unten ein.", + "WHAT_IT_GIVES": "Der NPSSO-Code ist deine PlayStation-Anmeldesitzung. Wer ihn hat, kann PlayStation-Seiten als du nutzen. Wir tauschen ihn sofort gegen ein begrenztes Token (Umfang der PlayStation-App) ein, speichern nur dieses Token verschlüsselt und verwerfen den Code. Du kannst das jederzeit beenden: hier trennen oder in den PlayStation-Kontoeinstellungen auf allen Geräten abmelden. Das nutzt dieselbe inoffizielle Schnittstelle wie Trophäen-Seiten von Drittanbietern; Sony unterstützt sie nicht und kann sie sperren.", + "NPSSO_LABEL": "NPSSO-Code", + "LINK": "PlayStation-Konto verknüpfen", + "LINKED": "PlayStation-Konto verknüpft.", + "LINKED_SINCE": "Verknüpft seit {{date}}", + "EXPIRES_ON": "Verknüpfung endet am {{date}}", + "EXPIRED": "Die Verknüpfung ist beendet. Füge einen neuen NPSSO-Code ein, um sie zu erneuern.", + "IMPORT": "Gespielte Spiele importieren", + "UNLINK": "Trennen", + "UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.", + "INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.", + "UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.", + "BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es erneut.", + "ERROR": "Mit der PlayStation-Verknüpfung ist etwas schiefgelaufen." + }, "VERSION": "Version", "VERSION_UNKNOWN": "unbekannt", "CHANGE_PASSWORD": { @@ -1039,6 +1060,13 @@ "START": "Spiele suchen", "START_FAILED": "Der Import konnte nicht starten.", "RUNNING": "Ein anderer Import wird noch gesucht. Warte, bis er fertig ist.", + "PSN_HINT": "Importiert deine gespielten PS4- und PS5-Titel mit Spielzeit. Titel, die du gekauft, aber nie gestartet hast, stehen nicht in der Liste.", + "PSN_NOT_LINKED": "Verknüpfe zuerst dein PlayStation-Konto in den Einstellungen.", + "PSN_RELINK": "Die PlayStation-Verknüpfung ist beendet. Füge in den Einstellungen einen neuen NPSSO-Code ein.", + "PSN_NO_TITLES": "PlayStation listet für dieses Konto keine gespielten Titel.", + "PSN_UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.", + "PSN_BUSY": "Eine andere PlayStation-Anfrage von dir läuft noch. Warte einen Moment und versuche es erneut.", + "PSN_SETTINGS": "Einstellungen öffnen", "LOAD_FAILED": "Der Import konnte nicht geladen werden.", "MATCHING": "Spiele werden gesucht: {{matched}} / {{total}}", "MATCHING_HINT": "Du kannst die Seite verlassen und später wiederkommen.", @@ -1102,6 +1130,7 @@ "SOURCE_HLTB": "HowLongToBeat", "SOURCE_PLAYNITE": "Playnite", "SOURCE_BOOKMARKLET": "Store-Seite", + "SOURCE_PSN": "PlayStation", "FORMAT_READ": "Gelesen als: {{format}}", "STORE_HINT": "Zieh den Button unten in deine Lesezeichenleiste. Öffne deine Spielebibliothek auf einer dieser Seiten, melde dich an und klick das Lesezeichen: {{sites}}. Es liest dort die Liste und bringt sie hierher. Die Liste geht nur durch deinen Browser.", "STORE_LINK": "To Be Played Import", diff --git a/public/i18n/en.json b/public/i18n/en.json index 02d926a..74608cb 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -143,6 +143,27 @@ "ERROR": "Could not start the Steam link.", "UNLINKED": "Steam account unlinked." }, + "PSN": { + "TITLE": "PlayStation", + "OFF": "The PlayStation link is not set up on this server.", + "STEPS_1": "Sign in at playstation.com in your browser.", + "STEPS_2": "In the same browser, open this address:", + "STEPS_3": "Copy the value of \"npsso\" and paste it below.", + "WHAT_IT_GIVES": "The NPSSO code is your PlayStation sign-in session. Whoever has it can use PlayStation sites as you. We exchange it at once for a limited token (PlayStation app scope), store only that token encrypted, and drop the code. You can end it any time: unlink here, or sign out of all devices in your PlayStation account settings. This uses the same unofficial interface as third-party trophy sites; Sony does not support it and may block it.", + "NPSSO_LABEL": "NPSSO code", + "LINK": "Link PlayStation account", + "LINKED": "PlayStation account linked.", + "LINKED_SINCE": "Linked since {{date}}", + "EXPIRES_ON": "Link ends on {{date}}", + "EXPIRED": "The link has ended. Paste a new NPSSO code to link again.", + "IMPORT": "Import played games", + "UNLINK": "Unlink", + "UNLINKED": "PlayStation account unlinked. Imported games stay in your library.", + "INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.", + "UNAVAILABLE": "PlayStation did not answer. Please try again later.", + "BUSY": "Another PlayStation request of yours is still running. Wait a moment and try again.", + "ERROR": "Something went wrong with the PlayStation link." + }, "VERSION": "Version", "VERSION_UNKNOWN": "unknown", "CHANGE_PASSWORD": { @@ -1039,6 +1060,13 @@ "START": "Search games", "START_FAILED": "Could not start the import.", "RUNNING": "Another import is still being matched. Wait until it is done.", + "PSN_HINT": "Imports your played PS4 and PS5 titles with playtime. Titles you bought but never started are not listed.", + "PSN_NOT_LINKED": "Link your PlayStation account in the settings first.", + "PSN_RELINK": "The PlayStation link has ended. Paste a new NPSSO code in the settings.", + "PSN_NO_TITLES": "PlayStation lists no played titles for this account.", + "PSN_UNAVAILABLE": "PlayStation did not answer. Please try again later.", + "PSN_BUSY": "Another PlayStation request of yours is still running. Wait a moment and try again.", + "PSN_SETTINGS": "Open settings", "LOAD_FAILED": "Could not load the import.", "MATCHING": "Searching games: {{matched}} / {{total}}", "MATCHING_HINT": "You can leave this page and come back later.", @@ -1102,6 +1130,7 @@ "SOURCE_HLTB": "HowLongToBeat", "SOURCE_PLAYNITE": "Playnite", "SOURCE_BOOKMARKLET": "Store page", + "SOURCE_PSN": "PlayStation", "FORMAT_READ": "Read as: {{format}}", "STORE_HINT": "Drag the button below to your bookmarks bar. Open your game library on one of these sites, log in, and click the bookmark: {{sites}}. It reads the list there and brings it back here. The list goes only through your browser.", "STORE_LINK": "To Be Played import", diff --git a/src/components/PsnAccountCard.jsx b/src/components/PsnAccountCard.jsx new file mode 100644 index 0000000..bb4baf5 --- /dev/null +++ b/src/components/PsnAccountCard.jsx @@ -0,0 +1,136 @@ +import {useState} from 'react'; +import {Link} from 'react-router-dom'; +import {useTranslation} from 'react-i18next'; +import {Gamepad, Link2, Loader2, Unlink, Upload} 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 usePsnAccount from '../hooks/usePsnAccount.js'; +import {formatDate} from '../utils/format.js'; +import {isExpired, linkPsn, NPSSO_PAGE_URL, unlinkPsn} from '../utils/psn.js'; + +/** + * The linked PlayStation account. The user pastes an NPSSO once; Core keeps only an encrypted token for about 10 days, + * so the card shows when the link ends. + */ +export default function PsnAccountCard() { + const {t, i18n} = useTranslation(); + const {account, setAccount, failed} = usePsnAccount(); + const [npsso, setNpsso] = useState(''); + const [busy, setBusy] = useState(false); + + const link = async (event) => { + event.preventDefault(); + setBusy(true); + try { + const response = await linkPsn(npsso.trim()); + if (response.status === 401) return; + if (response.ok) { + setAccount(await response.json()); + toast.success(t('SETTINGS.PSN.LINKED')); + } else if (response.status === 400) { + toast.error(t('SETTINGS.PSN.INVALID')); + } else if (response.status === 429) { + toast.error(t('SETTINGS.PSN.BUSY')); + } else if (response.status === 502) { + toast.error(t('SETTINGS.PSN.UNAVAILABLE')); + } else if (response.status === 503) { + setAccount((old) => ({...old, enabled: false})); + } else { + toast.error(t('SETTINGS.PSN.ERROR')); + } + } catch (err) { + console.error('PSN link error:', err); + toast.error(t('SETTINGS.PSN.ERROR')); + } finally { + // The NPSSO is a session secret: it does not stay in the page after any answer. + setNpsso(''); + setBusy(false); + } + }; + + const unlink = async () => { + setBusy(true); + try { + const response = await unlinkPsn(); + if (response.status === 401) return; + if (!response.ok) { + toast.error(t('SETTINGS.PSN.ERROR')); + return; + } + setAccount((old) => ({...old, linked: false, linkedAt: null, expiresAt: null})); + toast.success(t('SETTINGS.PSN.UNLINKED')); + } catch (err) { + console.error('PSN unlink error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + const expired = isExpired(account); + const form = ; + + return ( + + + {t('SETTINGS.PSN.TITLE')} + + + {failed ? ( +

{t('SETTINGS.PSN.ERROR')}

+ ) : !account ? ( + + ) : !account.enabled ? ( +

{t('SETTINGS.PSN.OFF')}

+ ) : !account.linked ? form : ( + <> +
+

{t('SETTINGS.PSN.LINKED_SINCE', {date: formatDate(account.linkedAt, i18n.language, {dateStyle: 'medium'})})}

+

+ {t('SETTINGS.PSN.EXPIRES_ON', {date: formatDate(account.expiresAt, i18n.language, {dateStyle: 'medium'})})} + {expired && <> · {t('SETTINGS.PSN.EXPIRED')}} +

+
+ {expired && form} +
+ {!expired && ( + + )} + +
+ + )} +
+
+ ); +} + +function NpssoForm({npsso, onChange, onSubmit, busy}) { + const {t} = useTranslation(); + + return ( +
+
    +
  1. {t('SETTINGS.PSN.STEPS_1')}
  2. +
  3. + {t('SETTINGS.PSN.STEPS_2')}{' '} + {NPSSO_PAGE_URL} +
  4. +
  5. {t('SETTINGS.PSN.STEPS_3')}
  6. +
+

{t('SETTINGS.PSN.WHAT_IT_GIVES')}

+ onChange(e.target.value)}/> + +
+ ); +} diff --git a/src/components/PsnAccountCard.test.jsx b/src/components/PsnAccountCard.test.jsx new file mode 100644 index 0000000..8f4b82b --- /dev/null +++ b/src/components/PsnAccountCard.test.jsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react'; +import {toast} from 'sonner'; +import PsnAccountCard from './PsnAccountCard.jsx'; +import {fetchRoutes, json} from '../test/fetchRoutes.js'; +import {renderWithProviders} from '../test/renderWithProviders.jsx'; + +vi.mock('react-i18next', () => import('../test/i18n-mock.js')); +vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn(), info: vi.fn()}})); + +const NPSSO = 'npsso-secret-value'; +const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}; +const linkedAccount = (expiresAt) => ({enabled: true, linked: true, linkedAt: '2026-10-01T10:00:00Z', expiresAt}); +const inDays = (days) => new Date(Date.now() + days * 86_400_000).toISOString(); + +const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method); + +function renderCard(routes) { + fetchRoutes({ + 'GET /api/users/me': json(200, {settings: {}}), + 'GET /api/library/psn-account': json(200, {enabled: true, linked: false}), + ...routes, + }); + return renderWithProviders(, {path: '/settings', entries: ['/settings']}); +} + +const paste = async (value = NPSSO) => { + const input = await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL'); + fireEvent.change(input, {target: {value}}); + fireEvent.click(screen.getByRole('button', {name: /SETTINGS.PSN.LINK/})); + return input; +}; + +beforeEach(() => localStorage.setItem('tbp_session', '1')); + +afterEach(() => { + cleanup(); + localStorage.clear(); + vi.clearAllMocks(); +}); + +describe('PsnAccountCard', () => { + it('says the feature is off when Core has no key', async () => { + renderCard({'GET /api/library/psn-account': json(200, {enabled: false, linked: false})}); + + expect(await screen.findByText('SETTINGS.PSN.OFF')).toBeTruthy(); + expect(screen.queryByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeNull(); + }); + + it('explains the NPSSO and takes it in a password field', async () => { + renderCard({}); + + const input = await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL'); + + expect(input.getAttribute('type')).toBe('password'); + expect(input.getAttribute('autocomplete')).toBe('off'); + expect(screen.getByText('SETTINGS.PSN.WHAT_IT_GIVES')).toBeTruthy(); + expect(screen.getByRole('link', {name: /ssocookie/}).getAttribute('rel')).toContain('noopener'); + }); + + it('links, shows both dates and never shows the NPSSO again', async () => { + renderCard({'POST /api/library/psn-account': json(200, linkedAccount(inDays(10)))}); + + const input = await paste(); + + expect(await screen.findByText('SETTINGS.PSN.LINKED_SINCE')).toBeTruthy(); + expect(screen.getByText('SETTINGS.PSN.EXPIRES_ON')).toBeTruthy(); + expect(screen.getByRole('link', {name: /SETTINGS.PSN.IMPORT/}).getAttribute('href')).toBe('/import?source=PSN'); + expect(JSON.parse(calls('POST')[0][1].body)).toEqual({npsso: NPSSO}); + expect(document.body.innerHTML).not.toContain(NPSSO); + expect(input.isConnected && input.value).toBeFalsy(); + expect(toast.success).toHaveBeenCalledWith('SETTINGS.PSN.LINKED'); + }); + + it('shows a toast for a refused NPSSO and clears the input', async () => { + renderCard({'POST /api/library/psn-account': json(400, {code: 'NPSSO_INVALID'})}); + + const input = await paste(); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.PSN.INVALID')); + expect(input.value).toBe(''); + expect(document.body.innerHTML).not.toContain(NPSSO); + }); + + it.each([[429, 'SETTINGS.PSN.BUSY'], [502, 'SETTINGS.PSN.UNAVAILABLE'], [500, 'SETTINGS.PSN.ERROR']])('shows a toast for status %i', async (status, key) => { + renderCard({'POST /api/library/psn-account': json(status, {})}); + + await paste(); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(key)); + }); + + it('marks an ended link in red and asks for a new NPSSO', async () => { + renderCard({'GET /api/library/psn-account': json(200, linkedAccount(inDays(-1)))}); + + expect(await screen.findByText(/SETTINGS.PSN.EXPIRED/)).toBeTruthy(); + expect(screen.getByText(/SETTINGS.PSN.EXPIRES_ON/).className).toContain('text-destructive'); + expect(screen.getByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy(); + expect(screen.queryByRole('link', {name: /SETTINGS.PSN.IMPORT/})).toBeNull(); + }); + + it('unlinks and shows the link form again', async () => { + renderCard({ + 'GET /api/library/psn-account': json(200, linkedAccount(inDays(5))), + 'DELETE /api/library/psn-account': noBody, + }); + + fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.PSN.UNLINK/})); + + expect(await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy(); + expect(toast.success).toHaveBeenCalledWith('SETTINGS.PSN.UNLINKED'); + expect(calls('DELETE')).toHaveLength(1); + }); +}); diff --git a/src/components/import/ImportInput.jsx b/src/components/import/ImportInput.jsx index ab31b02..84fcf61 100644 --- a/src/components/import/ImportInput.jsx +++ b/src/components/import/ImportInput.jsx @@ -1,5 +1,6 @@ import {useState} from 'react'; import {useTranslation} from 'react-i18next'; +import {Link} from 'react-router-dom'; import {Copy, Loader2, Search} from 'lucide-react'; import {toast} from 'sonner'; @@ -7,10 +8,13 @@ import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Input} from '@/components/ui/input'; import {Textarea} from '@/components/ui/textarea'; +import usePsnAccount from '../../hooks/usePsnAccount.js'; import useStartImport from '../../hooks/useStartImport.js'; +import useStartPsnImport from '../../hooks/useStartPsnImport.js'; import {readImportFile} from '../../utils/importFormats.js'; import {parseTextList} from '../../utils/imports.js'; import {MAX_IMPORT_ROWS} from '../../utils/limits.js'; +import {isExpired} from '../../utils/psn.js'; import {bookmarkletHref, consoleScript} from '../../utils/storeList.js'; const STORE_SITES = 'PlayStation, Xbox, Nintendo eShop, Epic Games, GOG'; @@ -19,10 +23,11 @@ const setBookmarklet = (link) => link?.setAttribute('href', bookmarkletHref(loca /** * @param storeRows the games the bookmarklet read on a store page, or null + * @param initialSource the tab to open, e.g. PSN; null for the default */ -export default function ImportInput({storeRows, onCreated}) { +export default function ImportInput({storeRows, initialSource = null, onCreated}) { const {t} = useTranslation(); - const [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : 'TEXT'); + const [source, setSource] = useState(storeRows ? 'BOOKMARKLET' : initialSource ?? 'TEXT'); const [text, setText] = useState(''); const [csv, setCsv] = useState(null); const [busy, setBusy] = useState(false); @@ -44,12 +49,14 @@ export default function ImportInput({storeRows, onCreated}) {

{t('IMPORT.HINT')}

- {['TEXT', 'CSV', 'BOOKMARKLET'].map((value) => ( + {['TEXT', 'CSV', 'BOOKMARKLET', 'PSN'].map((value) => ( ))}
- {source === 'BOOKMARKLET' ? ( + {source === 'PSN' ? ( + + ) : source === 'BOOKMARKLET' ? ( ) : source === 'TEXT' ? (