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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user