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:
sdarbinyan
2026-08-21 22:36:36 +04:00
parent 771dce9e29
commit 1c87a53f02
10 changed files with 487 additions and 22 deletions

View File

@@ -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`, {});
}
}

View File

@@ -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>;
}

View File

@@ -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);
});

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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');
});
});

View File

@@ -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);
});
}
}