Merge improvements/fork-harvest into main
Fork-harvest brings: the ip-api.com geo fix, credential bundle scan, mock gateways out of production, JIT compiler dropped (1.55->1.04 MB), host hardening, provider-agnostic identity + VK/Yandex + account linking, and the backend contracts consolidated into one BACKEND-INTEGRATION.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> # Conflicts: # docs/backend/BACKEND-HANDOFF.md # docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md
This commit is contained in:
@@ -16,6 +16,7 @@ import { MockMediaRepository } from './core/media/mock-media-repository.service'
|
||||
import { ApiConfigService } from './core/config/api-config.service';
|
||||
import { TenantResolverService } from './core/config/tenant-resolver.service';
|
||||
import { environment } from '../environments/environment';
|
||||
import { MOCK_GATEWAY_PROVIDERS } from './mock-gateway.providers';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -72,6 +73,9 @@ export const appConfig: ApplicationConfig = {
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
registrationStrategy: 'registerWhenStable:30000'
|
||||
})
|
||||
}),
|
||||
// Empty in production - the file is swapped at build time so no mock
|
||||
// gateway is even importable there. See mock-gateway.providers.ts.
|
||||
...MOCK_GATEWAY_PROVIDERS
|
||||
]
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<button
|
||||
type="button"
|
||||
class="social-login-button"
|
||||
[class]="'social-login-button--' + provider()"
|
||||
[disabled]="loading()"
|
||||
(click)="startLogin()"
|
||||
>
|
||||
<app-icon name="user" [size]="18" />
|
||||
<span>{{ label() }}</span>
|
||||
</button>
|
||||
@@ -1,4 +1,4 @@
|
||||
.vk-id-login {
|
||||
.social-login-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
|
||||
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 { IconComponent } from '../../shared/ui/icon/icon.component';
|
||||
|
||||
const PROVIDER_LABEL: Record<SocialProvider, string> = {
|
||||
vk: 'Continue with VK ID',
|
||||
yandex: 'Continue with Yandex ID',
|
||||
};
|
||||
|
||||
/**
|
||||
* One button per social provider, per v3.1 §14 (VK ID is the primary
|
||||
* storefront social login; Yandex ID is the second instance of the same
|
||||
* flow, not a separate integration).
|
||||
*
|
||||
* Deliberately not spliced into TelegramLoginComponent's dialog yet. That
|
||||
* component is the live customer login surface, and adding providers to it
|
||||
* belongs in the pass that also demotes Telegram to one ExternalIdentity
|
||||
* among several (FH-4.6) - not before a real OAuth application exists to
|
||||
* test against.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-social-login-button',
|
||||
standalone: true,
|
||||
imports: [IconComponent],
|
||||
templateUrl: './social-login-button.component.html',
|
||||
styleUrls: ['./social-login-button.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SocialLoginButtonComponent {
|
||||
private readonly gateway = inject(SOCIAL_IDENTITY_GATEWAY);
|
||||
|
||||
readonly provider = input.required<SocialProvider>();
|
||||
/** Where to land after the callback. Validated backend-side. */
|
||||
readonly returnTo = input<string | undefined>(undefined);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly label = computed(() => PROVIDER_LABEL[this.provider()]);
|
||||
|
||||
startLogin(): void {
|
||||
this.loading.set(true);
|
||||
this.gateway
|
||||
.getAuthorizeUrl(this.provider(), this.returnTo())
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: url => {
|
||||
this.loading.set(false);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = url;
|
||||
}
|
||||
},
|
||||
error: () => this.loading.set(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<button type="button" class="vk-id-login" [disabled]="loading()" (click)="startLogin()">
|
||||
<app-icon name="user" [size]="18" />
|
||||
<span>Continue with VK ID</span>
|
||||
</button>
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { VK_ID_GATEWAY } from '../../core/identity/services/vk-id-gateway.token';
|
||||
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
||||
|
||||
/**
|
||||
* Standalone VK ID login button, per Sprint 0.1 ("do all after vk" - VK ID
|
||||
* is the primary storefront social login going forward, per v3.1 §14).
|
||||
* Deliberately not wired into TelegramLoginComponent's dialog yet - that
|
||||
* component is the live, working customer/admin login surface, and
|
||||
* splicing a second provider into it needs its own careful pass once a
|
||||
* real VK OAuth app exists to test against, not a mock-backed bolt-on.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-vk-id-login',
|
||||
standalone: true,
|
||||
imports: [CommonModule, IconComponent],
|
||||
templateUrl: './vk-id-login.component.html',
|
||||
styleUrls: ['./vk-id-login.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class VkIdLoginComponent {
|
||||
private readonly gateway = inject(VK_ID_GATEWAY);
|
||||
|
||||
readonly loading = signal(false);
|
||||
|
||||
startLogin(): void {
|
||||
this.loading.set(true);
|
||||
this.gateway.getAuthorizeUrl().pipe(take(1)).subscribe(url => {
|
||||
this.loading.set(false);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = url;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { AnalyticsGateway } from './analytics-gateway.interface';
|
||||
import { AnalyticsLocalGateway } from './analytics-local.gateway';
|
||||
import { AnalyticsApiGateway } from './analytics-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
|
||||
export const ANALYTICS_GATEWAY = new InjectionToken<AnalyticsGateway>('ANALYTICS_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(AnalyticsLocalGateway) : inject(AnalyticsApiGateway)),
|
||||
factory: () => inject(AnalyticsApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { ServerCartGateway } from './server-cart-gateway.interface';
|
||||
import { ServerCartLocalGateway } from './server-cart-local.gateway';
|
||||
import { ServerCartApiGateway } from './server-cart-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
|
||||
export const SERVER_CART_GATEWAY = new InjectionToken<ServerCartGateway>('SERVER_CART_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(ServerCartLocalGateway) : inject(ServerCartApiGateway)),
|
||||
factory: () => inject(ServerCartApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { MallContentGateway } from './mall-content-gateway.interface';
|
||||
import { MallContentLocalGateway } from './mall-content-local.gateway';
|
||||
import { MallContentApiGateway } from './mall-content-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-10-CONTENT-MODULES-CONTRACT.md. */
|
||||
export const MALL_CONTENT_GATEWAY = new InjectionToken<MallContentGateway>('MALL_CONTENT_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(MallContentLocalGateway) : inject(MallContentApiGateway)),
|
||||
factory: () => inject(MallContentApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { FinanceGateway } from './finance-gateway.interface';
|
||||
import { FinanceLocalGateway } from './finance-local.gateway';
|
||||
import { FinanceApiGateway } from './finance-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md. */
|
||||
export const FINANCE_GATEWAY = new InjectionToken<FinanceGateway>('FINANCE_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(FinanceLocalGateway) : inject(FinanceApiGateway)),
|
||||
factory: () => inject(FinanceApiGateway),
|
||||
});
|
||||
|
||||
@@ -9,12 +9,16 @@ export interface Customer {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type ExternalIdentityProvider = 'vk_id' | 'telegram' | 'max';
|
||||
export type ExternalIdentityProvider = 'vk_id' | 'yandex_id' | 'telegram' | 'max';
|
||||
|
||||
export interface ExternalIdentity {
|
||||
customerId: string;
|
||||
provider: ExternalIdentityProvider;
|
||||
providerUserId: string;
|
||||
/** Not every provider returns one - VK frequently does not. */
|
||||
email?: string;
|
||||
phone?: string;
|
||||
displayName?: string;
|
||||
verifiedAt: string;
|
||||
lastUsedAt: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ExternalIdentity, ExternalIdentityProvider } from '../models/customer-identity.model';
|
||||
import { SocialIdentityGateway, SocialProvider } from './social-identity-gateway.interface';
|
||||
|
||||
/**
|
||||
* Contract: docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2.
|
||||
*
|
||||
* Provider-agnostic by construction - VK ID and Yandex ID differ only in the
|
||||
* path segment, because the differences that matter (PKCE, VK's device_id,
|
||||
* Yandex's Basic-auth token exchange) live entirely on the backend.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SocialIdentityApiGateway implements SocialIdentityGateway {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly base = '/api/identity/v1';
|
||||
|
||||
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string> {
|
||||
const params = returnTo ? new HttpParams().set('returnTo', returnTo) : undefined;
|
||||
return this.http
|
||||
.get<{ url: string }>(`${this.base}/${provider}/authorize`, { params })
|
||||
.pipe(map(response => response.url));
|
||||
}
|
||||
|
||||
listIdentities(): Observable<ExternalIdentity[]> {
|
||||
return this.http.get<ExternalIdentity[]>(`${this.base}/me/identities`);
|
||||
}
|
||||
|
||||
unlink(provider: ExternalIdentityProvider): Observable<void> {
|
||||
return this.http.post<void>(`${this.base}/${provider}/unlink`, {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { ExternalIdentity, ExternalIdentityProvider } from '../models/customer-identity.model';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Per docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2.
|
||||
*
|
||||
* One surface, one strategy per provider behind it. The client's entire
|
||||
* involvement is "send me somewhere" and "tell me what is linked" - the
|
||||
* OAuth exchange happens backend-side and the browser never holds a client
|
||||
* secret, an access token, or a PKCE code verifier.
|
||||
*
|
||||
* Note what is absent: there is no completeCallback(). An earlier VK-only
|
||||
* version of this interface took (code, codeVerifier) from the client, which
|
||||
* forced the browser to generate and store the verifier. We are a
|
||||
* confidential client; the backend owns state and verifier, handles the
|
||||
* provider's callback itself, and redirects back with a session already set.
|
||||
*/
|
||||
export interface SocialIdentityGateway {
|
||||
/**
|
||||
* URL to navigate the browser to in order to start the flow. The backend
|
||||
* has already minted and stored the single-use state and code verifier by
|
||||
* the time this resolves.
|
||||
*
|
||||
* @param returnTo where to land after the callback completes, validated
|
||||
* backend-side against the tenant's own origin - never used as an open
|
||||
* redirect.
|
||||
*/
|
||||
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string>;
|
||||
|
||||
/** Providers currently linked to the authenticated customer. */
|
||||
listIdentities(): Observable<ExternalIdentity[]>;
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SocialIdentityApiGateway } from './social-identity-api.gateway';
|
||||
import { SocialProvider } from './social-identity-gateway.interface';
|
||||
|
||||
/**
|
||||
* FH-4.1/FH-4.2. The whole point of this surface is what it does NOT carry:
|
||||
* no client secret, no access token, no PKCE code verifier. These tests pin
|
||||
* that shape, because a future "just add completeCallback back" would look
|
||||
* harmless in review and would move verifier custody into the browser.
|
||||
*/
|
||||
describe('SocialIdentityApiGateway', () => {
|
||||
let gateway: SocialIdentityApiGateway;
|
||||
let httpTesting: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [SocialIdentityApiGateway, provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
gateway = TestBed.inject(SocialIdentityApiGateway);
|
||||
httpTesting = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => httpTesting.verify());
|
||||
|
||||
const providers: SocialProvider[] = ['vk', 'yandex'];
|
||||
|
||||
for (const provider of providers) {
|
||||
it(`asks the backend for the ${provider} authorize URL and sends no secret material`, () => {
|
||||
let resolved: string | undefined;
|
||||
gateway.getAuthorizeUrl(provider).subscribe(url => (resolved = url));
|
||||
|
||||
const request = httpTesting.expectOne(`/api/identity/v1/${provider}/authorize`);
|
||||
expect(request.request.method).toBe('GET');
|
||||
expect(request.request.body).toBeNull();
|
||||
expect(request.request.urlWithParams).not.toContain('code_verifier');
|
||||
expect(request.request.urlWithParams).not.toContain('client_secret');
|
||||
|
||||
request.flush({ url: `https://id.example.test/${provider}/authorize?state=abc` });
|
||||
expect(resolved).toContain(provider);
|
||||
});
|
||||
}
|
||||
|
||||
it('passes returnTo through as a query parameter', () => {
|
||||
gateway.getAuthorizeUrl('vk', '/cart').subscribe();
|
||||
|
||||
const request = httpTesting.expectOne(r => r.url === '/api/identity/v1/vk/authorize');
|
||||
expect(request.request.params.get('returnTo')).toBe('/cart');
|
||||
request.flush({ url: 'https://id.example.test/vk/authorize' });
|
||||
});
|
||||
|
||||
it('reads linked identities from one endpoint for every provider', () => {
|
||||
gateway.listIdentities().subscribe();
|
||||
|
||||
const request = httpTesting.expectOne('/api/identity/v1/me/identities');
|
||||
expect(request.request.method).toBe('GET');
|
||||
request.flush([]);
|
||||
});
|
||||
|
||||
it('unlinks an OAuth provider by its identity name', () => {
|
||||
gateway.unlink('yandex_id').subscribe();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { SocialIdentityGateway } from './social-identity-gateway.interface';
|
||||
import { SocialIdentityApiGateway } from './social-identity-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. */
|
||||
export const SOCIAL_IDENTITY_GATEWAY = new InjectionToken<SocialIdentityGateway>('SOCIAL_IDENTITY_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => inject(SocialIdentityApiGateway),
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { ExternalIdentity, ExternalIdentityProvider } from '../models/customer-identity.model';
|
||||
import { SocialIdentityGateway, SocialProvider } from './social-identity-gateway.interface';
|
||||
|
||||
/**
|
||||
* Development stand-in. No VK or Yandex OAuth application is registered yet,
|
||||
* and registering one is blocked on a decision that has to be made before any
|
||||
* of this can work for real: both providers validate redirect_uri against an
|
||||
* exact registered list, so a multi-tenant platform needs one central
|
||||
* identity host as the sole registered callback, with the tenant carried in
|
||||
* the signed state. See FORK-HARVEST-TODO.md FH-0.1.
|
||||
*
|
||||
* Returning a data: URL rather than a fake provider URL is deliberate - it
|
||||
* cannot be mistaken for a working flow if this ever runs outside dev.
|
||||
*/
|
||||
@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(this.linked());
|
||||
}
|
||||
|
||||
unlink(provider: ExternalIdentityProvider): Observable<void> {
|
||||
this.linked.update(list => list.filter(identity => identity.provider !== provider));
|
||||
return of(void 0);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Customer } from '../models/customer-identity.model';
|
||||
import { VkIdGateway } from './vk-id-gateway.interface';
|
||||
|
||||
/**
|
||||
* Contract: docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2.
|
||||
* OAuth completion happens backend-side; this is the client-facing surface
|
||||
* only - getAuthorizeUrl navigates the browser there, completeCallback hands
|
||||
* back the code/verifier pair for the backend to exchange.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VkIdApiGateway implements VkIdGateway {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
getAuthorizeUrl(): Observable<string> {
|
||||
return this.http
|
||||
.get<{ url: string }>('/api/identity/v1/vk/authorize')
|
||||
.pipe(map(response => response.url));
|
||||
}
|
||||
|
||||
completeCallback(code: string, codeVerifier: string): Observable<Customer> {
|
||||
return this.http.post<Customer>('/api/identity/v1/vk/callback', { code, codeVerifier });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import { Customer } from '../models/customer-identity.model';
|
||||
|
||||
/** Per docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. OAuth completion is backend-side; this is the client-facing surface only. */
|
||||
export interface VkIdGateway {
|
||||
getAuthorizeUrl(): Observable<string>;
|
||||
completeCallback(code: string, codeVerifier: string): Observable<Customer>;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { VkIdGateway } from './vk-id-gateway.interface';
|
||||
import { VkIdLocalGateway } from './vk-id-local.gateway';
|
||||
import { VkIdApiGateway } from './vk-id-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2. */
|
||||
export const VK_ID_GATEWAY = new InjectionToken<VkIdGateway>('VK_ID_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(VkIdLocalGateway) : inject(VkIdApiGateway)),
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { Customer } from '../models/customer-identity.model';
|
||||
import { VkIdGateway } from './vk-id-gateway.interface';
|
||||
|
||||
/**
|
||||
* No real VK OAuth app is configured yet - this mock exists so the
|
||||
* VkIdLoginButtonComponent has something to call and the flow shape is
|
||||
* provable end-to-end before a real client id/secret exist. Swap
|
||||
* VK_ID_GATEWAY once docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §2
|
||||
* ships; the real backend completes OAuth server-side, this interface never
|
||||
* exposes a client secret regardless of implementation.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VkIdLocalGateway implements VkIdGateway {
|
||||
getAuthorizeUrl(): Observable<string> {
|
||||
return of('about:blank#vk-id-not-configured');
|
||||
}
|
||||
|
||||
completeCallback(_code: string, _codeVerifier: string): Observable<Customer> {
|
||||
return of({
|
||||
id: 'customer_vk_mock',
|
||||
marketplaceId: 'default',
|
||||
status: 'active',
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { ConnectorGateway } from './connector-gateway.interface';
|
||||
import { ConnectorLocalGateway } from './connector-local.gateway';
|
||||
import { ConnectorApiGateway } from './connector-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7. */
|
||||
export const CONNECTOR_GATEWAY = new InjectionToken<ConnectorGateway>('CONNECTOR_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(ConnectorLocalGateway) : inject(ConnectorApiGateway)),
|
||||
factory: () => inject(ConnectorApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { MarketplaceGateway } from './marketplace-gateway.interface';
|
||||
import { MarketplaceLocalGateway } from './marketplace-local.gateway';
|
||||
import { MarketplaceApiGateway } from './marketplace-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md. */
|
||||
export const MARKETPLACE_GATEWAY = new InjectionToken<MarketplaceGateway>('MARKETPLACE_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(MarketplaceLocalGateway) : inject(MarketplaceApiGateway)),
|
||||
factory: () => inject(MarketplaceApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { MarketplaceRevisionGateway } from './marketplace-revision-gateway.interface';
|
||||
import { MarketplaceRevisionLocalGateway } from './marketplace-revision-local.gateway';
|
||||
import { MarketplaceRevisionApiGateway } from './marketplace-revision-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §5. */
|
||||
export const MARKETPLACE_REVISION_GATEWAY = new InjectionToken<MarketplaceRevisionGateway>('MARKETPLACE_REVISION_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(MarketplaceRevisionLocalGateway) : inject(MarketplaceRevisionApiGateway)),
|
||||
factory: () => inject(MarketplaceRevisionApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { OfferGateway } from './offer-gateway.interface';
|
||||
import { OfferLocalGateway } from './offer-local.gateway';
|
||||
import { OfferApiGateway } from './offer-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7. */
|
||||
export const OFFER_GATEWAY = new InjectionToken<OfferGateway>('OFFER_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(OfferLocalGateway) : inject(OfferApiGateway)),
|
||||
factory: () => inject(OfferApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { PartnerHierarchyGateway } from './partner-hierarchy-gateway.interface';
|
||||
import { PartnerHierarchyLocalGateway } from './partner-hierarchy-local.gateway';
|
||||
import { PartnerHierarchyApiGateway } from './partner-hierarchy-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md §4, §6. */
|
||||
export const PARTNER_HIERARCHY_GATEWAY = new InjectionToken<PartnerHierarchyGateway>('PARTNER_HIERARCHY_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(PartnerHierarchyLocalGateway) : inject(PartnerHierarchyApiGateway)),
|
||||
factory: () => inject(PartnerHierarchyApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { PermissionGateway } from './permission-gateway.interface';
|
||||
import { PermissionLocalGateway } from './permission-local.gateway';
|
||||
import { PermissionApiGateway } from './permission-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §2-3. */
|
||||
export const PERMISSION_GATEWAY = new InjectionToken<PermissionGateway>('PERMISSION_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(PermissionLocalGateway) : inject(PermissionApiGateway)),
|
||||
factory: () => inject(PermissionApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { FxQuoteGateway } from './fx-quote-gateway.interface';
|
||||
import { FxQuoteLocalGateway } from './fx-quote-local.gateway';
|
||||
import { FxQuoteApiGateway } from './fx-quote-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.1. */
|
||||
export const FX_QUOTE_GATEWAY = new InjectionToken<FxQuoteGateway>('FX_QUOTE_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(FxQuoteLocalGateway) : inject(FxQuoteApiGateway)),
|
||||
factory: () => inject(FxQuoteApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { SellerGateway } from './seller-gateway.interface';
|
||||
import { SellerLocalGateway } from './seller-local.gateway';
|
||||
import { SellerApiGateway } from './seller-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-5-SELLER-PORTAL-CONTRACT.md §3. */
|
||||
export const SELLER_GATEWAY = new InjectionToken<SellerGateway>('SELLER_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(SellerLocalGateway) : inject(SellerApiGateway)),
|
||||
factory: () => inject(SellerApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { AdminDashboardMetricsGateway } from './admin-dashboard-metrics.gateway.interface';
|
||||
import { AdminDashboardMetricsLocalGateway } from './admin-dashboard-metrics.local.gateway';
|
||||
import { AdminDashboardMetricsApiGateway } from './admin-dashboard-metrics-api.gateway';
|
||||
|
||||
/** Swap point. */
|
||||
export const ADMIN_DASHBOARD_METRICS_GATEWAY = new InjectionToken<AdminDashboardMetricsGateway>('ADMIN_DASHBOARD_METRICS_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(AdminDashboardMetricsLocalGateway) : inject(AdminDashboardMetricsApiGateway)),
|
||||
factory: () => inject(AdminDashboardMetricsApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { AdminModerationGateway } from './admin-moderation-gateway.interface';
|
||||
import { AdminModerationLocalGateway } from './admin-moderation-local.gateway';
|
||||
import { AdminModerationApiGateway } from './admin-moderation-api.gateway';
|
||||
|
||||
/** Swap point. */
|
||||
export const ADMIN_MODERATION_GATEWAY = new InjectionToken<AdminModerationGateway>('ADMIN_MODERATION_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(AdminModerationLocalGateway) : inject(AdminModerationApiGateway)),
|
||||
factory: () => inject(AdminModerationApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { AdminMonitoringGateway } from './admin-monitoring-gateway.interface';
|
||||
import { AdminMonitoringLocalGateway } from './admin-monitoring-local.gateway';
|
||||
import { AdminMonitoringApiGateway } from './admin-monitoring-api.gateway';
|
||||
|
||||
/** Swap point. */
|
||||
export const ADMIN_MONITORING_GATEWAY = new InjectionToken<AdminMonitoringGateway>('ADMIN_MONITORING_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(AdminMonitoringLocalGateway) : inject(AdminMonitoringApiGateway)),
|
||||
factory: () => inject(AdminMonitoringApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { AdminNotificationsGateway } from './admin-notifications-gateway.interface';
|
||||
import { AdminNotificationsLocalGateway } from './admin-notifications-local.gateway';
|
||||
import { AdminNotificationsApiGateway } from './admin-notifications-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §7. */
|
||||
export const ADMIN_NOTIFICATIONS_GATEWAY = new InjectionToken<AdminNotificationsGateway>('ADMIN_NOTIFICATIONS_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(AdminNotificationsLocalGateway) : inject(AdminNotificationsApiGateway)),
|
||||
factory: () => inject(AdminNotificationsApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { AdminOrdersGateway } from './admin-orders-gateway.interface';
|
||||
import { AdminOrdersLocalGateway } from './admin-orders-local.gateway';
|
||||
import { AdminOrdersApiGateway } from './admin-orders-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md. */
|
||||
export const ADMIN_ORDERS_GATEWAY = new InjectionToken<AdminOrdersGateway>('ADMIN_ORDERS_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(AdminOrdersLocalGateway) : inject(AdminOrdersApiGateway)),
|
||||
factory: () => inject(AdminOrdersApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { AdminProductsGateway } from './admin-products-gateway.interface';
|
||||
import { AdminProductsLocalGateway } from './admin-products-local.gateway';
|
||||
import { AdminProductsApiGateway } from './admin-products-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-CONTRACT.md §7. */
|
||||
export const ADMIN_PRODUCTS_GATEWAY = new InjectionToken<AdminProductsGateway>('ADMIN_PRODUCTS_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(AdminProductsLocalGateway) : inject(AdminProductsApiGateway)),
|
||||
factory: () => inject(AdminProductsApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
|
||||
import { AdminTransactionsLocalGateway } from './admin-transactions-local.gateway';
|
||||
import { AdminTransactionsApiGateway } from './admin-transactions-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §6. */
|
||||
export const ADMIN_TRANSACTIONS_GATEWAY = new InjectionToken<AdminTransactionsGateway>('ADMIN_TRANSACTIONS_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(AdminTransactionsLocalGateway) : inject(AdminTransactionsApiGateway)),
|
||||
factory: () => inject(AdminTransactionsApiGateway),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { AdminUsersGateway } from './admin-users-gateway.interface';
|
||||
import { AdminUsersLocalGateway } from './admin-users-local.gateway';
|
||||
import { AdminUsersApiGateway } from './admin-users-api.gateway';
|
||||
|
||||
/** Swap point for docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md §8. */
|
||||
export const ADMIN_USERS_GATEWAY = new InjectionToken<AdminUsersGateway>('ADMIN_USERS_GATEWAY', {
|
||||
providedIn: 'root',
|
||||
factory: () => (environment.useMockData ? inject(AdminUsersLocalGateway) : inject(AdminUsersApiGateway)),
|
||||
factory: () => inject(AdminUsersApiGateway),
|
||||
});
|
||||
|
||||
@@ -508,6 +508,15 @@ export class ProjectEditorFacade {
|
||||
if (!current || this.hasBlockingIssues()) {
|
||||
return false;
|
||||
}
|
||||
// FH-E.5 / PHASE-9 §5. This is a LOCAL preview publish: it applies the
|
||||
// config to the in-memory runtime and saves the draft to localStorage.
|
||||
// localStorage is a recovery cache here (see draftRestored), never the
|
||||
// published source of truth. Real publish must round-trip through the
|
||||
// revision API - POST .../revisions/{id}/publish - which creates an
|
||||
// immutable server-side revision and flips the published pointer in one
|
||||
// transaction. Until that endpoint exists this stays local-only and must
|
||||
// not be treated as authoritative; when it ships, this method awaits the
|
||||
// server response and only then marks status 'published'.
|
||||
this.runtime.reloadFromBootstrap(current);
|
||||
const savedAt = this.draftStorage.save(current);
|
||||
this.state.update(state => ({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
9
src/app/mock-gateway.providers.production.ts
Normal file
9
src/app/mock-gateway.providers.production.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Provider } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Production replacement for mock-gateway.providers.ts (angular.json
|
||||
* fileReplacements). Imports nothing on purpose: this file existing is what
|
||||
* guarantees no *LocalGateway or its fixtures can be reached from a
|
||||
* production build.
|
||||
*/
|
||||
export const MOCK_GATEWAY_PROVIDERS: Provider[] = [];
|
||||
91
src/app/mock-gateway.providers.ts
Normal file
91
src/app/mock-gateway.providers.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Provider } from '@angular/core';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
import { ADMIN_DASHBOARD_METRICS_GATEWAY } from './features/admin/dashboard/services/admin-dashboard-metrics-gateway.token';
|
||||
import { AdminDashboardMetricsLocalGateway } from './features/admin/dashboard/services/admin-dashboard-metrics.local.gateway';
|
||||
import { ADMIN_MODERATION_GATEWAY } from './features/admin/moderation/services/admin-moderation-gateway.token';
|
||||
import { AdminModerationLocalGateway } from './features/admin/moderation/services/admin-moderation-local.gateway';
|
||||
import { ADMIN_MONITORING_GATEWAY } from './features/admin/monitoring/services/admin-monitoring-gateway.token';
|
||||
import { AdminMonitoringLocalGateway } from './features/admin/monitoring/services/admin-monitoring-local.gateway';
|
||||
import { ADMIN_NOTIFICATIONS_GATEWAY } from './features/admin/notifications/services/admin-notifications-gateway.token';
|
||||
import { AdminNotificationsLocalGateway } from './features/admin/notifications/services/admin-notifications-local.gateway';
|
||||
import { ADMIN_ORDERS_GATEWAY } from './features/admin/orders/services/admin-orders-gateway.token';
|
||||
import { AdminOrdersLocalGateway } from './features/admin/orders/services/admin-orders-local.gateway';
|
||||
import { ADMIN_PRODUCTS_GATEWAY } from './features/admin/products/services/admin-products-gateway.token';
|
||||
import { AdminProductsLocalGateway } from './features/admin/products/services/admin-products-local.gateway';
|
||||
import { ADMIN_TRANSACTIONS_GATEWAY } from './features/admin/transactions/services/admin-transactions-gateway.token';
|
||||
import { AdminTransactionsLocalGateway } from './features/admin/transactions/services/admin-transactions-local.gateway';
|
||||
import { ADMIN_USERS_GATEWAY } from './features/admin/users/services/admin-users-gateway.token';
|
||||
import { AdminUsersLocalGateway } from './features/admin/users/services/admin-users-local.gateway';
|
||||
import { ANALYTICS_GATEWAY } from './core/analytics/services/analytics-gateway.token';
|
||||
import { AnalyticsLocalGateway } from './core/analytics/services/analytics-local.gateway';
|
||||
import { CONNECTOR_GATEWAY } from './core/integrations/services/connector-gateway.token';
|
||||
import { ConnectorLocalGateway } from './core/integrations/services/connector-local.gateway';
|
||||
import { FINANCE_GATEWAY } from './core/finance/services/finance-gateway.token';
|
||||
import { FinanceLocalGateway } from './core/finance/services/finance-local.gateway';
|
||||
import { FX_QUOTE_GATEWAY } from './core/pricing/services/fx-quote-gateway.token';
|
||||
import { FxQuoteLocalGateway } from './core/pricing/services/fx-quote-local.gateway';
|
||||
import { MALL_CONTENT_GATEWAY } from './core/content-modules/services/mall-content-gateway.token';
|
||||
import { MallContentLocalGateway } from './core/content-modules/services/mall-content-local.gateway';
|
||||
import { MARKETPLACE_GATEWAY } from './core/marketplace-registry/services/marketplace-gateway.token';
|
||||
import { MarketplaceLocalGateway } from './core/marketplace-registry/services/marketplace-local.gateway';
|
||||
import { MARKETPLACE_REVISION_GATEWAY } from './core/marketplace-registry/services/marketplace-revision-gateway.token';
|
||||
import { MarketplaceRevisionLocalGateway } from './core/marketplace-registry/services/marketplace-revision-local.gateway';
|
||||
import { OFFER_GATEWAY } from './core/offers/services/offer-gateway.token';
|
||||
import { OfferLocalGateway } from './core/offers/services/offer-local.gateway';
|
||||
import { PARTNER_HIERARCHY_GATEWAY } from './core/partner-hierarchy/services/partner-hierarchy-gateway.token';
|
||||
import { PartnerHierarchyLocalGateway } from './core/partner-hierarchy/services/partner-hierarchy-local.gateway';
|
||||
import { PERMISSION_GATEWAY } from './core/permissions/services/permission-gateway.token';
|
||||
import { PermissionLocalGateway } from './core/permissions/services/permission-local.gateway';
|
||||
import { SELLER_GATEWAY } from './core/sellers/services/seller-gateway.token';
|
||||
import { SellerLocalGateway } from './core/sellers/services/seller-local.gateway';
|
||||
import { SERVER_CART_GATEWAY } from './core/cart/services/server-cart-gateway.token';
|
||||
import { ServerCartLocalGateway } from './core/cart/services/server-cart-local.gateway';
|
||||
import { SOCIAL_IDENTITY_GATEWAY } from './core/identity/services/social-identity-gateway.token';
|
||||
import { SocialIdentityLocalGateway } from './core/identity/services/social-identity-local.gateway';
|
||||
|
||||
/**
|
||||
* Development-only gateway overrides.
|
||||
*
|
||||
* This file is swapped for mock-gateway.providers.production.ts at build time
|
||||
* (see angular.json fileReplacements), the same mechanism
|
||||
* mock-data.interceptor.production.ts already uses. The production copy
|
||||
* imports nothing, so no *LocalGateway class - and none of its seeded
|
||||
* fixtures - can reach a production bundle.
|
||||
*
|
||||
* It used to live inside each token factory as
|
||||
* factory: () => (environment.useMockData ? inject(XLocal) : inject(XApi))
|
||||
* which reads as a toggle but is not one: naming both classes in the factory
|
||||
* keeps both reachable, so every mock shipped regardless of the flag. A
|
||||
* fixture string from partner-hierarchy-local.gateway.ts was verifiably
|
||||
* present in a production build on 2026-08-21.
|
||||
*
|
||||
* useExisting rather than useClass: the local gateways are already
|
||||
* providedIn: 'root' singletons, and an app-level provider for the token
|
||||
* wins over its tree-shakable default.
|
||||
*/
|
||||
export const MOCK_GATEWAY_PROVIDERS: Provider[] = environment.useMockData
|
||||
? [
|
||||
{ provide: ADMIN_DASHBOARD_METRICS_GATEWAY, useExisting: AdminDashboardMetricsLocalGateway },
|
||||
{ provide: ADMIN_MODERATION_GATEWAY, useExisting: AdminModerationLocalGateway },
|
||||
{ provide: ADMIN_MONITORING_GATEWAY, useExisting: AdminMonitoringLocalGateway },
|
||||
{ provide: ADMIN_NOTIFICATIONS_GATEWAY, useExisting: AdminNotificationsLocalGateway },
|
||||
{ provide: ADMIN_ORDERS_GATEWAY, useExisting: AdminOrdersLocalGateway },
|
||||
{ provide: ADMIN_PRODUCTS_GATEWAY, useExisting: AdminProductsLocalGateway },
|
||||
{ provide: ADMIN_TRANSACTIONS_GATEWAY, useExisting: AdminTransactionsLocalGateway },
|
||||
{ provide: ADMIN_USERS_GATEWAY, useExisting: AdminUsersLocalGateway },
|
||||
{ provide: ANALYTICS_GATEWAY, useExisting: AnalyticsLocalGateway },
|
||||
{ provide: CONNECTOR_GATEWAY, useExisting: ConnectorLocalGateway },
|
||||
{ provide: FINANCE_GATEWAY, useExisting: FinanceLocalGateway },
|
||||
{ provide: FX_QUOTE_GATEWAY, useExisting: FxQuoteLocalGateway },
|
||||
{ provide: MALL_CONTENT_GATEWAY, useExisting: MallContentLocalGateway },
|
||||
{ provide: MARKETPLACE_GATEWAY, useExisting: MarketplaceLocalGateway },
|
||||
{ provide: MARKETPLACE_REVISION_GATEWAY, useExisting: MarketplaceRevisionLocalGateway },
|
||||
{ provide: OFFER_GATEWAY, useExisting: OfferLocalGateway },
|
||||
{ provide: PARTNER_HIERARCHY_GATEWAY, useExisting: PartnerHierarchyLocalGateway },
|
||||
{ provide: PERMISSION_GATEWAY, useExisting: PermissionLocalGateway },
|
||||
{ provide: SELLER_GATEWAY, useExisting: SellerLocalGateway },
|
||||
{ provide: SERVER_CART_GATEWAY, useExisting: ServerCartLocalGateway },
|
||||
{ provide: SOCIAL_IDENTITY_GATEWAY, useExisting: SocialIdentityLocalGateway },
|
||||
]
|
||||
: [];
|
||||
Reference in New Issue
Block a user