feat(identity): account-linking UI + Telegram-as-identity surface (FH-4.7, FH-4.6, FH-4.8)
FH-4.7 - AccountIdentitiesComponent under features/website/account/identities/. Lists linked identities from GET /me/identities, offers attach buttons only for OAuth providers not already linked (reusing SocialLoginButtonComponent), detaches through unlink(). Refuses to detach the last remaining identity - it is the only way back in - with the control disabled and an explanatory title, matching the backend's last-identity 409. Loading / error / ready states; a load failure surfaces an error rather than rendering an empty account, and a slot carries the identity-conflict message from PHASE-8 §2.3. 6 unit tests. Not wired into a route: the storefront has no customer account area yet and no live OAuth application to authorize against (FH-0.1). This is the surface both depend on, buildable and tested now. FH-4.6 (client + contract) - the gateway now separates the two provider sets. SocialProvider (vk | yandex) is what has an OAuth authorize redirect; ExternalIdentityProvider (adds telegram | max) is what can be listed and unlinked. unlink() widened to the latter so Telegram detaches through the same path as VK, with no second code path. The dev local gateway seeds a Telegram identity so the linking screen is exercisable before any real provider exists. PHASE-8 §2.6 specifies the backend migration: a Telegram login writes an ExternalIdentity row under the same uniqueness and identity-conflict rule as VK, appears in /me/identities, is removable subject to the last-identity 409, and keeps customer (marketplace_session) and admin (bo_session) sessions as distinct cookies - closing the shared customer/admin Telegram session the audit flagged. The identity row and the messaging BotConversationBinding stay separate records. FH-4.8 - PHASE-8 §3 now states email/phone OTP's position explicitly: recovery when a linked messenger is unreachable and an addable second factor, never the primary login, and one more identity on the same customer rather than a parallel account. 262 tests pass. Build green, boundaries and cycles green, bundle scan clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ExternalIdentity[]>(`${this.base}/me/identities`);
|
||||
}
|
||||
|
||||
unlink(provider: SocialProvider): Observable<void> {
|
||||
unlink(provider: ExternalIdentityProvider): Observable<void> {
|
||||
return this.http.post<void>(`${this.base}/${provider}/unlink`, {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ExternalIdentity[]>;
|
||||
|
||||
/** Unlinks a provider from the authenticated customer. */
|
||||
unlink(provider: SocialProvider): Observable<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<ExternalIdentity[]>([
|
||||
{
|
||||
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<string> {
|
||||
return of(`about:blank#${provider}-oauth-not-configured`);
|
||||
}
|
||||
|
||||
listIdentities(): Observable<ExternalIdentity[]> {
|
||||
return of([]);
|
||||
return of(this.linked());
|
||||
}
|
||||
|
||||
unlink(): Observable<void> {
|
||||
unlink(provider: ExternalIdentityProvider): Observable<void> {
|
||||
this.linked.update(list => list.filter(identity => identity.provider !== provider));
|
||||
return of(void 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<section class="identities">
|
||||
<header class="identities__header">
|
||||
<h1>Connected accounts</h1>
|
||||
<p>Sign in with any of these. You can add or remove them at any time.</p>
|
||||
</header>
|
||||
|
||||
@switch (state()) {
|
||||
@case ('loading') {
|
||||
<p class="identities__status" role="status">Loading…</p>
|
||||
}
|
||||
@case ('error') {
|
||||
<div class="identities__status identities__status--error" role="alert">
|
||||
<app-icon name="warning" [size]="18" />
|
||||
<span>Could not load your connected accounts.</span>
|
||||
<button type="button" class="identities__retry" (click)="reload()">Retry</button>
|
||||
</div>
|
||||
}
|
||||
@case ('ready') {
|
||||
@if (conflict(); as message) {
|
||||
<div class="identities__conflict" role="alert">
|
||||
<app-icon name="warning" [size]="18" />
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul class="identities__list">
|
||||
@for (identity of identities(); track identity.provider) {
|
||||
<li class="identity">
|
||||
<span class="identity__icon"><app-icon name="user" [size]="20" /></span>
|
||||
<span class="identity__body">
|
||||
<span class="identity__name">{{ label(identity.provider) }}</span>
|
||||
@if (identity.displayName) {
|
||||
<span class="identity__detail">{{ identity.displayName }}</span>
|
||||
}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="identity__unlink"
|
||||
[disabled]="isLastIdentity() || unlinking() === identity.provider"
|
||||
[attr.title]="isLastIdentity() ? 'This is your only way to sign in - add another before removing it.' : null"
|
||||
(click)="unlink(identity.provider)"
|
||||
>
|
||||
@if (unlinking() === identity.provider) {
|
||||
<app-icon name="refresh" [size]="16" />
|
||||
} @else {
|
||||
<app-icon name="trash" [size]="16" />
|
||||
}
|
||||
<span>Remove</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@if (linkable().length) {
|
||||
<div class="identities__add">
|
||||
<h2>Add another</h2>
|
||||
@for (provider of linkable(); track provider) {
|
||||
<app-social-login-button [provider]="provider" returnTo="/account/identities" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
return of('about:blank');
|
||||
}
|
||||
listIdentities(): Observable<ExternalIdentity[]> {
|
||||
return this.failList ? throwError(() => new Error('boom')) : of(this.linked);
|
||||
}
|
||||
unlink(provider: ExternalIdentityProvider): Observable<void> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<ExternalIdentityProvider, string> = {
|
||||
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<SocialProvider, ExternalIdentityProvider> = {
|
||||
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<LoadState>('loading');
|
||||
readonly identities = signal<ExternalIdentity[]>([]);
|
||||
/** Set when a link attempt hit an identity conflict (§2.3). */
|
||||
readonly conflict = signal<string | null>(null);
|
||||
/** Provider currently being detached, so only its button shows a spinner. */
|
||||
readonly unlinking = signal<ExternalIdentityProvider | null>(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<SocialProvider[]>(() => {
|
||||
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<ExternalIdentity[] | null>(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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user