Public Access
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.
This commit is contained in:
1 parent
6ae2abee07
commit
599b083591
18 files changed
+573
-12
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = <NpssoForm npsso={npsso} onChange={setNpsso} onSubmit={link} busy={busy}/>;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Gamepad className="size-4 text-primary"/>{t('SETTINGS.PSN.TITLE')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{failed ? (
|
||||
<p className="text-sm text-destructive">{t('SETTINGS.PSN.ERROR')}</p>
|
||||
) : !account ? (
|
||||
<Loader2 className="animate-spin text-muted-foreground"/>
|
||||
) : !account.enabled ? (
|
||||
<p className="text-sm text-muted-foreground">{t('SETTINGS.PSN.OFF')}</p>
|
||||
) : !account.linked ? form : (
|
||||
<>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p>{t('SETTINGS.PSN.LINKED_SINCE', {date: formatDate(account.linkedAt, i18n.language, {dateStyle: 'medium'})})}</p>
|
||||
<p className={expired ? 'font-medium text-destructive' : undefined}>
|
||||
{t('SETTINGS.PSN.EXPIRES_ON', {date: formatDate(account.expiresAt, i18n.language, {dateStyle: 'medium'})})}
|
||||
{expired && <> · {t('SETTINGS.PSN.EXPIRED')}</>}
|
||||
</p>
|
||||
</div>
|
||||
{expired && form}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{!expired && (
|
||||
<Button asChild>
|
||||
<Link to="/import?source=PSN"><Upload/>{t('SETTINGS.PSN.IMPORT')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={unlink} disabled={busy}>
|
||||
<Unlink/>{t('SETTINGS.PSN.UNLINK')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function NpssoForm({npsso, onChange, onSubmit, busy}) {
|
||||
const {t} = useTranslation();
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<ol className="list-decimal space-y-1 pl-5 text-sm">
|
||||
<li>{t('SETTINGS.PSN.STEPS_1')}</li>
|
||||
<li>
|
||||
{t('SETTINGS.PSN.STEPS_2')}{' '}
|
||||
<a href={NPSSO_PAGE_URL} target="_blank" rel="noopener noreferrer" className="break-all underline">{NPSSO_PAGE_URL}</a>
|
||||
</li>
|
||||
<li>{t('SETTINGS.PSN.STEPS_3')}</li>
|
||||
</ol>
|
||||
<p className="text-sm text-muted-foreground">{t('SETTINGS.PSN.WHAT_IT_GIVES')}</p>
|
||||
<Input type="password" autoComplete="off" spellCheck={false} maxLength={128} value={npsso}
|
||||
aria-label={t('SETTINGS.PSN.NPSSO_LABEL')} onChange={(e) => onChange(e.target.value)}/>
|
||||
<Button type="submit" disabled={busy || !npsso.trim()}>
|
||||
{busy ? <Loader2 className="animate-spin"/> : <Link2/>}{t('SETTINGS.PSN.LINK')}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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(<PsnAccountCard/>, {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);
|
||||
});
|
||||
});
|
||||
@@ -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}) {
|
||||
<Card className="gap-4 p-5">
|
||||
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
|
||||
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex flex-wrap gap-2">
|
||||
{['TEXT', 'CSV', 'BOOKMARKLET'].map((value) => (
|
||||
{['TEXT', 'CSV', 'BOOKMARKLET', 'PSN'].map((value) => (
|
||||
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
|
||||
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
|
||||
))}
|
||||
</div>
|
||||
{source === 'BOOKMARKLET' ? (
|
||||
{source === 'PSN' ? (
|
||||
<PsnInput onCreated={onCreated}/>
|
||||
) : source === 'BOOKMARKLET' ? (
|
||||
<StoreInput rows={storeRows}/>
|
||||
) : source === 'TEXT' ? (
|
||||
<Textarea rows={10} value={text} aria-label={t('IMPORT.TEXT_LABEL')} placeholder={t('IMPORT.TEXT_PLACEHOLDER')}
|
||||
@@ -66,14 +73,14 @@ export default function ImportInput({storeRows, onCreated}) {
|
||||
<RowPreview rows={csv?.rows}/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{source !== 'PSN' && <div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className={tooMany ? 'text-sm text-destructive' : 'text-sm text-muted-foreground'}>
|
||||
{t(tooMany ? 'IMPORT.TOO_MANY' : 'IMPORT.LINES', {count: rows.length, max: MAX_IMPORT_ROWS})}
|
||||
</span>
|
||||
<Button onClick={start} disabled={busy || rows.length === 0 || tooMany}>
|
||||
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -117,3 +124,40 @@ function StoreInput({rows}) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The played PlayStation titles: Core reads them from the linked account, so there is nothing to type.
|
||||
*/
|
||||
function PsnInput({onCreated}) {
|
||||
const {t} = useTranslation();
|
||||
const {account, failed} = usePsnAccount();
|
||||
const startImport = useStartPsnImport();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true);
|
||||
const id = await startImport();
|
||||
setBusy(false);
|
||||
if (id) onCreated(id);
|
||||
};
|
||||
|
||||
if (failed) return <p className="text-sm text-destructive">{t('SETTINGS.PSN.ERROR')}</p>;
|
||||
if (!account) return <Loader2 className="animate-spin text-muted-foreground"/>;
|
||||
const linked = account.enabled && account.linked;
|
||||
if (!linked || isExpired(account)) {
|
||||
return (
|
||||
<p className="text-sm">
|
||||
{t(linked ? 'IMPORT.PSN_RELINK' : 'IMPORT.PSN_NOT_LINKED')}{' '}
|
||||
<Link to="/settings" className="underline">{t('IMPORT.PSN_SETTINGS')}</Link>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">{t('IMPORT.PSN_HINT')}</p>
|
||||
<Button onClick={start} disabled={busy}>
|
||||
{busy ? <Loader2 className="animate-spin"/> : <Search/>}{t('IMPORT.START')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {getPsnAccount} from '../utils/psn.js';
|
||||
|
||||
/**
|
||||
* The linked PlayStation account of the user: `account` is null while loading, `failed` when Core did not answer.
|
||||
*/
|
||||
export default function usePsnAccount() {
|
||||
const [account, setAccount] = useState(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const response = await getPsnAccount();
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) setAccount(await response.json());
|
||||
else setFailed(true);
|
||||
} catch (err) {
|
||||
console.error('PSN account error:', err);
|
||||
setFailed(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return {account, setAccount, failed};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import {useCallback} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {startPsnImport} from '../utils/psn.js';
|
||||
|
||||
/**
|
||||
* Returns `start()`: it asks Core to read the played PlayStation titles and create an import. Resolves to the import's
|
||||
* id, or shows why not and resolves to null.
|
||||
*/
|
||||
export default function useStartPsnImport() {
|
||||
const {t} = useTranslation();
|
||||
|
||||
return useCallback(async () => {
|
||||
try {
|
||||
const response = await startPsnImport();
|
||||
if (response.status === 401) return null;
|
||||
if (response.status === 204) {
|
||||
toast.info(t('IMPORT.PSN_NO_TITLES'));
|
||||
} else if (response.status === 404) {
|
||||
toast.error(t('IMPORT.PSN_NOT_LINKED'));
|
||||
} else if (response.status === 409) {
|
||||
const body = await response.json().catch(() => null);
|
||||
toast.error(t(body?.code === 'PSN_RELINK' ? 'IMPORT.PSN_RELINK' : 'IMPORT.RUNNING'));
|
||||
} else if (response.status === 429) {
|
||||
toast.error(t('IMPORT.PSN_BUSY'));
|
||||
} else if (response.status === 502) {
|
||||
toast.error(t('IMPORT.PSN_UNAVAILABLE'));
|
||||
} else if (response.status === 503) {
|
||||
toast.error(t('MANUAL_GAME.UNAVAILABLE'));
|
||||
} else if (!response.ok) {
|
||||
toast.error(t('IMPORT.START_FAILED'));
|
||||
} else {
|
||||
return (await response.json()).id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('PSN import start error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
}
|
||||
return null;
|
||||
}, [t]);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export default function GameDetail() {
|
||||
if (data && String(data.appid) !== appid) navigate(`/library/${data.appid}`, {replace: true});
|
||||
}, [data, appid, navigate]);
|
||||
|
||||
const manual = data?.source === 'MANUAL';
|
||||
const manual = !!data && data.source !== 'STEAM';
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
|
||||
@@ -240,6 +240,15 @@ describe('GameDetail of a game added by hand', () => {
|
||||
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('treats a game from a PlayStation import like a game added by hand', async () => {
|
||||
answer({...manualGame, source: 'PSN', platform: 'PS5'},
|
||||
{'PUT /api/library/games/-1025/manual': noBody, 'DELETE /api/library/games/-1025': noBody});
|
||||
renderDetail(-1025);
|
||||
|
||||
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
|
||||
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('saves platform and hours', async () => {
|
||||
renderDetail(-1025);
|
||||
fireEvent.change(await screen.findByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2.5'}});
|
||||
|
||||
@@ -14,8 +14,8 @@ import {decodeStoreList} from '../utils/storeList.js';
|
||||
|
||||
/**
|
||||
* Many games at once: a list or a CSV goes to Core, which matches it with IGDB in the background; the user checks
|
||||
* the matches and saves them. ?id= opens one import; the history below can undo a saved import. #data= is the list
|
||||
* the bookmarklet read on a store page.
|
||||
* the matches and saves them. ?id= opens one import; ?source=PSN opens the PlayStation input; the history below can
|
||||
* undo a saved import. #data= is the list the bookmarklet read on a store page.
|
||||
*/
|
||||
export default function ImportPage() {
|
||||
const {t} = useTranslation();
|
||||
@@ -84,7 +84,8 @@ export default function ImportPage() {
|
||||
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
|
||||
) : (
|
||||
// A store list comes after the first answer: a new key opens its tab.
|
||||
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows} onCreated={(created) => {
|
||||
<ImportInput key={storeRows ? 'store' : 'input'} storeRows={storeRows}
|
||||
initialSource={searchParams.get('source') === 'PSN' ? 'PSN' : null} onCreated={(created) => {
|
||||
setStoreRows(null);
|
||||
show(created);
|
||||
loadImports();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import ImportPage from './ImportPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.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(), info: vi.fn()}}));
|
||||
|
||||
const linked = {enabled: true, linked: true, linkedAt: '2026-10-01T10:00:00Z', expiresAt: new Date(Date.now() + 5 * 86_400_000).toISOString()};
|
||||
|
||||
function renderPage(routes, entry = '/import?source=PSN') {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {favoritePlatform: null}}),
|
||||
'GET /api/library/imports': json(200, {imports: []}),
|
||||
'GET /api/library/psn-account': json(200, linked),
|
||||
...routes,
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
|
||||
<Route path="/settings" element={<p>settings page</p>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
const startButton = () => screen.findByRole('button', {name: 'IMPORT.START'});
|
||||
|
||||
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ImportPage PlayStation source', () => {
|
||||
it('opens on the PlayStation tab for ?source=PSN', async () => {
|
||||
renderPage({});
|
||||
|
||||
expect(await screen.findByText('IMPORT.PSN_HINT')).toBeTruthy();
|
||||
expect(screen.getByRole('button', {name: 'IMPORT.SOURCE_PSN'}).getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('opens on the list without the parameter', async () => {
|
||||
renderPage({}, '/import');
|
||||
|
||||
expect(await screen.findByLabelText('IMPORT.TEXT_LABEL')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('points to the settings when no account is linked', async () => {
|
||||
renderPage({'GET /api/library/psn-account': json(200, {enabled: true, linked: false})});
|
||||
|
||||
expect(await screen.findByText(/IMPORT.PSN_NOT_LINKED/)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', {name: 'IMPORT.START'})).toBeNull();
|
||||
fireEvent.click(screen.getByRole('link', {name: 'IMPORT.PSN_SETTINGS'}));
|
||||
expect(await screen.findByText('settings page')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('points to the settings when the link has ended', async () => {
|
||||
renderPage({'GET /api/library/psn-account': json(200, {...linked, expiresAt: '2020-01-01T00:00:00Z'})});
|
||||
|
||||
expect(await screen.findByText(/IMPORT.PSN_RELINK/)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', {name: 'IMPORT.START'})).toBeNull();
|
||||
});
|
||||
|
||||
it('starts the import at the PlayStation endpoint without sending rows, and shows its progress', async () => {
|
||||
renderPage({
|
||||
'POST /api/library/psn-account/imports': json(202, {id: 9}),
|
||||
'GET /api/library/imports/9': json(200, {id: 9, source: 'PSN', status: 'MATCHING', total: 4, matched: 1}),
|
||||
});
|
||||
|
||||
fireEvent.click(await startButton());
|
||||
|
||||
expect(await screen.findByText('IMPORT.MATCHING')).toBeTruthy();
|
||||
const [, init] = fetch.mock.calls.find(([url, call]) => call?.method === 'POST' && url.endsWith('/psn-account/imports'));
|
||||
expect(init.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[204, {}, 'info', 'IMPORT.PSN_NO_TITLES'],
|
||||
[404, {}, 'error', 'IMPORT.PSN_NOT_LINKED'],
|
||||
[409, {code: 'PSN_RELINK'}, 'error', 'IMPORT.PSN_RELINK'],
|
||||
[409, {code: 'IMPORT_RUNNING'}, 'error', 'IMPORT.RUNNING'],
|
||||
[429, {code: 'PSN_BUSY'}, 'error', 'IMPORT.PSN_BUSY'],
|
||||
[502, {}, 'error', 'IMPORT.PSN_UNAVAILABLE'],
|
||||
[503, {}, 'error', 'MANUAL_GAME.UNAVAILABLE'],
|
||||
])('answers status %i %j with a toast', async (status, body, kind, key) => {
|
||||
renderPage({'POST /api/library/psn-account/imports': json(status, body)});
|
||||
|
||||
fireEvent.click(await startButton());
|
||||
|
||||
await waitFor(() => expect(toast[kind]).toHaveBeenCalledWith(key));
|
||||
expect(screen.getByText('IMPORT.PSN_HINT')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import DeleteAccountCard from '../components/DeleteAccountCard.jsx';
|
||||
import FollowsCard from '../components/FollowsCard.jsx';
|
||||
import PreferencesCard from '../components/PreferencesCard.jsx';
|
||||
import ProfileCard from '../components/ProfileCard.jsx';
|
||||
import PsnAccountCard from '../components/PsnAccountCard.jsx';
|
||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import useSettingsLoad from '../hooks/useSettingsLoad.js';
|
||||
@@ -73,6 +74,8 @@ export default function SettingsPage() {
|
||||
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
|
||||
onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
|
||||
|
||||
<PsnAccountCard/>
|
||||
|
||||
<ProfileCard displayName={displayName}/>
|
||||
|
||||
<FollowsCard/>
|
||||
|
||||
@@ -33,6 +33,7 @@ function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
|
||||
'POST /api/auth/refresh': noBody,
|
||||
'GET /api/version': json(404, {}),
|
||||
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
|
||||
});
|
||||
renderWithProviders(<SettingsPage/>, {path: '/settings', entries: [entry]});
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('SettingsPage Steam toast', () => {
|
||||
'POST /api/auth/refresh': {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }},
|
||||
'GET /api/version': json(404, {}),
|
||||
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ const authRoutes = (me) => ({
|
||||
'PUT /api/users/settings/owner-lists': noBody,
|
||||
'GET /api/version': json(404, {}),
|
||||
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
|
||||
});
|
||||
|
||||
function renderPage(me) {
|
||||
@@ -294,6 +295,7 @@ describe('SettingsPage session', () => {
|
||||
'POST /api/auth/refresh': json(401, {}),
|
||||
'GET /api/version': json(404, {}),
|
||||
'GET /api/follows': json(200, {following: [], followers: []}),
|
||||
'GET /api/library/psn-account': json(200, {enabled: false, linked: false}),
|
||||
});
|
||||
const {router} = renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings'],
|
||||
guarded: true, routes: {'/login': <p>login</p>}});
|
||||
|
||||
@@ -51,8 +51,8 @@ export const preselectPlatform = (own, platforms, favorite) => {
|
||||
return find(favorite) ?? (platforms.length === 1 ? platforms[0] : '');
|
||||
};
|
||||
|
||||
// A Steam game has no stored platform.
|
||||
export const platformLabel = (game) => (game.source === 'MANUAL' ? game.platform : 'Steam');
|
||||
// A Steam game has no stored platform; a game added by hand or imported from PlayStation has.
|
||||
export const platformLabel = (game) => (game.source === 'STEAM' ? 'Steam' : game.platform);
|
||||
|
||||
/**
|
||||
* PUT /api/users/settings/favorite-platform; null clears it. The caller handles the response status.
|
||||
|
||||
@@ -26,5 +26,6 @@ describe('platformLabel', () => {
|
||||
it('shows Steam for a Steam game', () => {
|
||||
expect(platformLabel({source: 'STEAM', platform: null})).toBe('Steam');
|
||||
expect(platformLabel({source: 'MANUAL', platform: 'PS5'})).toBe('PS5');
|
||||
expect(platformLabel({source: 'PSN', platform: 'PS5'})).toBe('PS5');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import {apiFetch} from './api.js';
|
||||
|
||||
const PSN_URL = () => `${import.meta.env.VITE_CORE_API_URL}/api/library/psn-account`;
|
||||
|
||||
// The calls return the raw response; the caller handles the status.
|
||||
export const getPsnAccount = () => apiFetch(PSN_URL());
|
||||
|
||||
// The NPSSO goes only into this request body: Core uses it once and never stores it.
|
||||
export const linkPsn = (npsso) =>
|
||||
apiFetch(PSN_URL(), {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({npsso})});
|
||||
|
||||
export const unlinkPsn = () => apiFetch(PSN_URL(), {method: 'DELETE'});
|
||||
|
||||
export const startPsnImport = () => apiFetch(`${PSN_URL()}/imports`, {method: 'POST'});
|
||||
|
||||
// Where a signed-in user reads the NPSSO value.
|
||||
export const NPSSO_PAGE_URL = 'https://ca.account.sony.com/api/v1/ssocookie';
|
||||
|
||||
export const isExpired = (account) => !!account?.expiresAt && new Date(account.expiresAt).getTime() <= Date.now();
|
||||
Reference in new issue
Block a user