feat(auth): add admin login/password form with generic-error fallback

Wires the existing @marketplaces/auth credentials gateway into the admin
Telegram login dialog behind a toggle. Targets the documented but not-yet-
live POST /api/identity/v1/session contract (BACKEND-INTEGRATION.md §1.2);
any failure - 404 today, a real auth error once the backend ships - shows
one generic "try another way to sign in" message, never the raw error.

Also adds public/favicon.ico (was 404ing; reuses existing app icon).
This commit is contained in:
sdarbinyan
2026-08-27 04:03:24 +04:00
parent 7e3f2d743f
commit 80c9978e03
9 changed files with 195 additions and 2 deletions

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 B

View File

@@ -43,6 +43,11 @@ export const appConfig: ApplicationConfig = {
useFactory: (tenantResolver: TenantResolverService): MarketplacesAuthConfig => ({
apiUrl: environment.authApiUrl,
marketplaceDomain: () => tenantResolver.getBaseDomain(),
// Admin login/password contract per docs/backend/BACKEND-INTEGRATION.md §1.2.
// Not live yet - backend endpoint doesn't exist, so every call here 404s
// until it ships. That's fine: the UI treats any failure as "something
// went wrong, try Telegram instead" and never surfaces the raw error.
credentialsPath: '/api/identity/v1/session',
}),
deps: [TenantResolverService]
},

View File

@@ -64,6 +64,38 @@
}
</div>
@if (mode === 'admin') {
@if (!credentialsVisible()) {
<button class="credentials-toggle" type="button" (click)="toggleCredentials()">
{{ 'auth.loginWithCredentials' | translate }}
</button>
} @else {
<form class="credentials-form" (submit)="submitCredentials($event)">
<label>
{{ 'auth.credentialsLoginLabel' | translate }}
<input type="text" autocomplete="username" [value]="credentialsLogin()"
(input)="credentialsLogin.set($any($event.target).value)" />
</label>
<label>
{{ 'auth.credentialsPasswordLabel' | translate }}
<input type="password" autocomplete="current-password" [value]="credentialsPassword()"
(input)="credentialsPassword.set($any($event.target).value)" />
</label>
@if (credentialsError()) {
<p class="credentials-error" role="alert">{{ 'auth.credentialsGenericError' | translate }}</p>
}
<div class="credentials-actions">
<button type="submit" class="credentials-submit" [disabled]="credentialsBusy()">
{{ 'auth.credentialsSubmit' | translate }}
</button>
<button type="button" class="credentials-back" (click)="toggleCredentials()">
{{ 'auth.credentialsBack' | translate }}
</button>
</div>
</form>
}
}
<p class="login-note">{{ (mode === 'admin' ? 'auth.adminLoginNote' : 'auth.loginNote') | translate }}</p>
}
</div>

View File

@@ -195,6 +195,91 @@ h2 {
}
}
.credentials-toggle {
display: block;
width: 100%;
margin-top: 16px;
padding: 10px;
border: none;
background: none;
color: var(--accent-color, #497671);
font-size: var(--font-size-sm, 0.8125rem);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.credentials-form {
margin-top: 16px;
text-align: left;
label {
display: block;
margin-bottom: 12px;
font-size: var(--font-size-sm, 0.8125rem);
color: var(--text-secondary, #666);
input {
display: block;
width: 100%;
margin-top: 6px;
padding: 10px 12px;
border: 1px solid #e0e0e0;
border-radius: var(--radius-md, 12px);
font-size: var(--font-size-base, 0.875rem);
color: var(--text-primary, #1a1a1a);
background: var(--bg-card, #fff);
&:focus {
outline: none;
border-color: var(--accent-color, #497671);
}
}
}
.credentials-error {
margin: 0 0 12px;
font-size: var(--font-size-xs, 0.75rem);
color: #d32f2f;
line-height: 1.4;
}
.credentials-actions {
display: flex;
gap: 10px;
}
.credentials-submit {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: var(--radius-md, 12px);
background: var(--accent-color, #497671);
color: #fff;
font-size: var(--font-size-base, 0.875rem);
font-weight: var(--font-weight-semibold, 600);
cursor: pointer;
transition: opacity 0.2s ease;
&:disabled {
opacity: 0.6;
cursor: wait;
}
}
.credentials-back {
padding: 12px 16px;
border: 1px solid #e0e0e0;
border-radius: var(--radius-md, 12px);
background: none;
color: var(--text-secondary, #666);
font-size: var(--font-size-base, 0.875rem);
cursor: pointer;
}
}
.login-note {
margin: 16px 0 0;
font-size: var(--font-size-xs, 0.75rem);

View File

@@ -1,6 +1,6 @@
import { Component, ChangeDetectionStrategy, Input, Injector, Signal, inject, effect, OnDestroy, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, Input, Injector, Signal, signal, inject, effect, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService, AdminAuthService, AuthSession } from '@marketplaces/auth';
import { AuthService, AdminAuthService, AuthSession, MARKETPLACES_AUTH_GATEWAY } from '@marketplaces/auth';
import { LanguageService } from '../../services/language.service';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine';
@@ -27,6 +27,7 @@ export class TelegramLoginComponent implements OnInit, OnDestroy {
private readonly customerAuth = inject(AuthService);
private readonly adminAuth = inject(AdminAuthService);
private readonly authGateway = inject(MARKETPLACES_AUTH_GATEWAY);
private readonly injector = inject(Injector);
private readonly router = inject(Router);
private readonly languageService = inject(LanguageService);
@@ -41,6 +42,17 @@ export class TelegramLoginComponent implements OnInit, OnDestroy {
qrStatus!: Signal<QrLoginStatus>;
encodedQrUrl!: Signal<string>;
// Admin-only: password login per docs/backend/BACKEND-INTEGRATION.md §1.2.
// Backend endpoint isn't live yet, so any failure here (404 today, or a
// real auth failure once it ships) surfaces as one generic message -
// never the raw error - per product decision to keep Telegram as the
// fallback path rather than leak backend state.
credentialsVisible = signal(false);
credentialsLogin = signal('');
credentialsPassword = signal('');
credentialsBusy = signal(false);
credentialsError = signal(false);
ngOnInit(): void {
const service = this.mode === 'admin' ? this.adminAuth : this.customerAuth;
this.showDialog = service.showLoginDialog;
@@ -81,6 +93,7 @@ export class TelegramLoginComponent implements OnInit, OnDestroy {
close(): void {
this.engine.setActive(false);
(this.mode === 'admin' ? this.adminAuth : this.customerAuth).hideLogin();
this.resetCredentials();
}
openTelegramLogin(): void {
@@ -90,4 +103,38 @@ export class TelegramLoginComponent implements OnInit, OnDestroy {
refreshQr(): void {
this.engine.refresh();
}
toggleCredentials(): void {
this.credentialsVisible.update(visible => !visible);
this.credentialsError.set(false);
}
submitCredentials(event: Event): void {
event.preventDefault();
const login = this.credentialsLogin().trim();
const password = this.credentialsPassword();
if (!login || !password || this.credentialsBusy()) return;
this.credentialsBusy.set(true);
this.credentialsError.set(false);
this.authGateway.loginWithCredentials('admin', { login, password }).subscribe({
next: () => {
this.credentialsBusy.set(false);
this.adminAuth.onLoginComplete();
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'dashboard']);
},
error: () => {
this.credentialsBusy.set(false);
this.credentialsError.set(true);
},
});
}
private resetCredentials(): void {
this.credentialsVisible.set(false);
this.credentialsLogin.set('');
this.credentialsPassword.set('');
this.credentialsBusy.set(false);
this.credentialsError.set(false);
}
}

View File

@@ -1164,6 +1164,12 @@ export const en: Translations = {
loginNote: 'You will be redirected back after login',
qrExpired: 'QR code expired. Click to refresh',
qrError: 'Could not create login session. Click to retry',
loginWithCredentials: 'Sign in with login and password',
credentialsLoginLabel: 'Login',
credentialsPasswordLabel: 'Password',
credentialsSubmit: 'Sign in',
credentialsBack: 'Back',
credentialsGenericError: 'Something went wrong. Try another way to sign in.',
},
ux: {
items: 'items',

View File

@@ -1164,6 +1164,12 @@ export const hy: Translations = {
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար',
loginWithCredentials: 'Մուտք գործել լոգինով և գաղտնաբառով',
credentialsLoginLabel: 'Լոգին',
credentialsPasswordLabel: 'Գաղտնաբառ',
credentialsSubmit: 'Մուտք',
credentialsBack: 'Հետ',
credentialsGenericError: 'Ինչ-որ բան այն չէ։ Փորձեք մուտք գործել այլ եղանակով։',
},
ux: {
items: 'ապրանք',

View File

@@ -1164,6 +1164,12 @@ export const ru: Translations = {
loginNote: 'После входа вы будете перенаправлены обратно',
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить',
loginWithCredentials: 'Войти по логину и паролю',
credentialsLoginLabel: 'Логин',
credentialsPasswordLabel: 'Пароль',
credentialsSubmit: 'Войти',
credentialsBack: 'Назад',
credentialsGenericError: 'Что-то пошло не так. Попробуйте войти другим способом.',
},
ux: {
items: 'товаров',

View File

@@ -1163,6 +1163,12 @@ export interface Translations {
loginNote: string;
qrExpired: string;
qrError: string;
loginWithCredentials: string;
credentialsLoginLabel: string;
credentialsPasswordLabel: string;
credentialsSubmit: string;
credentialsBack: string;
credentialsGenericError: string;
};
ux: {
items: string;