From 74f0719f443402ffbfbe1e0a28710261c3a580d9 Mon Sep 17 00:00:00 2001 From: For Study and work Date: Tue, 10 Nov 2026 20:16:44 +0300 Subject: [PATCH 01/10] feat: support white label program signup and client host sessions --- .../feature-specs/white-label-registration.md | 52 +++++++++++ .../lib/services/tokens/token.service.spec.ts | 47 ++++++++++ .../src/lib/services/tokens/token.service.ts | 10 ++- .../program/program-registration.model.ts | 2 + .../program-registration-http.adapter.spec.ts | 14 +++ .../public-registration.component.html | 72 ++++++++++------ .../public-registration.component.spec.ts | 86 ++++++++++++++++++- .../public-registration.component.ts | 20 ++++- 8 files changed, 269 insertions(+), 34 deletions(-) create mode 100644 docs/feature-specs/white-label-registration.md diff --git a/docs/feature-specs/white-label-registration.md b/docs/feature-specs/white-label-registration.md new file mode 100644 index 000000000..c6e027ac3 --- /dev/null +++ b/docs/feature-specs/white-label-registration.md @@ -0,0 +1,52 @@ + + +# Короткая регистрация в клиентском экземпляре + +Существующая `/program-registration/:id` получает дополнительный вариант из API. +При `account_mode: "email_password"` новый участник вводит почту, пароль/повтор и +подтверждает прежние согласия. ФИО и дата рождения скрыты и отсутствуют в payload; +обязательность динамических вопросов анкеты остается. Письмо и подтверждение +используют существующий flow, затем открывается программа без общего онбординга. + +`branding: {name, logo_url}` задает имя и логотип формы, входа и заголовка вкладки. +Без логотипа показывается имя клиента. Компоненты, Mont, стили, состояния формы, +ошибки, ссылки, сохранение ответов, подтверждение почты и permissions сохраняются. +Новая страница и новый макет не создаются; квиз не входит в изменения. + +Без новых ключей API форма сохраняет полные поля и бренд PROCOLLAB. Отдельно +TokenService сохраняет production cookies для текущего клиентского host; +домен `.procollab.ru` используется на доменах платформы, ключи dev/prod прежние. + +## Критерии и проверка + +- WL10: скрытые поля профиля отсутствуют в payload, остальные проверки работают. + Component tests: короткая/полная форма, пароли, согласия, вопросы и переход назад. +- WL11: имя/логотип и заголовки зависят от API, обычная форма сохраняет бренд. + Component и HTTP adapter tests проверяют в том числе вложенное snake/camel casing. +- WL12: клиентский host получает собственные production cookies, домены платформы + сохраняют общий domain. TokenService tests включают похожие посторонние домены. + +Проверки 10 ноября 2026: 44 целевых Vitest tests (component, HTTP adapter, +TokenService, AuthRequiredGuard и BearerTokenInterceptor), scoped ESLint/Prettier, +`test:solution-contract`, production Angular build. Полный Vitest suite не запускался. +Сборка проходит с предупреждениями Sass, budgets и CommonJS в существующих файлах. + +В браузере Chromium production сборка работала с настоящим локальным API и +отдельной PostgreSQL. Проверены desktop 1440×1000 и mobile 390×844, регистрация → +локальное письмо → подтверждение → программа → reload с сохранением сессии. +Дополнительно проверены фокус с клавиатуры, отсутствие горизонтального скролла, +ошибка обязательных данных, переключение входа/восстановления. Консольных JS ошибок +в сценарии не было. Внешние запросы блокировались; адреса API перенаправлялись на +loopback перехватом браузера. Изображение логотипа проверено unit tests, реальные +клиентские assets еще не предоставлены. Детектор ошибочно пометил Angular `[src]` +как отсутствующий `src`; привязка и условие непустого URL подтверждены по source. + +## Внедрение + +Сначала backend с добавочным контрактом, затем этот frontend. White label на +общем стенде по умолчанию выключен. Схема БД не меняется. Реальные клиентские +домен/логотип, общий кабинет, favicon/статическая metadata, legal documents, +остальные письма и публичное развертывание входят в последующие этапы. +Адреса API/WebSocket клиентской сборки необходимо настроить отдельно: текущие +environment-файлы продолжают указывать на обычные dev/prod API. Этот PR не создает +и не публикует клиентский стенд. DNS/HTTPS/SMTP и нагрузочная проверка не выполнены. diff --git a/projects/core/src/lib/services/tokens/token.service.spec.ts b/projects/core/src/lib/services/tokens/token.service.spec.ts index 22c5158c1..08cb63573 100644 --- a/projects/core/src/lib/services/tokens/token.service.spec.ts +++ b/projects/core/src/lib/services/tokens/token.service.spec.ts @@ -27,6 +27,53 @@ describe("TokenService", () => { it("should be created", () => { expect(service).toBeTruthy(); }); + it.each(["procollab.ru", "app.procollab.ru", "dev.procollab.ru"])( + "сохраняет общий production cookie domain на %s", + hostname => { + const production = new TokenService(TestBed.inject(ApiService), true, { + location: { hostname }, + } as Document); + expect(production.getCookieOptions()).toMatchObject({ + domain: ".procollab.ru", + secure: true, + sameSite: "None", + expires: expect.any(Date), + }); + }, + ); + it.each(["championship.example.test", "procollab.ru.example.test", "otherprocollab.ru"])( + "сохраняет и удаляет production cookies текущего host на %s", + hostname => { + const set = vi.spyOn(Cookies, "set").mockImplementation(() => undefined); + const remove = vi.spyOn(Cookies, "remove").mockImplementation(() => undefined); + try { + const production = new TokenService(TestBed.inject(ApiService), true, { + location: { hostname }, + } as Document); + const options = production.getCookieOptions(); + expect(options).not.toHaveProperty("domain"); + expect(options).toMatchObject({ secure: true, sameSite: "None" }); + production.memTokens({ access: "demo-access", refresh: "demo-refresh" }); + expect(set).toHaveBeenCalledWith( + "accessToken", + "demo-access", + expect.not.objectContaining({ domain: expect.anything() }), + ); + production.clearTokens(); + expect(remove).toHaveBeenCalledWith( + "accessToken", + expect.not.objectContaining({ domain: expect.anything() }), + ); + expect(remove).toHaveBeenCalledWith( + "refreshToken", + expect.not.objectContaining({ domain: expect.anything() }), + ); + } finally { + set.mockRestore(); + remove.mockRestore(); + } + }, + ); it("выход на dev очищает только dev-сессию, сохраняя production cookies", () => { const keys = ["devAccessToken", "devRefreshToken", "accessToken", "refreshToken"]; try { diff --git a/projects/core/src/lib/services/tokens/token.service.ts b/projects/core/src/lib/services/tokens/token.service.ts index 015bc767a..093bbf70e 100644 --- a/projects/core/src/lib/services/tokens/token.service.ts +++ b/projects/core/src/lib/services/tokens/token.service.ts @@ -1,6 +1,7 @@ /** @format */ import { Inject, Injectable } from "@angular/core"; +import { DOCUMENT } from "@angular/common"; import { map, Observable } from "rxjs"; import { plainToInstance } from "class-transformer"; import Cookies from "js-cookie"; @@ -13,7 +14,7 @@ import { RefreshResponse, Tokens } from "@core/public-api"; * Сервис для управления JWT токенами аутентификации * * Основные функции: - * - Хранение access и refresh токенов в HTTP-only cookies + * - Хранение access и refresh токенов в cookies * - Автоматическое обновление токенов при истечении срока действия * - Управление настройками cookies в зависимости от окружения * - Очистка токенов при выходе из системы @@ -53,6 +54,7 @@ export class TokenService { constructor( private apiService: ApiService, @Inject(PRODUCTION) private production: boolean, + @Inject(DOCUMENT) private readonly document: Document, ) {} /** @@ -80,7 +82,7 @@ export class TokenService { * @returns Объект с настройками для js-cookie * * Production настройки: - * - domain: ".procollab.ru" - cookies доступны на всех поддоменах + * - domain: ".procollab.ru" на доменах платформы; текущий host у клиента * - expires: 30 дней - автоматическое истечение * * Development настройки: @@ -89,8 +91,10 @@ export class TokenService { */ getCookieOptions(): Parameters[2] { if (this.production) { + const hostname = this.document.location?.hostname; + const platformHost = hostname === "procollab.ru" || hostname?.endsWith(".procollab.ru"); return { - domain: ".procollab.ru", // Домен для production окружения + ...(platformHost ? { domain: ".procollab.ru" } : {}), expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 дней secure: true, sameSite: "None", diff --git a/projects/social_platform/src/app/domain/program/program-registration.model.ts b/projects/social_platform/src/app/domain/program/program-registration.model.ts index 0ead2ad0d..c8dc57d00 100644 --- a/projects/social_platform/src/app/domain/program/program-registration.model.ts +++ b/projects/social_platform/src/app/domain/program/program-registration.model.ts @@ -39,6 +39,8 @@ export interface RegistrationPublic { enrolled: boolean; programUrl: string; registrationEnds: string; + accountMode?: "email_password"; + branding?: { name: string; logoUrl: string }; } export interface RegistrationImport { options: string[]; diff --git a/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.spec.ts index 2a29c3ede..3b9eca15d 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/program/program-registration-http.adapter.spec.ts @@ -23,6 +23,20 @@ describe("Контракт регистрации программы", () => { http = TestBed.inject(HttpTestingController); }); afterEach(() => http.verify()); + it("читает короткий режим и вложенный логотип из snake_case ответа API", () => { + adapter.publicForm(4).subscribe(result => { + expect(result.accountMode).toBe("email_password"); + expect(result.branding).toEqual({ + name: "Чемпионат клиента", + logoUrl: "https://client.example.test/logo.svg", + }); + }); + const request = http.expectOne("https://local.test/programs/4/registration/"); + request.flush({ + account_mode: "email_password", + branding: { name: "Чемпионат клиента", logo_url: "https://client.example.test/logo.svg" }, + }); + }); it("не меняет динамические идентификаторы ответов при snake/camel преобразовании", () => { const key = "f0123456789abcdef0123456789abcdef"; adapter diff --git a/projects/social_platform/src/app/ui/pages/program/registration/public-registration.component.html b/projects/social_platform/src/app/ui/pages/program/registration/public-registration.component.html index fc90f5870..09e412731 100644 --- a/projects/social_platform/src/app/ui/pages/program/registration/public-registration.component.html +++ b/projects/social_platform/src/app/ui/pages/program/registration/public-registration.component.html @@ -1,9 +1,19 @@
- + @if (program(); as data) { + + @if (data.branding; as brand) { + @if (brand.logoUrl) { + + } @else { + {{ brand.name }} + } + } @else { + + } + + } @if (loading()) {

Загружаем программу…

} @@ -15,7 +25,7 @@

{{ data.name }}

{{ mode() === "login" - ? "Вход в PROCOLLAB" + ? "Вход в " + brandName() : mode() === "reset" ? "Восстановление пароля" : mode() === "sent" || mode() === "confirm" || mode() === "expired" @@ -122,22 +132,24 @@

Уже есть аккаунт? Войти
-

Аккаунт PROCOLLAB

-
- -
+

Аккаунт {{ brandName() }}

+ @if (!shortAccount()) { +
+ +
+ }

autocomplete="email" maxlength="254" /> - + @if (!shortAccount()) { + + }