From a0bfc28dc78f3bfee35315ae50047969dfd993b4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 3 Aug 2026 21:14:54 +0200 Subject: [PATCH] fix(stalker): keep the portal's words on every login refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the terminal `blocked` exit carried the portal's own explanation. A login refusal is precisely where a portal says something actionable — wrong password, subscription expired — and every status-2 exit threw without it, leaving the dialog on a generic line while the useful sentence sat unread in the payload. Each exit now reads the response in hand, so the retry's rejection is quoted rather than the request that already succeeded. Co-Authored-By: Claude Fable 5 --- docs/architecture/stalker-portal.md | 12 +++++ .../src/lib/stalker-auth.api.spec.ts | 48 +++++++++++++++++++ .../data-access/src/lib/stalker-auth.api.ts | 18 +++++-- 3 files changed, 75 insertions(+), 3 deletions(-) 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()); } }