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.
|
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.*
|
*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
|
- [~] **FH-4.6 — Migrate Telegram onto `ExternalIdentity`** · M · **client + contract done 2026-08-21; backend write path pending**
|
||||||
Telegram becomes one provider among several rather than the schema's only key. Ends the shared customer/admin Telegram session their audit flagged.
|
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
|
- [x] **FH-4.7 — Account linking UI** · M · **done 2026-08-21**
|
||||||
`/me/identities` — show linked providers, link, unlink, and surface the conflict-resolution path from FH-4.3.
|
`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
|
- [x] **FH-4.8 — Email/phone OTP repositioned as recovery** · S · **done 2026-08-21**
|
||||||
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.
|
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 |
|
| 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) |
|
| 2 — Contracts | 14 | — | 0 | 1 rejected (FH-2.12) |
|
||||||
| 3 — Proof | 3 | 1 | 1 | see note below |
|
| 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 | — |
|
| Ops | 1 | — | 2 | — |
|
||||||
| Process | 5 | — | 1 | — |
|
| Process | 5 | — | 1 | — |
|
||||||
| **Total** | **27** | **4** | **11** | 1 rejected |
|
| **Total** | **29** | **4** | **9** | 1 rejected |
|
||||||
|
|
||||||
**Landed 2026-08-21**
|
**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.
|
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.
|
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
|
## 4. Sprint 8.4 — MAX + Telegram bot channels
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
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';
|
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`);
|
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`, {});
|
return this.http.post<void>(`${this.base}/${provider}/unlink`, {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Observable } from 'rxjs';
|
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';
|
export type SocialProvider = 'vk' | 'yandex';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +40,11 @@ export interface SocialIdentityGateway {
|
|||||||
/** Providers currently linked to the authenticated customer. */
|
/** Providers currently linked to the authenticated customer. */
|
||||||
listIdentities(): Observable<ExternalIdentity[]>;
|
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([]);
|
request.flush([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unlinks per provider', () => {
|
it('unlinks an OAuth provider by its identity name', () => {
|
||||||
gateway.unlink('yandex').subscribe();
|
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');
|
expect(request.request.method).toBe('POST');
|
||||||
request.flush(null);
|
request.flush(null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import { Observable, of } from 'rxjs';
|
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';
|
import { SocialIdentityGateway, SocialProvider } from './social-identity-gateway.interface';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,15 +16,31 @@ import { SocialIdentityGateway, SocialProvider } from './social-identity-gateway
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SocialIdentityLocalGateway implements SocialIdentityGateway {
|
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> {
|
getAuthorizeUrl(provider: SocialProvider): Observable<string> {
|
||||||
return of(`about:blank#${provider}-oauth-not-configured`);
|
return of(`about:blank#${provider}-oauth-not-configured`);
|
||||||
}
|
}
|
||||||
|
|
||||||
listIdentities(): Observable<ExternalIdentity[]> {
|
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);
|
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