diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index ec41b37f6..6522278d8 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -345,6 +345,18 @@ dialog shows them in its failure snackbar (with kind-specific i18n headlines, guidance) when category loading failed with a portal refusal (`stalkerCategoryErrorDescription` in `workspace-context-panel.component.ts`). +Both renderers are kind-agnostic — they append `portalText` whenever it is +present — so the obligation sits entirely on the throw sites: **every** exit +out of the status-2 branch carries the text, not just the terminal `blocked` +one. A login refusal is precisely where the portal says something actionable +("wrong password", "subscription expired"), and dropping it leaves the user +with a generic line while the useful sentence sits unread in the payload. +Each exit reads the response IN HAND: after the `auth_second_step=1` retry the +text is the retry's, since quoting the first profile back would describe a +request that already succeeded. `do_auth` itself answers a bare `{js: false}`, +so a rejection there keeps the profile's text — the one that asked for the +login. + ### Abandoning an authentication `authenticate()` takes an optional `AbortSignal` and checks it before every diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts index 19598ba76..43b31b6a2 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.spec.ts @@ -140,6 +140,54 @@ describe('StalkerAuthApi', () => { ).rejects.toMatchObject({ kind: 'login-rejected' }); }); + it('carries the portal explanation out of every login refusal', async () => { + // The actionable sentence ("wrong password", "subscription expired") + // rides along with the refusal, and the dialog renders it after the + // generic line. Each exit must read the response IN HAND: the retry + // explains its own rejection, and quoting the first profile back + // would describe a request that already succeeded. + const handshake = { js: { token: 'TOKEN-1', random: 'r1' } }; + const credentials = { username: 'user', password: 'secret' }; + + sendIpcEvent + .mockResolvedValueOnce(handshake) + .mockResolvedValueOnce({ + js: { status: 2, msg: 'Enter your subscriber login' }, + }); + await expect( + api.authenticate(portalUrl, macAddress) + ).rejects.toMatchObject({ + kind: 'login-required', + portalText: 'Enter your subscriber login', + }); + + sendIpcEvent + .mockResolvedValueOnce(handshake) + .mockResolvedValueOnce({ js: { status: 2, msg: 'Login needed' } }) + .mockResolvedValueOnce({ js: false }); + await expect( + api.authenticate(portalUrl, macAddress, {}, { credentials }) + ).rejects.toMatchObject({ + kind: 'login-rejected', + portalText: 'Login needed', + }); + + sendIpcEvent + .mockResolvedValueOnce(handshake) + .mockResolvedValueOnce({ js: { status: 2, msg: 'Login needed' } }) + .mockResolvedValueOnce({ js: true }) + .mockResolvedValueOnce({ + js: { status: 2, block_msg: 'Subscription expired' }, + }); + await expect( + api.authenticate(portalUrl, macAddress, {}, { credentials }) + ).rejects.toMatchObject({ + kind: 'login-rejected', + // The retry's text, not the first profile's. + portalText: 'Subscription expired', + }); + }); + it('decodes a blocked profile into the portal explanation', async () => { sendIpcEvent .mockResolvedValueOnce({ diff --git a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts index 15590d0e8..fa5f6e020 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-auth.api.ts @@ -480,10 +480,19 @@ export class StalkerAuthApi { // strict `=== 2` would read `"2"` as a healthy profile and skip the // whole login flow. if (toFiniteNumber(js?.status) === 2) { + // The portal's own sentence travels WITH the refusal — "wrong + // password", "subscription expired", "contact your provider" — + // and reading it is the whole reason these errors carry + // `portalText`. It is read at each exit rather than once up + // front because `js` advances to the retry's response below: the + // second refusal explains itself, and quoting the first one back + // would describe a request that already succeeded. + const loginText = () => + combineStalkerPortalMessages(js?.msg, js?.block_msg); const username = options.credentials?.username?.trim() ?? ''; const password = options.credentials?.password ?? ''; if (!username || !password) { - throw new StalkerPortalError('login-required'); + throw new StalkerPortalError('login-required', loginText()); } assertNotAborted(options.signal); @@ -496,7 +505,10 @@ export class StalkerAuthApi { options.signal ); if (!accepted) { - throw new StalkerPortalError('login-rejected'); + // `do_auth` answers a bare `{js: false}`, so the only text + // available here is the profile's — which is the one that + // asked for the login in the first place. + throw new StalkerPortalError('login-rejected', loginText()); } assertNotAborted(options.signal); @@ -515,7 +527,7 @@ export class StalkerAuthApi { settled = retried; js = retried?.js; if (toFiniteNumber(js?.status) === 2) { - throw new StalkerPortalError('login-rejected'); + throw new StalkerPortalError('login-rejected', loginText()); } }