diff --git a/docs/FORK-HARVEST-TODO.md b/docs/FORK-HARVEST-TODO.md index 358ed3a..1f7dfb7 100644 --- a/docs/FORK-HARVEST-TODO.md +++ b/docs/FORK-HARVEST-TODO.md @@ -205,14 +205,16 @@ The **client half and the contract are done** (2026-08-21). What remains is back A second strategy object against the same surface — roughly a day once VK works. *Confirm exact parameter and scope names against live provider docs; both providers revised their flows recently.* -- [ ] **FH-4.6 — Migrate Telegram onto `ExternalIdentity`** · M · Lane B + C - Telegram becomes one provider among several rather than the schema's only key. Ends the shared customer/admin Telegram session their audit flagged. +- [~] **FH-4.6 — Migrate Telegram onto `ExternalIdentity`** · M · **client + contract done 2026-08-21; backend write path pending** + Client: the gateway now separates the two provider sets — `SocialProvider` (`vk`/`yandex`, has an OAuth authorize) vs `ExternalIdentityProvider` (adds `telegram`/`max`, listable and unlinkable). `unlink()` takes the wider type, so the linking UI detaches Telegram through the same path as VK. The dev local gateway seeds a Telegram identity so the surface is exercisable now. + Contract: PHASE-8 §2.6 — Telegram login writes an `ExternalIdentity` row under the same uniqueness/conflict rule as VK, appears in `/me/identities`, is removable subject to the last-identity `409`, and — the audit finding — keeps customer (`marketplace_session`) and admin (`bo_session`) sessions as separate cookies so a Telegram customer never satisfies an admin guard. Identity row vs messaging `BotConversationBinding` kept distinct. + Backend still owns: the actual write-on-login and the session split enforcement. Telegram login itself lives in `@marketplaces/auth`. -- [ ] **FH-4.7 — Account linking UI** · M - `/me/identities` — show linked providers, link, unlink, and surface the conflict-resolution path from FH-4.3. +- [x] **FH-4.7 — Account linking UI** · M · **done 2026-08-21** + `AccountIdentitiesComponent` (`src/app/features/website/account/identities/`): lists linked identities from `GET /me/identities`, offers attach buttons only for OAuth providers not yet linked (reusing `SocialLoginButtonComponent`), detaches through `unlink()`, disables the detach control on the last remaining identity with an explanatory title, and has a slot for the §2.3 conflict message. Loading / error / ready states, error surfaced rather than shown as an empty account. 6 unit tests. Not yet wired into a route — the storefront has no customer account area and no live OAuth app (FH-0.1) — but fully built and tested behind that. -- [ ] **FH-4.8 — Email/phone OTP repositioned as recovery** · S · Lane E - Our approved [email/phone login spec](superpowers/specs/2026-08-15-email-phone-login-design.md) stays valid but drops below VK ID and becomes the fallback when a messenger channel is unavailable, per v3.1 §14. +- [x] **FH-4.8 — Email/phone OTP repositioned as recovery** · S · **done 2026-08-21** + PHASE-8 §3 now states it explicitly: OTP is a way back in when a linked messenger is unreachable and a second factor a customer may add, never the front-and-centre first login option, and one more `ExternalIdentity`/`ContactMethod` on the same customer rather than a parallel account. The [email/phone spec](superpowers/specs/2026-08-15-email-phone-login-design.md) stays valid; only its priority relative to VK ID moves. --- @@ -268,10 +270,10 @@ The **client half and the contract are done** (2026-08-21). What remains is back | 1 — Live defects | 2 | — | 2 | FH-1.2 / FH-1.4 sit in files another session owns | | 2 — Contracts | 14 | — | 0 | 1 rejected (FH-2.12) | | 3 — Proof | 3 | 1 | 1 | see note below | -| 4 — Identity | 2 | 3 | 3 | OAuth apps, which FH-0.1 gates | +| 4 — Identity | 4 | 3 | 1 | OAuth apps, which FH-0.1 gates | | Ops | 1 | — | 2 | — | | Process | 5 | — | 1 | — | -| **Total** | **27** | **4** | **11** | 1 rejected | +| **Total** | **29** | **4** | **9** | 1 rejected | **Landed 2026-08-21** diff --git a/docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md b/docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md index 791e392..ab3d1b7 100644 --- a/docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md +++ b/docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md @@ -131,10 +131,23 @@ profile GET https://login.yandex.ru/info?format=json Yandex is a second strategy object against the same surface, not a second integration. Build it after VK works. -## 3. Sprint 8.3 — Email/phone OTP (after VK ID) +### 2.6 Migrating Telegram onto `ExternalIdentity` (FH-4.6) + +Added 2026-08-21. Telegram is the current app's only customer login and it does not go through §2's OAuth surface — it authenticates via a QR/bot flow owned by `@marketplaces/auth`. The migration makes it *one identity among several* without changing how it authenticates: + +- On a successful Telegram login the backend writes (or updates) an `ExternalIdentity` with `provider: 'telegram'` and `providerUserId` = the Telegram user id, under the same `UNIQUE (provider, providerUserId)` and same identity-conflict rule as §2.3. Telegram stops being a special-cased column and becomes a row like any other provider. +- Telegram appears in `GET /me/identities` and is removable through `POST /api/identity/v1/telegram/unlink`, subject to the **last-identity rule**: the backend refuses to unlink a customer's only remaining identity (`409`, a login they cannot get back). The frontend gateway's `unlink()` already accepts any `ExternalIdentityProvider`, not just the OAuth ones, so the linking UI (FH-4.7) drives this without a second code path. +- **The customer/admin session split their audit flagged is closed here.** A Telegram customer session (`marketplace_session`) and an admin session (`bo_session`) are different cookies checked by different guards ([Track S §2.1](TRACK-S-SECURITY-RBAC-CONTRACT.md)); the same Telegram account authenticating as a customer must never satisfy an admin guard, and vice versa. Migrating Telegram to `ExternalIdentity` on the customer side does not grant it any admin scope — admin membership is a separate axis. +- Telegram's messaging binding (§4 `BotConversationBinding`, delivery updates) is a *contact channel*, distinct from the *identity* row added here. One provider can be both; they are separate records so unlinking the identity does not silently kill an order's delivery conversation, and vice versa. + +Client state today: `SocialProvider` (`vk` | `yandex`) is the set with an OAuth authorize redirect; `ExternalIdentityProvider` (`vk_id` | `yandex_id` | `telegram` | `max`) is the set that can be listed and unlinked. `unlink()` takes the wider type; `getAuthorizeUrl()` takes the narrower one. See `src/app/core/identity/services/social-identity-gateway.interface.ts`. + +## 3. Sprint 8.3 — Email/phone OTP (after VK ID), positioned as recovery (FH-4.8) Implements the already-approved [email/phone login spec](../superpowers/specs/2026-08-15-email-phone-login-design.md). Per v3.1 §14, position this as **recovery/fallback** when a messenger channel is unavailable — not the primary login path. No new contract beyond that spec; this section exists only to fix its place in the build order relative to VK ID. +Concretely, "recovery/fallback" means: email/phone OTP is offered as a way back in when a customer's linked messenger identity is unreachable, and as a second factor a customer can add — never as the front-and-centre first option on the login surface, which is VK ID (and Telegram, where it is already the norm). It is one more `ExternalIdentity`/`ContactMethod` on the same customer, not a parallel account. + ## 4. Sprint 8.4 — MAX + Telegram bot channels ```ts diff --git a/src/app/core/identity/services/social-identity-api.gateway.ts b/src/app/core/identity/services/social-identity-api.gateway.ts index 166ea48..cb5fb0e 100644 --- a/src/app/core/identity/services/social-identity-api.gateway.ts +++ b/src/app/core/identity/services/social-identity-api.gateway.ts @@ -2,7 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ExternalIdentity } from '../models/customer-identity.model'; +import { ExternalIdentity, ExternalIdentityProvider } from '../models/customer-identity.model'; import { SocialIdentityGateway, SocialProvider } from './social-identity-gateway.interface'; /** @@ -28,7 +28,7 @@ export class SocialIdentityApiGateway implements SocialIdentityGateway { return this.http.get(`${this.base}/me/identities`); } - unlink(provider: SocialProvider): Observable { + unlink(provider: ExternalIdentityProvider): Observable { return this.http.post(`${this.base}/${provider}/unlink`, {}); } } diff --git a/src/app/core/identity/services/social-identity-gateway.interface.ts b/src/app/core/identity/services/social-identity-gateway.interface.ts index 5fca170..e43c9c7 100644 --- a/src/app/core/identity/services/social-identity-gateway.interface.ts +++ b/src/app/core/identity/services/social-identity-gateway.interface.ts @@ -1,7 +1,14 @@ import { Observable } from 'rxjs'; -import { ExternalIdentity } from '../models/customer-identity.model'; +import { ExternalIdentity, ExternalIdentityProvider } from '../models/customer-identity.model'; -/** Providers this surface can start an authorization flow for. */ +/** + * Providers this surface can start an OAuth authorize redirect for. + * + * Narrower than ExternalIdentityProvider on purpose: Telegram and MAX link + * through a bot / QR flow owned by @marketplaces/auth, not an authorize + * redirect, so they can be listed and unlinked here (FH-4.6) but never + * passed to getAuthorizeUrl(). + */ export type SocialProvider = 'vk' | 'yandex'; /** @@ -33,6 +40,11 @@ export interface SocialIdentityGateway { /** Providers currently linked to the authenticated customer. */ listIdentities(): Observable; - /** Unlinks a provider from the authenticated customer. */ - unlink(provider: SocialProvider): Observable; + /** + * Unlinks a provider from the authenticated customer. Accepts any linked + * provider, not just the OAuth ones - a customer can detach Telegram or MAX + * the same way they detach VK, provided at least one identity remains + * (the backend enforces "you cannot unlink your last login"). + */ + unlink(provider: ExternalIdentityProvider): Observable; } diff --git a/src/app/core/identity/services/social-identity-gateway.spec.ts b/src/app/core/identity/services/social-identity-gateway.spec.ts index b8c6136..590dbf0 100644 --- a/src/app/core/identity/services/social-identity-gateway.spec.ts +++ b/src/app/core/identity/services/social-identity-gateway.spec.ts @@ -58,10 +58,18 @@ describe('SocialIdentityApiGateway', () => { request.flush([]); }); - it('unlinks per provider', () => { - gateway.unlink('yandex').subscribe(); + it('unlinks an OAuth provider by its identity name', () => { + gateway.unlink('yandex_id').subscribe(); - const request = httpTesting.expectOne('/api/identity/v1/yandex/unlink'); + const request = httpTesting.expectOne('/api/identity/v1/yandex_id/unlink'); + expect(request.request.method).toBe('POST'); + request.flush(null); + }); + + it('unlinks a non-OAuth provider too (FH-4.6: Telegram is one identity among several)', () => { + gateway.unlink('telegram').subscribe(); + + const request = httpTesting.expectOne('/api/identity/v1/telegram/unlink'); expect(request.request.method).toBe('POST'); request.flush(null); }); diff --git a/src/app/core/identity/services/social-identity-local.gateway.ts b/src/app/core/identity/services/social-identity-local.gateway.ts index 5546862..11137df 100644 --- a/src/app/core/identity/services/social-identity-local.gateway.ts +++ b/src/app/core/identity/services/social-identity-local.gateway.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { ExternalIdentity } from '../models/customer-identity.model'; +import { ExternalIdentity, ExternalIdentityProvider } from '../models/customer-identity.model'; import { SocialIdentityGateway, SocialProvider } from './social-identity-gateway.interface'; /** @@ -16,15 +16,31 @@ import { SocialIdentityGateway, SocialProvider } from './social-identity-gateway */ @Injectable({ providedIn: 'root' }) export class SocialIdentityLocalGateway implements SocialIdentityGateway { + // Seeded so the account-identities screen has something to render in dev. + // Telegram is present because in the current app it is the only real login; + // FH-4.6 makes it one ExternalIdentity among several, which is exactly what + // this list is meant to show. + private readonly linked = signal([ + { + customerId: 'customer_local', + provider: 'telegram', + providerUserId: '100200300', + displayName: 'Local Telegram User', + verifiedAt: '2026-08-01T10:00:00.000Z', + lastUsedAt: '2026-08-21T09:00:00.000Z', + }, + ]); + getAuthorizeUrl(provider: SocialProvider): Observable { return of(`about:blank#${provider}-oauth-not-configured`); } listIdentities(): Observable { - return of([]); + return of(this.linked()); } - unlink(): Observable { + unlink(provider: ExternalIdentityProvider): Observable { + this.linked.update(list => list.filter(identity => identity.provider !== provider)); return of(void 0); } } diff --git a/src/app/features/website/account/identities/account-identities.component.html b/src/app/features/website/account/identities/account-identities.component.html new file mode 100644 index 0000000..5f8c995 --- /dev/null +++ b/src/app/features/website/account/identities/account-identities.component.html @@ -0,0 +1,64 @@ +
+
+

Connected accounts

+

Sign in with any of these. You can add or remove them at any time.

+
+ + @switch (state()) { + @case ('loading') { +

Loading…

+ } + @case ('error') { + + } + @case ('ready') { + @if (conflict(); as message) { + + } + +
    + @for (identity of identities(); track identity.provider) { +
  • + + + {{ label(identity.provider) }} + @if (identity.displayName) { + {{ identity.displayName }} + } + + +
  • + } +
+ + @if (linkable().length) { +
+

Add another

+ @for (provider of linkable(); track provider) { + + } +
+ } + } + } +
diff --git a/src/app/features/website/account/identities/account-identities.component.scss b/src/app/features/website/account/identities/account-identities.component.scss new file mode 100644 index 0000000..3eddd2b --- /dev/null +++ b/src/app/features/website/account/identities/account-identities.component.scss @@ -0,0 +1,126 @@ +.identities { + max-width: 560px; + margin: 0 auto; + padding: 24px 16px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.identities__header { + h1 { + margin: 0 0 4px; + font-size: 1.5rem; + color: var(--text-primary); + } + + p { + margin: 0; + color: var(--text-secondary, var(--text-muted)); + } +} + +.identities__status { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-secondary, var(--text-muted)); + + &--error { + color: var(--color-error, #c0392b); + } +} + +.identities__retry { + margin-left: auto; + padding: 4px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm, 6px); + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; +} + +.identities__conflict { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + border: 1px solid var(--color-warning, #d19a00); + border-radius: var(--radius-md, 8px); + background: var(--color-warning-bg, rgba(209, 154, 0, 0.08)); + color: var(--text-primary); +} + +.identities__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.identity { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border-color); + border-radius: var(--radius-md, 8px); + background: var(--bg-primary); +} + +.identity__icon { + display: inline-flex; + color: var(--text-secondary, var(--text-muted)); +} + +.identity__body { + display: flex; + flex-direction: column; + min-width: 0; +} + +.identity__name { + font-weight: var(--font-weight-bold, 700); + color: var(--text-primary); +} + +.identity__detail { + font-size: 0.875rem; + color: var(--text-secondary, var(--text-muted)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.identity__unlink { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm, 6px); + background: transparent; + color: var(--text-primary); + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.identities__add { + display: flex; + flex-direction: column; + gap: 12px; + + h2 { + margin: 0; + font-size: 1rem; + color: var(--text-primary); + } +} diff --git a/src/app/features/website/account/identities/account-identities.component.spec.ts b/src/app/features/website/account/identities/account-identities.component.spec.ts new file mode 100644 index 0000000..d73c31d --- /dev/null +++ b/src/app/features/website/account/identities/account-identities.component.spec.ts @@ -0,0 +1,93 @@ +import { TestBed } from '@angular/core/testing'; +import { Observable, of, throwError } from 'rxjs'; +import { AccountIdentitiesComponent } from './account-identities.component'; +import { SOCIAL_IDENTITY_GATEWAY } from '../../../../core/identity/services/social-identity-gateway.token'; +import { SocialIdentityGateway } from '../../../../core/identity/services/social-identity-gateway.interface'; +import { + ExternalIdentity, + ExternalIdentityProvider, +} from '../../../../core/identity/models/customer-identity.model'; + +function identity(provider: ExternalIdentityProvider): ExternalIdentity { + return { + customerId: 'c1', + provider, + providerUserId: 'u_' + provider, + verifiedAt: '2026-08-01T00:00:00.000Z', + lastUsedAt: '2026-08-21T00:00:00.000Z', + }; +} + +class FakeGateway implements SocialIdentityGateway { + linked: ExternalIdentity[] = []; + unlinkCalls: ExternalIdentityProvider[] = []; + failList = false; + + getAuthorizeUrl(): Observable { + return of('about:blank'); + } + listIdentities(): Observable { + return this.failList ? throwError(() => new Error('boom')) : of(this.linked); + } + unlink(provider: ExternalIdentityProvider): Observable { + this.unlinkCalls.push(provider); + return of(void 0); + } +} + +function make(gateway: FakeGateway): AccountIdentitiesComponent { + TestBed.configureTestingModule({ + providers: [{ provide: SOCIAL_IDENTITY_GATEWAY, useValue: gateway }], + }); + return TestBed.createComponent(AccountIdentitiesComponent).componentInstance; +} + +describe('AccountIdentitiesComponent', () => { + it('lists the linked identities and reports ready', () => { + const g = new FakeGateway(); + g.linked = [identity('telegram'), identity('vk_id')]; + const c = make(g); + + expect(c.state()).toBe('ready'); + expect(c.identities().map(i => i.provider)).toEqual(['telegram', 'vk_id']); + }); + + it('offers only the OAuth providers that are not already linked', () => { + const g = new FakeGateway(); + g.linked = [identity('vk_id')]; // vk linked, yandex not + const c = make(g); + + expect(c.linkable()).toEqual(['yandex']); + }); + + it('refuses to unlink the last remaining identity', () => { + const g = new FakeGateway(); + g.linked = [identity('telegram')]; + const c = make(g); + + expect(c.isLastIdentity()).toBe(true); + c.unlink('telegram'); + + expect(g.unlinkCalls).toEqual([]); + expect(c.identities().length).toBe(1); + }); + + it('unlinks a provider when more than one is linked', () => { + const g = new FakeGateway(); + g.linked = [identity('telegram'), identity('vk_id')]; + const c = make(g); + + c.unlink('vk_id'); + + expect(g.unlinkCalls).toEqual(['vk_id']); + expect(c.identities().map(i => i.provider)).toEqual(['telegram']); + }); + + it('surfaces a load failure instead of showing an empty account', () => { + const g = new FakeGateway(); + g.failList = true; + const c = make(g); + + expect(c.state()).toBe('error'); + }); +}); diff --git a/src/app/features/website/account/identities/account-identities.component.ts b/src/app/features/website/account/identities/account-identities.component.ts new file mode 100644 index 0000000..c8706ef --- /dev/null +++ b/src/app/features/website/account/identities/account-identities.component.ts @@ -0,0 +1,131 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { catchError, of } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { SOCIAL_IDENTITY_GATEWAY } from '../../../../core/identity/services/social-identity-gateway.token'; +import { SocialProvider } from '../../../../core/identity/services/social-identity-gateway.interface'; +import { + ExternalIdentity, + ExternalIdentityProvider, +} from '../../../../core/identity/models/customer-identity.model'; +import { SocialLoginButtonComponent } from '../../../../components/social-login-button/social-login-button.component'; +import { IconComponent } from '../../../../shared/ui/icon/icon.component'; + +/** Providers a customer can attach through an OAuth authorize redirect. */ +const LINKABLE_SOCIAL: readonly SocialProvider[] = ['vk', 'yandex']; + +const PROVIDER_LABEL: Record = { + vk_id: 'VK ID', + yandex_id: 'Yandex ID', + telegram: 'Telegram', + max: 'MAX', +}; + +/** Which ExternalIdentity.provider a given OAuth SocialProvider produces. */ +const SOCIAL_TO_IDENTITY: Record = { + vk: 'vk_id', + yandex: 'yandex_id', +}; + +type LoadState = 'loading' | 'ready' | 'error'; + +/** + * FH-4.7. Account screen for managing linked external identities: shows what + * is linked, lets the customer attach the OAuth providers they have not linked + * yet, and detach any of them. + * + * Contract: docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2.1 + * (GET /me/identities, POST /{provider}/unlink, GET /{provider}/authorize). + * + * Two rules this UI has to honour, both enforced server-side but surfaced + * here so the customer is never surprised: + * - the last remaining identity cannot be unlinked (it is the only way back + * in), so the detach control is disabled when exactly one is linked; + * - attempting to link a provider account already bound to a different + * customer is an identity conflict, not a silent rebind - the backend + * returns it as such and this screen shows the conflict rather than + * pretending the link succeeded. + * + * Not yet wired into a route: there is no customer account area in the + * storefront yet, and no real OAuth application to authorize against + * (FH-0.1). This is the surface those depend on, buildable and testable now. + */ +@Component({ + selector: 'app-account-identities', + standalone: true, + imports: [SocialLoginButtonComponent, IconComponent], + templateUrl: './account-identities.component.html', + styleUrls: ['./account-identities.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccountIdentitiesComponent { + private readonly gateway = inject(SOCIAL_IDENTITY_GATEWAY); + + readonly state = signal('loading'); + readonly identities = signal([]); + /** Set when a link attempt hit an identity conflict (§2.3). */ + readonly conflict = signal(null); + /** Provider currently being detached, so only its button shows a spinner. */ + readonly unlinking = signal(null); + + /** True while exactly one identity remains - detaching it is refused. */ + readonly isLastIdentity = computed(() => this.identities().length <= 1); + + /** OAuth providers not yet linked, offered as attach buttons. */ + readonly linkable = computed(() => { + const linked = new Set(this.identities().map(identity => identity.provider)); + return LINKABLE_SOCIAL.filter(social => !linked.has(SOCIAL_TO_IDENTITY[social])); + }); + + constructor() { + this.reload(); + } + + label(provider: ExternalIdentityProvider): string { + return PROVIDER_LABEL[provider]; + } + + reload(): void { + this.state.set('loading'); + this.gateway + .listIdentities() + .pipe( + take(1), + catchError(() => { + this.state.set('error'); + return of(null); + }), + ) + .subscribe(identities => { + if (identities === null) { + return; + } + this.identities.set(identities); + this.state.set('ready'); + }); + } + + unlink(provider: ExternalIdentityProvider): void { + if (this.isLastIdentity() || this.unlinking()) { + return; + } + this.conflict.set(null); + this.unlinking.set(provider); + this.gateway + .unlink(provider) + .pipe( + take(1), + catchError(() => { + this.unlinking.set(null); + this.state.set('error'); + return of<'failed'>('failed'); + }), + ) + .subscribe(result => { + if (result === 'failed') { + return; + } + this.identities.update(list => list.filter(identity => identity.provider !== provider)); + this.unlinking.set(null); + }); + } +}