feat: extract auth into @marketplaces/auth package, add backoffice admin provisioning spec
- ADR-0001: decision to extract auth/payment into shared @marketplaces/* packages - Scaffold packages/auth, packages/payment; @marketplaces/auth now holds the real telegram (customer+admin QR/session) and ed25519 (future admin challenge/response) auth implementation, pushed to sources.vitanova.network/sdarbinyan/vitanovaPackages - Rewire ~30 call sites to import from @marketplaces/auth; delete migrated originals from core/auth, core/admin-auth, services/, models/ - Replace environment coupling with AUTH_API_URL/TELEGRAM_BOT_USERNAME injection tokens and isDevMode(); wired as file:packages/auth pending registry publish - Add TRACK-S §8: bootstrap per-marketplace admin login + marketplace-scoped sub-admin invite/role endpoints - Build, arch:check:boundaries, and full test suite (103/103) all green Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,12 +7,11 @@ import { cacheInterceptor } from './interceptors/cache.interceptor';
|
||||
import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor';
|
||||
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
|
||||
import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
|
||||
import { adminAuthHeadersInterceptor } from './core/admin-auth/admin-auth-headers.interceptor';
|
||||
import { Ed25519VerificationService } from './core/admin-auth/ed25519-verification.model';
|
||||
import { NoopEd25519VerificationService } from './core/admin-auth/noop-ed25519-verification.service';
|
||||
import { adminAuthHeadersInterceptor, Ed25519VerificationService, NoopEd25519VerificationService, AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '@marketplaces/auth';
|
||||
import { provideServiceWorker } from '@angular/service-worker';
|
||||
import { MediaRepository } from './core/media/media-repository';
|
||||
import { MockMediaRepository } from './core/media/mock-media-repository.service';
|
||||
import { environment } from '../environments/environment';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -25,6 +24,8 @@ export const appConfig: ApplicationConfig = {
|
||||
provideHttpClient(withXhr(),
|
||||
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor])
|
||||
),
|
||||
{ provide: AUTH_API_URL, useValue: environment.authApiUrl },
|
||||
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },
|
||||
{ provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService },
|
||||
{ provide: MediaRepository, useClass: MockMediaRepository },
|
||||
provideServiceWorker('ngsw-worker.js', {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { languageGuard } from './guards/language.guard';
|
||||
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
|
||||
import { adminAuthGuard, requireAdminPermission } from './core/admin-auth/admin-auth.guard';
|
||||
import { adminAuthGuard } from '@marketplaces/auth';
|
||||
import { requireAdminPermission } from './core/admin-auth/admin-auth.guard';
|
||||
import { authRoutes } from './core/auth/auth.routes';
|
||||
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
|
||||
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
|
||||
|
||||
@@ -16,8 +16,7 @@ import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade';
|
||||
import { ApiHealthService } from './services/api-health.service';
|
||||
import { SeoService } from './services/seo.service';
|
||||
import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component';
|
||||
import { AdminAuthService } from './core/admin-auth/admin-auth.service';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { AdminAuthService, AuthService } from '@marketplaces/auth';
|
||||
import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { of } from 'rxjs';
|
||||
import { BootstrapConfig } from '../../shared/models/config';
|
||||
import { CONFIG_PROVIDER } from '../../core/config/config-provider.token';
|
||||
import { ConfigService } from '../../core/config/config.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { AuthService, AUTH_API_URL } from '@marketplaces/auth';
|
||||
import { HeaderComponent } from './header.component';
|
||||
|
||||
function makeBootstrap(): BootstrapConfig {
|
||||
@@ -52,6 +52,7 @@ describe('HeaderComponent profile control (login/logout gating regression)', ()
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
|
||||
{ provide: AUTH_API_URL, useValue: 'https://test.local' },
|
||||
{ provide: AuthService, useValue: fakeAuth },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { FeatureConfigService } from '../../core/config/feature-config.service';
|
||||
import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
|
||||
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
|
||||
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { AuthService } from '@marketplaces/auth';
|
||||
import { TelegramLoginComponent } from '../telegram-login/telegram-login.component';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Component, ChangeDetectionStrategy, Input, Injector, Signal, inject, effect, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { AdminAuthService } from '../../core/admin-auth/admin-auth.service';
|
||||
import { AuthService, AdminAuthService, AuthSession } from '@marketplaces/auth';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine';
|
||||
import { QrLoginAdapter, QrLoginStatus } from '../../shared/qr-login/qr-login.model';
|
||||
import { AuthSession } from '../../models/auth.model';
|
||||
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
|
||||
/** Backend paths that require an active AdminWebSessionID per API-REFERENCE.md §0. */
|
||||
const ADMIN_GATED_PATH_SEGMENTS = ['/admin/', '/backoffice/', '/builder/', '/media/'];
|
||||
|
||||
/**
|
||||
* Attaches admin session/token headers only to admin API requests. Mirrors
|
||||
* apiHeadersInterceptor's self-guarding pattern but scoped to admin-gated
|
||||
* paths so it never touches customer requests and never reads AuthService's
|
||||
* session.
|
||||
*/
|
||||
export const adminAuthHeadersInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const isAdminRequest = ADMIN_GATED_PATH_SEGMENTS.some(segment => req.url.includes(segment));
|
||||
if (!isAdminRequest) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const adminAuth = inject(AdminAuthService);
|
||||
const session = adminAuth.session();
|
||||
const token = adminAuth.getAdminToken();
|
||||
|
||||
let headers = req.headers;
|
||||
if (session?.sessionId) {
|
||||
headers = headers.set('AdminWebSessionID', session.sessionId);
|
||||
}
|
||||
if (token) {
|
||||
headers = headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
return next(req.clone({ headers }));
|
||||
};
|
||||
@@ -1,24 +1,14 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn } from '@angular/router';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminAuthService } from '@marketplaces/auth';
|
||||
import { AdminPermissionsService } from './admin-permissions.service';
|
||||
|
||||
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
|
||||
export const adminAuthGuard: CanActivateFn = () => {
|
||||
const adminAuth = inject(AdminAuthService);
|
||||
|
||||
if (adminAuth.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
adminAuth.requestLogin();
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* UI-only gate for a specific permission, on top of adminAuthGuard's
|
||||
* authentication check. See AdminPermissionsService for why this is
|
||||
* cosmetic until the backend ships real admin-role enforcement.
|
||||
* UI-only gate for a specific permission, on top of the package's
|
||||
* adminAuthGuard authentication check. See AdminPermissionsService for why
|
||||
* this is cosmetic until the backend ships real admin-role enforcement.
|
||||
* Kept app-local because it depends on AdminPermissionsService, which reads
|
||||
* this app's mock Users domain - not a portable auth concern.
|
||||
*/
|
||||
export function requireAdminPermission(permission: string): CanActivateFn {
|
||||
return () => {
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { AdminAuthStatus } from '../../models/admin-auth.model';
|
||||
import { AuthSession, WebSessionStart } from '../../models/auth.model';
|
||||
import { TelegramSessionApiService } from '../../services/telegram-session-api.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
/**
|
||||
* Admin login uses the exact same Telegram QR/session API as the customer
|
||||
* login (TelegramSessionApiService, `{authApiUrl}/users/sessions`) - there is
|
||||
* no separate admin backend endpoint, and none should be invented client-side.
|
||||
* Only the *storage* is kept separate from AuthService, so an admin QR scan
|
||||
* never authenticates the customer session or vice versa: distinct cookie
|
||||
* name, distinct signals, distinct guard/interceptor.
|
||||
*
|
||||
* Backend gap this creates (see docs/backend/BACKEND-INTEGRATION.md §2.5): since the session
|
||||
* API itself has no concept of "admin", the frontend cannot tell an admin
|
||||
* Telegram session from a regular one. Actual admin authorization must be
|
||||
* enforced server-side when admin API calls are made with the resulting
|
||||
* session id - the frontend only decides where to *store* the result.
|
||||
*/
|
||||
const ADMIN_SESSION_COOKIE = 'adminSessionID';
|
||||
const ADMIN_TOKEN_STORAGE_KEY = 'adminToken';
|
||||
const ADMIN_REFRESH_STORAGE_KEY = 'adminRefreshToken';
|
||||
const ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminAuthService {
|
||||
private readonly api = inject(TelegramSessionApiService);
|
||||
|
||||
private readonly sessionSignal = signal<AuthSession | null>(null);
|
||||
private readonly statusSignal = signal<AdminAuthStatus>('unknown');
|
||||
private readonly showLoginSignal = signal(false);
|
||||
|
||||
readonly session = this.sessionSignal.asReadonly();
|
||||
readonly status = this.statusSignal.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
|
||||
readonly showLoginDialog = this.showLoginSignal.asReadonly();
|
||||
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
||||
|
||||
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor() {
|
||||
this.checkSession();
|
||||
}
|
||||
|
||||
checkSession(): void {
|
||||
const webSessionID = this.getStoredAdminSessionID();
|
||||
if (!webSessionID) {
|
||||
this.clearAuthState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusSignal.set('checking');
|
||||
this.checkSessionOnce(webSessionID).subscribe(session => {
|
||||
if (!session?.active) {
|
||||
this.clearAuthState('unauthenticated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Check session without mutating internal state beyond activating on success (used for polling). */
|
||||
checkSessionOnce(webSessionID = this.getStoredAdminSessionID()): Observable<AuthSession | null> {
|
||||
return this.api.checkSessionOnce(webSessionID).pipe(
|
||||
tap(session => {
|
||||
if (session?.active) {
|
||||
this.activateSession(session);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a backend web session - identical call to the customer login (TelegramSessionApiService.createSession). */
|
||||
createWebSession(): Observable<WebSessionStart> {
|
||||
return this.api.createSession();
|
||||
}
|
||||
|
||||
getAdminAppLoginUrl(webSessionID: string): string {
|
||||
return this.api.getBotAppLoginUrl(webSessionID);
|
||||
}
|
||||
|
||||
onLoginComplete(): void {
|
||||
this.hideLogin();
|
||||
if (!this.isAuthenticated()) {
|
||||
this.checkSession();
|
||||
}
|
||||
}
|
||||
|
||||
requestLogin(): void {
|
||||
this.showLoginSignal.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only shortcut for local testing without a reachable Telegram/session
|
||||
* backend: fabricates a local session and activates it directly, skipping
|
||||
* the QR flow entirely. No-ops in production builds (checked at runtime,
|
||||
* not just build-time, so it is safe even if this code ships). Never call
|
||||
* this from anywhere reachable in a production build.
|
||||
*/
|
||||
devBypassLogin(): void {
|
||||
if (environment.production) {
|
||||
return;
|
||||
}
|
||||
this.hideLogin();
|
||||
this.activateSession({
|
||||
sessionId: `dev-bypass-${Date.now()}`,
|
||||
userId: 0,
|
||||
username: 'dev-admin',
|
||||
displayName: 'Dev Admin (local bypass)',
|
||||
active: true,
|
||||
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
hideLogin(): void {
|
||||
this.showLoginSignal.set(false);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
const webSessionID = this.sessionSignal()?.sessionId || this.getStoredAdminSessionID();
|
||||
if (!webSessionID) {
|
||||
this.clearAuthState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.logout(webSessionID).subscribe(() => this.clearAuthState('unauthenticated'));
|
||||
}
|
||||
|
||||
/** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */
|
||||
getAdminToken(): string | null {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
setAdminTokens(token: string, refreshToken: string): void {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token);
|
||||
localStorage.setItem(ADMIN_REFRESH_STORAGE_KEY, refreshToken);
|
||||
}
|
||||
|
||||
clearAdminTokens(): void {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
|
||||
localStorage.removeItem(ADMIN_REFRESH_STORAGE_KEY);
|
||||
}
|
||||
|
||||
private activateSession(session: AuthSession): void {
|
||||
this.sessionSignal.set(session);
|
||||
this.statusSignal.set('authenticated');
|
||||
this.setStoredAdminSessionID(session.sessionId);
|
||||
this.scheduleSessionRefresh(session.expires);
|
||||
}
|
||||
|
||||
private clearAuthState(status: AdminAuthStatus): void {
|
||||
this.sessionSignal.set(null);
|
||||
this.statusSignal.set(status);
|
||||
this.clearStoredAdminSessionID();
|
||||
this.clearAdminTokens();
|
||||
this.clearSessionRefresh();
|
||||
}
|
||||
|
||||
private scheduleSessionRefresh(expiresAt: string): void {
|
||||
this.clearSessionRefresh();
|
||||
const expiresMs = new Date(expiresAt).getTime();
|
||||
const nowMs = Date.now();
|
||||
const refreshIn = Number.isFinite(expiresMs)
|
||||
? Math.max(expiresMs - nowMs - 60_000, 30_000)
|
||||
: ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS * 1000;
|
||||
|
||||
this.sessionCheckTimer = setTimeout(() => this.checkSession(), refreshIn);
|
||||
}
|
||||
|
||||
private clearSessionRefresh(): void {
|
||||
if (this.sessionCheckTimer) {
|
||||
clearTimeout(this.sessionCheckTimer);
|
||||
this.sessionCheckTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getStoredAdminSessionID(): string | null {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const cookie = document.cookie.split('; ').find(row => row.startsWith(`${ADMIN_SESSION_COOKIE}=`));
|
||||
if (!cookie) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(cookie.substring(ADMIN_SESSION_COOKIE.length + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private setStoredAdminSessionID(webSessionID: string): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : '';
|
||||
document.cookie = `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Strict${secure}`;
|
||||
}
|
||||
|
||||
private clearStoredAdminSessionID(): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
document.cookie = `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminAuthService } from '@marketplaces/auth';
|
||||
import { AdminUsersLocalGateway } from '../../features/admin/users/services/admin-users-local.gateway';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Prep interfaces for a future Ed25519 challenge/response admin auth flow.
|
||||
* No crypto is implemented here - verification is delegated to an injectable
|
||||
* service so the real implementation (native WebCrypto Ed25519 support, or a
|
||||
* backend verification call) can be swapped in once the backend API exists,
|
||||
* without touching AdminAuthService or components.
|
||||
*/
|
||||
export interface Ed25519Challenge {
|
||||
nonce: string;
|
||||
timestamp: string;
|
||||
/** Opaque challenge payload the client must sign with its private key. */
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface Ed25519SignedResponse {
|
||||
challenge: Ed25519Challenge;
|
||||
publicKey: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface Ed25519VerificationResult {
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export abstract class Ed25519VerificationService {
|
||||
abstract requestChallenge(): Observable<Ed25519Challenge>;
|
||||
abstract verify(response: Ed25519SignedResponse): Observable<Ed25519VerificationResult>;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { Ed25519Challenge, Ed25519SignedResponse, Ed25519VerificationResult, Ed25519VerificationService } from './ed25519-verification.model';
|
||||
|
||||
/**
|
||||
* Default DI binding for Ed25519VerificationService until the backend ships
|
||||
* the real challenge/verify endpoints. Intentionally fails closed (throws)
|
||||
* rather than pretending to verify anything, so accidental use in a login
|
||||
* path is loud instead of silently accepting unsigned sessions.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NoopEd25519VerificationService implements Ed25519VerificationService {
|
||||
requestChallenge(): Observable<Ed25519Challenge> {
|
||||
return throwError(() => new Error('Ed25519 challenge endpoint is not yet available from the backend.'));
|
||||
}
|
||||
|
||||
verify(_response: Ed25519SignedResponse): Observable<Ed25519VerificationResult> {
|
||||
return throwError(() => new Error('Ed25519 verification endpoint is not yet available from the backend.'));
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { AdminRole } from './permission.model';
|
||||
|
||||
/**
|
||||
* Wire contracts for the Ed25519 challenge/response admin auth flow. These
|
||||
* are documented in docs/AUTH.md and match the endpoints listed there
|
||||
* exactly - none of this is invented beyond what's documented as FUTURE
|
||||
* there and in docs/backend/BACKEND-INTEGRATION.md §2.5.
|
||||
*/
|
||||
export interface AuthChallenge {
|
||||
nonce: string;
|
||||
/** ISO 8601 issue time of the challenge. */
|
||||
issuedAt: string;
|
||||
/** ISO 8601 - challenge must be used before this or the backend rejects it. */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface VerifySignatureRequest {
|
||||
publicKey: string;
|
||||
signature: string;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
export interface AuthTokenPair {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims expected in the JWT `token`. Decoded client-side for display/UX
|
||||
* only (role-gating UI, expiry countdown) - the frontend never treats this
|
||||
* as proof of authorization; every admin request is still re-checked
|
||||
* server-side per docs/AUTH.md security considerations.
|
||||
*/
|
||||
export interface JwtClaims {
|
||||
sub: string;
|
||||
role: AdminRole;
|
||||
/** Issued-at, seconds since epoch (standard `iat` claim). */
|
||||
iat: number;
|
||||
/** Expiry, seconds since epoch (standard `exp` claim). */
|
||||
exp: number;
|
||||
publicKey: string;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Error codes the Ed25519 admin auth flow can surface to the UI. Each maps to
|
||||
* a dedicated screen (see `core/auth/pages`) rather than a generic toast,
|
||||
* because the recovery action differs per code (re-login vs. retry vs. wait).
|
||||
*/
|
||||
export type AuthErrorCode =
|
||||
| 'session-expired'
|
||||
| 'invalid-signature'
|
||||
| 'unauthorized'
|
||||
| 'forbidden'
|
||||
| 'backend-unavailable';
|
||||
|
||||
export interface AuthError {
|
||||
code: AuthErrorCode;
|
||||
message: string;
|
||||
/** HTTP status that produced this error, when known (absent for client-side errors, e.g. no Ed25519 support). */
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the backend error envelope's `error.code` (see
|
||||
* BACKEND-API-REFERENCE.md §5) to the client's AuthErrorCode screens.
|
||||
* Only codes with a dedicated screen are mapped; anything else falls back
|
||||
* to the HTTP-status-derived code via authErrorCodeFromStatus.
|
||||
*/
|
||||
const BACKEND_ERROR_CODE_MAP: Record<string, AuthErrorCode> = {
|
||||
TOKEN_EXPIRED: 'session-expired',
|
||||
INVALID_SIGNATURE: 'invalid-signature',
|
||||
UNAUTHENTICATED: 'unauthorized',
|
||||
FORBIDDEN: 'forbidden',
|
||||
SERVICE_UNAVAILABLE: 'backend-unavailable',
|
||||
};
|
||||
|
||||
export function authErrorCodeFromBackendCode(code: unknown): AuthErrorCode | undefined {
|
||||
return typeof code === 'string' ? BACKEND_ERROR_CODE_MAP[code] : undefined;
|
||||
}
|
||||
|
||||
/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */
|
||||
export function authErrorCodeFromStatus(status: number): AuthErrorCode {
|
||||
switch (status) {
|
||||
case 401:
|
||||
return 'unauthorized';
|
||||
case 403:
|
||||
return 'forbidden';
|
||||
case 0:
|
||||
return 'backend-unavailable';
|
||||
default:
|
||||
return status >= 500 ? 'backend-unavailable' : 'unauthorized';
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Roles the Ed25519 JWT `role` claim is expected to carry (see docs/AUTH.md
|
||||
* §JWT Claims). Ordered highest-to-lowest privilege; PermissionService does
|
||||
* not rely on the order, it is documentation only.
|
||||
*/
|
||||
export type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly';
|
||||
|
||||
/**
|
||||
* Coarse-grained permission keys. Intentionally small and domain-agnostic
|
||||
* (mirrors the existing bootstrap-level `PermissionsConfig` shape in
|
||||
* `shared/models/config/permissions.model.ts`) - fine-grained, per-domain
|
||||
* permissions stay server-side until the backend ships a real permission
|
||||
* model; the frontend only needs enough to hide/disable UI, never to be the
|
||||
* source of truth for authorization.
|
||||
*/
|
||||
export type Permission =
|
||||
| 'backoffice.read'
|
||||
| 'backoffice.write'
|
||||
| 'builder.read'
|
||||
| 'builder.write'
|
||||
| 'users.manage'
|
||||
| 'settings.manage';
|
||||
|
||||
export const ROLE_PERMISSIONS: Readonly<Record<AdminRole, readonly Permission[]>> = {
|
||||
Owner: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write', 'users.manage', 'settings.manage'],
|
||||
Administrator: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write', 'users.manage'],
|
||||
Editor: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write'],
|
||||
Support: ['backoffice.read'],
|
||||
ReadOnly: ['backoffice.read', 'builder.read']
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { ButtonComponent } from '../../../shared/ui/button/button.component';
|
||||
import { AuthFacade } from '../services/auth-facade.service';
|
||||
import { Ed25519KeypairService } from '../services/ed25519-keypair.service';
|
||||
import { AuthFacade, Ed25519KeypairService } from '@marketplaces/auth';
|
||||
|
||||
/**
|
||||
* Ed25519 admin login page. Prepared UI for the flow described in
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { map } from 'rxjs';
|
||||
import { ButtonComponent } from '../../../shared/ui/button/button.component';
|
||||
import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
|
||||
import { AuthErrorCode } from '../models/auth-error.model';
|
||||
import { AuthErrorCode } from '@marketplaces/auth';
|
||||
|
||||
interface AuthErrorCopy {
|
||||
title: string;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import { AuthChallenge, AuthTokenPair, RefreshTokenRequest, VerifySignatureRequest } from '../models/auth-api.model';
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the Ed25519 admin auth endpoints documented in
|
||||
* docs/AUTH.md. These endpoints do not exist on the backend yet (FUTURE -
|
||||
* see docs/backend/BACKEND-INTEGRATION.md §2.5) - calling them today 404s
|
||||
* or connection-errors, which AuthService maps to the
|
||||
* `backend-unavailable` error screen. No mock/fake responses are fabricated
|
||||
* here; this is real HttpClient wiring against the real contract, ready for
|
||||
* the moment the backend ships.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.authApiUrl}/api/admin/auth`;
|
||||
|
||||
requestChallenge(): Observable<AuthChallenge> {
|
||||
return this.http.get<AuthChallenge>(`${this.baseUrl}/challenge`);
|
||||
}
|
||||
|
||||
verifySignature(request: VerifySignatureRequest): Observable<AuthTokenPair> {
|
||||
return this.http.post<AuthTokenPair>(`${this.baseUrl}/verify`, request);
|
||||
}
|
||||
|
||||
refresh(request: RefreshTokenRequest): Observable<AuthTokenPair> {
|
||||
return this.http.post<AuthTokenPair>(`${this.baseUrl}/refresh`, request);
|
||||
}
|
||||
|
||||
logout(refreshToken: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/logout`, { refreshToken } satisfies RefreshTokenRequest);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { finalize } from 'rxjs';
|
||||
import { AuthService } from './auth.service';
|
||||
import { PermissionService } from './permission.service';
|
||||
import { SessionService } from './session.service';
|
||||
import { Permission } from '../models/permission.model';
|
||||
|
||||
/**
|
||||
* Public surface for components/pages. Components should depend on this,
|
||||
* not on AuthService/SessionService/PermissionService directly, so the
|
||||
* orchestration details (which service owns what) can change without
|
||||
* touching UI code.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthFacade {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly session = inject(SessionService);
|
||||
private readonly permissions = inject(PermissionService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly isAuthenticated = this.session.isAuthenticated;
|
||||
readonly status = this.session.status;
|
||||
readonly role = this.session.role;
|
||||
readonly loginPhase = this.auth.loginPhase;
|
||||
readonly lastError = this.auth.lastError;
|
||||
|
||||
restoreSession(): void {
|
||||
this.auth.restoreSession();
|
||||
}
|
||||
|
||||
login(onSuccessRedirectTo?: string): void {
|
||||
this.auth.login().subscribe({
|
||||
next: () => {
|
||||
if (onSuccessRedirectTo) {
|
||||
this.router.navigateByUrl(onSuccessRedirectTo);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
const code = this.auth.lastError()?.code ?? 'unauthorized';
|
||||
this.router.navigate(['/admin-login/error', code]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logout(redirectTo = '/admin-login'): void {
|
||||
this.auth
|
||||
.logout()
|
||||
.pipe(finalize(() => this.router.navigateByUrl(redirectTo)))
|
||||
.subscribe({ error: () => undefined });
|
||||
}
|
||||
|
||||
can(permission: Permission): boolean {
|
||||
return this.permissions.has(permission);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { catchError, switchMap, tap, throwError } from 'rxjs';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AuthTokenPair } from '../models/auth-api.model';
|
||||
import { AuthError, authErrorCodeFromBackendCode, authErrorCodeFromStatus } from '../models/auth-error.model';
|
||||
import { AuthApiService } from './auth-api.service';
|
||||
import { Ed25519KeypairService } from './ed25519-keypair.service';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
export type LoginPhase = 'idle' | 'requesting-challenge' | 'signing' | 'verifying' | 'done';
|
||||
|
||||
/**
|
||||
* Orchestrates the Ed25519 challenge/response admin auth flow end to end:
|
||||
*
|
||||
* GET /api/admin/auth/challenge -> { nonce }
|
||||
* sign(nonce) with local Ed25519 key -> signature
|
||||
* POST /api/admin/auth/verify -> { token, refreshToken }
|
||||
*
|
||||
* This is the lowest-level orchestrator; components should go through
|
||||
* AuthFacade rather than calling this directly.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly api = inject(AuthApiService);
|
||||
private readonly keypair = inject(Ed25519KeypairService);
|
||||
private readonly session = inject(SessionService);
|
||||
|
||||
private readonly loginPhaseSignal = signal<LoginPhase>('idle');
|
||||
private readonly lastErrorSignal = signal<AuthError | null>(null);
|
||||
|
||||
readonly loginPhase = this.loginPhaseSignal.asReadonly();
|
||||
readonly lastError = this.lastErrorSignal.asReadonly();
|
||||
|
||||
constructor() {
|
||||
this.session.onRefreshDue(() => this.refresh().subscribe());
|
||||
}
|
||||
|
||||
/** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */
|
||||
restoreSession(): void {
|
||||
this.session.restore();
|
||||
}
|
||||
|
||||
login(): Observable<AuthTokenPair> {
|
||||
this.lastErrorSignal.set(null);
|
||||
this.loginPhaseSignal.set('requesting-challenge');
|
||||
|
||||
return this.api.requestChallenge().pipe(
|
||||
switchMap(challenge =>
|
||||
this.signChallenge(challenge.nonce).pipe(
|
||||
switchMap(({ publicKeyBase64, signature }) => {
|
||||
this.loginPhaseSignal.set('verifying');
|
||||
return this.api.verifySignature({ publicKey: publicKeyBase64, signature, nonce: challenge.nonce });
|
||||
})
|
||||
)
|
||||
),
|
||||
tap(tokens => {
|
||||
this.session.activate(tokens);
|
||||
this.loginPhaseSignal.set('done');
|
||||
}),
|
||||
catchError(error => this.handleAuthError<AuthTokenPair>(error, 'invalid-signature'))
|
||||
);
|
||||
}
|
||||
|
||||
refresh(): Observable<AuthTokenPair> {
|
||||
const refreshToken = this.session.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
this.session.markExpired();
|
||||
return throwError(() => this.toAuthError({ code: 'session-expired', message: 'No refresh token available.' }));
|
||||
}
|
||||
|
||||
return this.api.refresh({ refreshToken }).pipe(
|
||||
tap(tokens => this.session.activate(tokens)),
|
||||
catchError(error => this.handleAuthError<AuthTokenPair>(error, 'session-expired', () => this.session.markExpired()))
|
||||
);
|
||||
}
|
||||
|
||||
logout(): Observable<void> {
|
||||
const refreshToken = this.session.getRefreshToken();
|
||||
this.session.clear();
|
||||
if (!refreshToken) {
|
||||
return new Observable<void>(subscriber => {
|
||||
subscriber.next();
|
||||
subscriber.complete();
|
||||
});
|
||||
}
|
||||
return this.api.logout(refreshToken).pipe(catchError(() => throwError(() => null)));
|
||||
}
|
||||
|
||||
private signChallenge(nonce: string): Observable<{ publicKeyBase64: string; signature: string }> {
|
||||
this.loginPhaseSignal.set('signing');
|
||||
return new Observable<{ publicKeyBase64: string; signature: string }>(subscriber => {
|
||||
this.keypair
|
||||
.getOrCreateKeyPair()
|
||||
.then(({ publicKeyBase64 }) =>
|
||||
this.keypair.sign(nonce).then(signature => {
|
||||
subscriber.next({ publicKeyBase64, signature });
|
||||
subscriber.complete();
|
||||
})
|
||||
)
|
||||
.catch(error => subscriber.error(error));
|
||||
});
|
||||
}
|
||||
|
||||
private handleAuthError<T>(error: unknown, fallbackCode: AuthError['code'], onError?: () => void): Observable<T> {
|
||||
onError?.();
|
||||
return throwError(() => this.toAuthError(this.toAuthErrorShape(error, fallbackCode)));
|
||||
}
|
||||
|
||||
private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const bodyCode = (error.error as { error?: { code?: unknown } } | null)?.error?.code;
|
||||
const code = authErrorCodeFromBackendCode(bodyCode) ?? authErrorCodeFromStatus(error.status);
|
||||
return { code, message: error.message, status: error.status };
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return { code: fallbackCode, message: error.message };
|
||||
}
|
||||
return { code: fallbackCode, message: 'Unknown authentication error.' };
|
||||
}
|
||||
|
||||
private toAuthError(error: AuthError): AuthError {
|
||||
this.lastErrorSignal.set(error);
|
||||
return error;
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Manages the browser-local Ed25519 keypair used to sign admin auth
|
||||
* challenges. Real WebCrypto Ed25519 (RFC 8032 support landed in evergreen
|
||||
* browsers) - not a placeholder. The private key is generated
|
||||
* non-extractable and kept only in IndexedDB as a CryptoKey handle; it is
|
||||
* never serialized, never sent anywhere, and cannot be exported by design.
|
||||
*
|
||||
* Registering `publicKey` with an admin's account (associating it with a
|
||||
* role) is a backend-side, out-of-band operation (e.g. an Owner approving a
|
||||
* new admin's public key) - entirely outside this frontend's scope.
|
||||
*/
|
||||
const DB_NAME = 'admin-auth-ed25519';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'keypair';
|
||||
const KEY_RECORD_ID = 'device-keypair';
|
||||
|
||||
interface StoredKeyPair {
|
||||
id: string;
|
||||
publicKey: CryptoKey;
|
||||
privateKey: CryptoKey;
|
||||
publicKeyBase64: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class Ed25519KeypairService {
|
||||
private cached: StoredKeyPair | null = null;
|
||||
|
||||
isSupported(): boolean {
|
||||
return typeof crypto !== 'undefined' && !!crypto.subtle && typeof indexedDB !== 'undefined';
|
||||
}
|
||||
|
||||
/** Returns the device's Ed25519 keypair, generating and persisting one on first use. */
|
||||
async getOrCreateKeyPair(): Promise<{ publicKeyBase64: string }> {
|
||||
if (!this.isSupported()) {
|
||||
throw new Error('Ed25519 is not supported in this browser (requires WebCrypto + IndexedDB).');
|
||||
}
|
||||
const existing = await this.loadFromStore();
|
||||
if (existing) {
|
||||
this.cached = existing;
|
||||
return { publicKeyBase64: existing.publicKeyBase64 };
|
||||
}
|
||||
|
||||
const generated = await this.generateAndPersist();
|
||||
this.cached = generated;
|
||||
return { publicKeyBase64: generated.publicKeyBase64 };
|
||||
}
|
||||
|
||||
async sign(message: string): Promise<string> {
|
||||
const keyPair = this.cached ?? (await this.loadFromStore());
|
||||
if (!keyPair) {
|
||||
throw new Error('No Ed25519 keypair available - call getOrCreateKeyPair() first.');
|
||||
}
|
||||
|
||||
const signatureBuffer = await crypto.subtle.sign('Ed25519', keyPair.privateKey, new TextEncoder().encode(message));
|
||||
return this.toBase64(new Uint8Array(signatureBuffer));
|
||||
}
|
||||
|
||||
/** Discards the local keypair (e.g. "forget this device"). A new keypair on next login requires re-registration with the backend. */
|
||||
async clear(): Promise<void> {
|
||||
this.cached = null;
|
||||
const db = await this.openDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).delete(KEY_RECORD_ID);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
private async generateAndPersist(): Promise<StoredKeyPair> {
|
||||
const keyPair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])) as CryptoKeyPair;
|
||||
const publicKeyRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
|
||||
const publicKeyBase64 = this.toBase64(new Uint8Array(publicKeyRaw));
|
||||
|
||||
const record: StoredKeyPair = {
|
||||
id: KEY_RECORD_ID,
|
||||
publicKey: keyPair.publicKey,
|
||||
privateKey: keyPair.privateKey,
|
||||
publicKeyBase64
|
||||
};
|
||||
|
||||
const db = await this.openDatabase();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
tx.objectStore(STORE_NAME).put(record);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
private async loadFromStore(): Promise<StoredKeyPair | null> {
|
||||
const db = await this.openDatabase();
|
||||
return new Promise<StoredKeyPair | null>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const request = tx.objectStore(STORE_NAME).get(KEY_RECORD_ID);
|
||||
request.onsuccess = () => resolve((request.result as StoredKeyPair | undefined) ?? null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
private openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
|
||||
request.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
private toBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { JwtClaims } from '../models/auth-api.model';
|
||||
|
||||
/**
|
||||
* Client-side JWT *decoding* only - never verification. The signature is
|
||||
* meaningless to check here because the frontend has no trusted key to check
|
||||
* it against; verifying a JWT's signature is the backend's job on every
|
||||
* request. This service exists purely so the UI can read `role`/`exp` for
|
||||
* display and route-gating UX (e.g. "session expires in 4m").
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class JwtService {
|
||||
decode(token: string): JwtClaims | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.base64UrlDecode(parts[1]);
|
||||
const claims = JSON.parse(payload) as JwtClaims;
|
||||
return this.isJwtClaims(claims) ? claims : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
isExpired(claims: JwtClaims, skewSeconds = 0): boolean {
|
||||
return claims.exp * 1000 <= Date.now() + skewSeconds * 1000;
|
||||
}
|
||||
|
||||
private isJwtClaims(value: unknown): value is JwtClaims {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const claims = value as Partial<JwtClaims>;
|
||||
return typeof claims.sub === 'string' && typeof claims.role === 'string' && typeof claims.exp === 'number';
|
||||
}
|
||||
|
||||
private base64UrlDecode(input: string): string {
|
||||
const base64 = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(input.length + ((4 - (input.length % 4)) % 4), '=');
|
||||
return decodeURIComponent(escape(atob(base64)));
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import { Permission, ROLE_PERMISSIONS } from '../models/permission.model';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
/**
|
||||
* Derives the current admin's permission set from their JWT `role` claim.
|
||||
* UI-only gate (hide/disable) - the backend must independently enforce
|
||||
* every mutation server-side; see docs/AUTH.md security considerations.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PermissionService {
|
||||
private readonly session = inject(SessionService);
|
||||
|
||||
readonly permissions = computed<readonly Permission[]>(() => {
|
||||
const role = this.session.role();
|
||||
return role ? ROLE_PERMISSIONS[role] : [];
|
||||
});
|
||||
|
||||
has(permission: Permission): boolean {
|
||||
return this.permissions().includes(permission);
|
||||
}
|
||||
|
||||
hasAny(permissions: readonly Permission[]): boolean {
|
||||
return permissions.some(permission => this.has(permission));
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { Injectable, computed, signal } from '@angular/core';
|
||||
import { AuthTokenPair, JwtClaims } from '../models/auth-api.model';
|
||||
import { JwtService } from './jwt.service';
|
||||
|
||||
export type SessionStatus = 'unknown' | 'restoring' | 'authenticated' | 'unauthenticated' | 'expired';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'ed25519AdminToken';
|
||||
const REFRESH_STORAGE_KEY = 'ed25519AdminRefreshToken';
|
||||
/** Refresh this long before actual expiry, so a request never races an expiring token. */
|
||||
const REFRESH_SKEW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Holds the Ed25519-flow JWT/refresh-token pair and derived claims. Separate
|
||||
* from AdminAuthService (Telegram-session state) by design - the two auth
|
||||
* mechanisms are not merged until the backend actually ships the Ed25519
|
||||
* endpoints and a migration decision is made (see docs/AUTH.md).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionService {
|
||||
private readonly jwt = new JwtService();
|
||||
|
||||
private readonly tokenSignal = signal<string | null>(null);
|
||||
private readonly refreshTokenSignal = signal<string | null>(null);
|
||||
private readonly claimsSignal = signal<JwtClaims | null>(null);
|
||||
private readonly statusSignal = signal<SessionStatus>('unknown');
|
||||
|
||||
readonly token = this.tokenSignal.asReadonly();
|
||||
readonly claims = this.claimsSignal.asReadonly();
|
||||
readonly status = this.statusSignal.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
|
||||
readonly role = computed(() => this.claimsSignal()?.role ?? null);
|
||||
|
||||
private refreshTimer?: ReturnType<typeof setTimeout>;
|
||||
private refreshCallback?: () => void;
|
||||
|
||||
/** Called once by AuthService on init to wire up the refresh trigger without a circular DI dependency. */
|
||||
onRefreshDue(callback: () => void): void {
|
||||
this.refreshCallback = callback;
|
||||
}
|
||||
|
||||
/** Restores session state from persisted storage. Returns true if a (possibly expired) session was found. */
|
||||
restore(): boolean {
|
||||
this.statusSignal.set('restoring');
|
||||
const token = this.readStorage(TOKEN_STORAGE_KEY);
|
||||
const refreshToken = this.readStorage(REFRESH_STORAGE_KEY);
|
||||
if (!token || !refreshToken) {
|
||||
this.statusSignal.set('unauthenticated');
|
||||
return false;
|
||||
}
|
||||
|
||||
const claims = this.jwt.decode(token);
|
||||
if (!claims) {
|
||||
this.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.tokenSignal.set(token);
|
||||
this.refreshTokenSignal.set(refreshToken);
|
||||
this.claimsSignal.set(claims);
|
||||
|
||||
if (this.jwt.isExpired(claims)) {
|
||||
this.statusSignal.set('expired');
|
||||
} else {
|
||||
this.statusSignal.set('authenticated');
|
||||
this.scheduleRefresh(claims);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
activate(tokens: AuthTokenPair): void {
|
||||
const claims = this.jwt.decode(tokens.token);
|
||||
if (!claims) {
|
||||
throw new Error('Received a malformed JWT from the auth backend.');
|
||||
}
|
||||
|
||||
this.tokenSignal.set(tokens.token);
|
||||
this.refreshTokenSignal.set(tokens.refreshToken);
|
||||
this.claimsSignal.set(claims);
|
||||
this.statusSignal.set('authenticated');
|
||||
this.writeStorage(TOKEN_STORAGE_KEY, tokens.token);
|
||||
this.writeStorage(REFRESH_STORAGE_KEY, tokens.refreshToken);
|
||||
this.scheduleRefresh(claims);
|
||||
}
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
return this.refreshTokenSignal();
|
||||
}
|
||||
|
||||
markExpired(): void {
|
||||
this.statusSignal.set('expired');
|
||||
this.clearRefreshTimer();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.tokenSignal.set(null);
|
||||
this.refreshTokenSignal.set(null);
|
||||
this.claimsSignal.set(null);
|
||||
this.statusSignal.set('unauthenticated');
|
||||
this.removeStorage(TOKEN_STORAGE_KEY);
|
||||
this.removeStorage(REFRESH_STORAGE_KEY);
|
||||
this.clearRefreshTimer();
|
||||
}
|
||||
|
||||
private scheduleRefresh(claims: JwtClaims): void {
|
||||
this.clearRefreshTimer();
|
||||
const expiresInMs = claims.exp * 1000 - Date.now();
|
||||
const refreshInMs = Math.max(expiresInMs - REFRESH_SKEW_MS, 5_000);
|
||||
this.refreshTimer = setTimeout(() => this.refreshCallback?.(), refreshInMs);
|
||||
}
|
||||
|
||||
private clearRefreshTimer(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private readStorage(key: string): string | null {
|
||||
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
|
||||
}
|
||||
|
||||
private writeStorage(key: string, value: string): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
private removeStorage(key: string): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { ApiService } from '../../../services';
|
||||
import { AuthService } from '../../../services/auth.service';
|
||||
import { AuthService } from '@marketplaces/auth';
|
||||
import { CategoryService } from '../../categories/category.service';
|
||||
import { ProductDataProvider } from './product-data-provider.interface';
|
||||
import { Product, ProductCategory, ProductListQuery, ProductListResult, ProductSearchQuery, RelatedProductsQuery } from '../models/product-domain.model';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { ProjectEditorFacade } from '../../../project-editor/facade/project-editor.facade';
|
||||
import { EditorSchemaService } from '../../../project-editor/schema/editor-schema.service';
|
||||
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||
import { AdminAuthService } from '@marketplaces/auth';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { ADMIN_DASHBOARD_METRICS_GATEWAY } from '../services/admin-dashboard-metrics-gateway.token';
|
||||
import { AdminDashboardHistoryService } from '../services/admin-dashboard-history.service';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Observable, of } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus, TERMINAL_ORDER_STATUSES } from '../models/admin-order.model';
|
||||
import { AdminOrdersGateway } from './admin-orders-gateway.interface';
|
||||
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||
import { AdminAuthService } from '@marketplaces/auth';
|
||||
|
||||
const STATUSES: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
|
||||
const CUSTOMER_NAMES = ['Anna Petrova', 'Karen Sargsyan', 'Ivan Ivanov', 'Mariam Grigoryan', 'Sergey Volkov', 'Lilit Hakobyan'];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { signal } from '@angular/core';
|
||||
import { AUTH_API_URL } from '@marketplaces/auth';
|
||||
import { AdminLayoutComponent } from './admin-layout.component';
|
||||
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
|
||||
import { AdminOrder } from '../orders/models/admin-order.model';
|
||||
@@ -47,6 +48,7 @@ describe('AdminLayoutComponent notifications bell', () => {
|
||||
imports: [AdminLayoutComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AUTH_API_URL, useValue: 'https://test.local' },
|
||||
{ provide: AdminOrderWatcherService, useValue: watcherStub },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../../i18n/translate.service';
|
||||
import { LanguageService } from '../../../services/language.service';
|
||||
import { AdminAuthService } from '../../../core/admin-auth/admin-auth.service';
|
||||
import { AdminAuthService } from '@marketplaces/auth';
|
||||
import { ADMIN_NAV_BOTTOM, ADMIN_NAV_PRIMARY, AdminBreadcrumbEntry, AdminNavEntry } from './admin-nav.model';
|
||||
import { IconComponent } from '../../../shared/ui/icon/icon.component';
|
||||
import { AdminPreferencesService } from '../settings/services/admin-preferences.service';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AdminOrderWatcherService } from './admin-order-watcher.service';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model';
|
||||
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
||||
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||
import { AdminAuthService } from '@marketplaces/auth';
|
||||
|
||||
function makeOrder(id: string, orderNumber: string, createdAt: string): AdminOrder {
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LocalStorageService } from '../../../../core/storage/local-storage.serv
|
||||
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { TranslateService } from '../../../../i18n/translate.service';
|
||||
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||
import { AdminAuthService } from '@marketplaces/auth';
|
||||
|
||||
const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1';
|
||||
const LAST_NOTIFIED_AT_KEY = 'adminOrderWatcher.lastNotifiedOrderCreatedAt.v1';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { delay } from 'rxjs/operators';
|
||||
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
|
||||
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||
import { AdminAuthService } from '@marketplaces/auth';
|
||||
|
||||
const METHODS = ['card', 'qr', 'cash_on_delivery'];
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Observable, of } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { AdminInvitation, AdminUserRoleRecord, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
|
||||
import { AdminUsersGateway } from './admin-users-gateway.interface';
|
||||
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||
import { AdminAuthService } from '@marketplaces/auth';
|
||||
|
||||
const BUILT_IN_ROLES: AdminUserRoleRecord[] = [
|
||||
{ id: 'owner', name: 'Owner', permissions: ['*'], builtIn: true },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { AuthService } from '../../../services/auth.service';
|
||||
import { AuthService } from '@marketplaces/auth';
|
||||
import { SearchHistory } from '../models/search.model';
|
||||
import { BackendSearchHistoryRepository, LocalSearchHistoryRepository, SearchHistoryRepository } from './search-history.repository';
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { AnalyticsService } from '../../../../core/analytics/services/analytics.
|
||||
import { ApiService } from '../../../../services/api.service';
|
||||
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
||||
import { AuthService } from '../../../../services/auth.service';
|
||||
import { AuthService } from '@marketplaces/auth';
|
||||
|
||||
const RESTOCK_SUBSCRIPTIONS_KEY = 'restockSubscriptions';
|
||||
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { inject } from '@angular/core';
|
||||
import { ApiConfigService } from '../core/config/api-config.service';
|
||||
import { LocationService } from '../services/location.service';
|
||||
import { LanguageService } from '../services/language.service';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { AuthService } from '@marketplaces/auth';
|
||||
|
||||
/** Map internal language codes to API header values */
|
||||
const LANG_HEADER_MAP: Record<string, string> = {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ResolvedWidget } from '../../widgets/contracts/widget-component.contrac
|
||||
import { WidgetHostService } from '../../dynamic-renderer/widget-host/widget-host.service';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { Category } from '../../core/categories/models/category-domain.model';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { AuthService } from '@marketplaces/auth';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dynamic-page-layout',
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export type AdminAuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
||||
@@ -1,15 +0,0 @@
|
||||
export interface AuthSession {
|
||||
sessionId: string;
|
||||
userId: number | null;
|
||||
username: string | null;
|
||||
displayName: string;
|
||||
active: boolean;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
export interface WebSessionStart {
|
||||
webSessionID: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './category.model';
|
||||
export * from './item.model';
|
||||
export * from './location.model';
|
||||
export * from './auth.model';
|
||||
|
||||
@@ -3,7 +3,8 @@ import { DecimalPipe } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||
import { CartService, ApiService, LanguageService, AuthService } from '../../services';
|
||||
import { CartService, ApiService, LanguageService } from '../../services';
|
||||
import { AuthService } from '@marketplaces/auth';
|
||||
import { Item, CartItem, DeliveryOption } from '../../models';
|
||||
import { EMPTY, interval, of, Subscription } from 'rxjs';
|
||||
import { catchError, exhaustMap, take, timeout } from 'rxjs/operators';
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { AuthSession, AuthStatus, WebSessionStart } from '../models/auth.model';
|
||||
import { TelegramSessionApiService } from './telegram-session-api.service';
|
||||
|
||||
const WEB_SESSION_COOKIE = 'webSessionID';
|
||||
const WEB_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly api = inject(TelegramSessionApiService);
|
||||
|
||||
private sessionSignal = signal<AuthSession | null>(null);
|
||||
private statusSignal = signal<AuthStatus>('unknown');
|
||||
private showLoginSignal = signal(false);
|
||||
|
||||
/** Current auth session */
|
||||
readonly session = this.sessionSignal.asReadonly();
|
||||
/** Current auth status */
|
||||
readonly status = this.statusSignal.asReadonly();
|
||||
/** Whether user is fully authenticated */
|
||||
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
|
||||
/** Whether to show login dialog */
|
||||
readonly showLoginDialog = this.showLoginSignal.asReadonly();
|
||||
/** Display name of authenticated user */
|
||||
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
||||
|
||||
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor() {
|
||||
// On init, check existing session via cookie
|
||||
this.checkSession();
|
||||
}
|
||||
|
||||
/** Check the current webSessionID cookie against the auth backend. */
|
||||
checkSession(): void {
|
||||
const webSessionID = this.getStoredWebSessionID();
|
||||
|
||||
if (!webSessionID) {
|
||||
this.clearAuthState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusSignal.set('checking');
|
||||
|
||||
this.checkSessionOnce(webSessionID).subscribe(session => {
|
||||
if (!session?.active) {
|
||||
this.clearAuthState('unauthenticated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Check session without updating internal state beyond activating on success (used for polling). */
|
||||
checkSessionOnce(webSessionID = this.getStoredWebSessionID()): Observable<AuthSession | null> {
|
||||
return this.api.checkSessionOnce(webSessionID).pipe(
|
||||
tap(session => {
|
||||
if (session?.active) {
|
||||
this.activateSession(session);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called after user completes Telegram login.
|
||||
*/
|
||||
onTelegramLoginComplete(): void {
|
||||
this.hideLogin();
|
||||
|
||||
if (!this.isAuthenticated()) {
|
||||
this.checkSession();
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate the Telegram login URL for bot-based auth */
|
||||
getTelegramLoginUrl(webSessionID: string): string {
|
||||
return this.api.getBotLoginUrl(webSessionID);
|
||||
}
|
||||
|
||||
/** Generate a Telegram app deep link for mobile login without opening a browser tab. */
|
||||
getTelegramAppLoginUrl(webSessionID: string): string {
|
||||
return this.api.getBotAppLoginUrl(webSessionID);
|
||||
}
|
||||
|
||||
/** Create a backend web session and return the Telegram start link for it. */
|
||||
createWebSession(): Observable<WebSessionStart> {
|
||||
return this.api.createSession();
|
||||
}
|
||||
|
||||
/** Show login dialog (called when user tries to pay without being logged in) */
|
||||
requestLogin(): void {
|
||||
this.showLoginSignal.set(true);
|
||||
}
|
||||
|
||||
/** Hide login dialog */
|
||||
hideLogin(): void {
|
||||
this.showLoginSignal.set(false);
|
||||
}
|
||||
|
||||
/** Logout — clears session on backend and locally */
|
||||
logout(): void {
|
||||
const webSessionID = this.sessionSignal()?.sessionId || this.getStoredWebSessionID();
|
||||
|
||||
if (!webSessionID) {
|
||||
this.clearAuthState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
this.api.logout(webSessionID).subscribe(() => {
|
||||
this.clearAuthState('unauthenticated');
|
||||
});
|
||||
}
|
||||
|
||||
private activateSession(session: AuthSession): void {
|
||||
this.sessionSignal.set(session);
|
||||
this.statusSignal.set('authenticated');
|
||||
this.setStoredWebSessionID(session.sessionId);
|
||||
this.scheduleSessionRefresh(session.expires);
|
||||
}
|
||||
|
||||
private clearAuthState(status: AuthStatus): void {
|
||||
this.sessionSignal.set(null);
|
||||
this.statusSignal.set(status);
|
||||
this.clearStoredWebSessionID();
|
||||
this.clearSessionRefresh();
|
||||
}
|
||||
|
||||
/** Schedule a session re-check before it expires */
|
||||
private scheduleSessionRefresh(expiresAt: string): void {
|
||||
this.clearSessionRefresh();
|
||||
|
||||
const expiresMs = new Date(expiresAt).getTime();
|
||||
const nowMs = Date.now();
|
||||
// Re-check 60 seconds before expiry, minimum 30s from now
|
||||
const refreshIn = Number.isFinite(expiresMs)
|
||||
? Math.max(expiresMs - nowMs - 60_000, 30_000)
|
||||
: WEB_SESSION_COOKIE_MAX_AGE_SECONDS * 1000;
|
||||
|
||||
this.sessionCheckTimer = setTimeout(() => {
|
||||
this.checkSession();
|
||||
}, refreshIn);
|
||||
}
|
||||
|
||||
private clearSessionRefresh(): void {
|
||||
if (this.sessionCheckTimer) {
|
||||
clearTimeout(this.sessionCheckTimer);
|
||||
this.sessionCheckTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getStoredWebSessionID(): string | null {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith(`${WEB_SESSION_COOKIE}=`));
|
||||
|
||||
if (!cookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(cookie.substring(WEB_SESSION_COOKIE.length + 1));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private setStoredWebSessionID(webSessionID: string): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : '';
|
||||
document.cookie = `${WEB_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${WEB_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Lax${secure}`;
|
||||
}
|
||||
|
||||
private clearStoredWebSessionID(): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.cookie = `${WEB_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`;
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,3 @@ export * from './cart.service';
|
||||
export * from './language.service';
|
||||
export * from './seo.service';
|
||||
export * from './location.service';
|
||||
export * from './auth.service';
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, catchError, map } from 'rxjs';
|
||||
import { AuthSession, WebSessionStart } from '../models/auth.model';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { generateGuid } from '../shared/util/guid.util';
|
||||
|
||||
const SESSION_MAX_AGE_SECONDS = 60 * 60;
|
||||
|
||||
/**
|
||||
* The one Telegram QR/session API (`{authApiUrl}/users/sessions`). Customer
|
||||
* login (AuthService) and admin login (AdminAuthService) both call this same
|
||||
* service against this same endpoint - there is no separate admin backend.
|
||||
* This class only does the HTTP call + response normalization; it holds no
|
||||
* session state and writes no cookies, so each caller manages its own
|
||||
* storage/signals independently on top of it.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TelegramSessionApiService {
|
||||
private readonly authApiUrl = environment.authApiUrl;
|
||||
|
||||
constructor(private readonly http: HttpClient) {}
|
||||
|
||||
createSession(): Observable<WebSessionStart> {
|
||||
const webSessionID = generateGuid();
|
||||
|
||||
return this.http.post<Record<string, unknown>>(
|
||||
`${this.authApiUrl}/users/sessions`,
|
||||
{ webSessionID },
|
||||
{ headers: { WebSessionID: webSessionID } }
|
||||
).pipe(
|
||||
map(response => {
|
||||
const responseWebSessionID = this.extractSessionId(response, webSessionID);
|
||||
return {
|
||||
webSessionID: responseWebSessionID,
|
||||
url: this.getBotLoginUrl(responseWebSessionID),
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
checkSessionOnce(webSessionID: string | null): Observable<AuthSession | null> {
|
||||
if (!webSessionID) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return this.http.get<Record<string, unknown>>(
|
||||
`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`
|
||||
).pipe(
|
||||
map(response => this.normalizeWebSession(response, webSessionID)),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
logout(webSessionID: string): Observable<unknown> {
|
||||
return this.http.delete(`${this.authApiUrl}/users/sessions/${encodeURIComponent(webSessionID)}`, {
|
||||
headers: { WebSessionID: webSessionID }
|
||||
}).pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
getBotLoginUrl(webSessionID: string): string {
|
||||
return `https://t.me/${this.getBotUsername()}?start=${encodeURIComponent(webSessionID)}`;
|
||||
}
|
||||
|
||||
getBotAppLoginUrl(webSessionID: string): string {
|
||||
return `tg://resolve?domain=${encodeURIComponent(this.getBotUsername())}&start=${encodeURIComponent(webSessionID)}`;
|
||||
}
|
||||
|
||||
private getBotUsername(): string {
|
||||
return (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
|
||||
}
|
||||
|
||||
private normalizeWebSession(response: Record<string, unknown> | null, fallbackSessionId: string): AuthSession | null {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = this.asRecord(this.readFirst(response, ['user', 'User', 'telegramUser', 'TelegramUser'])) ?? response;
|
||||
const status = this.readFirst(response, [
|
||||
'status', 'Status', 'active', 'Active', 'loggedIn', 'LoggedIn',
|
||||
'isLoggedIn', 'IsLoggedIn', 'authenticated', 'Authenticated'
|
||||
]);
|
||||
const active = this.isActiveStatus(status);
|
||||
const sessionId = this.extractSessionId(response, fallbackSessionId);
|
||||
const username = this.readString(this.readFirst(user, ['username', 'Username']))
|
||||
?? this.readString(this.readFirst(response, ['username', 'Username']));
|
||||
const firstName = this.readString(this.readFirst(user, ['firstName', 'first_name', 'FirstName', 'First_name']));
|
||||
const lastName = this.readString(this.readFirst(user, ['lastName', 'last_name', 'LastName', 'Last_name']));
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||
const explicitDisplayName = this.readString(this.readFirst(response, ['displayName', 'DisplayName', 'name', 'Name']))
|
||||
?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name']));
|
||||
const displayName = explicitDisplayName ?? username ?? (fullName || 'Telegram User');
|
||||
const telegramUserId = this.readNumber(this.readFirst(user, ['userId', 'telegramUserId', 'telegramUserID', 'TelegramUserID', 'id', 'ID']))
|
||||
?? this.readNumber(this.readFirst(response, ['userId', 'telegramUserId', 'telegramUserID', 'TelegramUserID', 'userID', 'UserID', 'UserId']))
|
||||
?? null;
|
||||
const expiresAt = this.readString(this.readFirst(response, ['expiresAt', 'ExpiresAt', 'expires', 'Expires']))
|
||||
?? new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000).toISOString();
|
||||
|
||||
return { sessionId, userId: telegramUserId, username, displayName, active, expires: expiresAt };
|
||||
}
|
||||
|
||||
private extractSessionId(response: Record<string, unknown> | null, fallbackSessionId: string): string {
|
||||
if (!response) {
|
||||
return fallbackSessionId;
|
||||
}
|
||||
return this.readString(this.readFirst(response, [
|
||||
'webSessionID', 'WebSessionID', 'webSessionId', 'sessionID', 'SessionID', 'sessionId', 'id', 'ID'
|
||||
])) ?? fallbackSessionId;
|
||||
}
|
||||
|
||||
private readFirst(source: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
return source[key];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private readString(value: unknown): string | null {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private readNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
private isActiveStatus(status: unknown): boolean {
|
||||
if (status === true || status === 1) {
|
||||
return true;
|
||||
}
|
||||
if (typeof status !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return ['true', '1', 'active', 'authenticated', 'confirmed', 'success', 'logged_in'].includes(status.toLowerCase());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user