mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✨ feat(internal-ui): несколько организаций — переключатель, разделы администратора установки, приглашения
Переключатель организаций в сайдбаре (дизайн-базлайн v2, A1) показывает все организации человека, текущая отмечена, выбор другой пересобирает Shell по ключу организации. Вход без организации в адресе открывает последнюю открытую или первую по списку вместо экрана «нет доступа»; предпочтение переехало в localStorage. Разделы «Платформа» и «Хранилище файлов» видны только администратору установки, relay для звонков у остальных менеджеров только на чтение; настройки установки запрашиваются с /api/v1/instance/. Ссылка-приглашение /join: вошедший принимает приглашение и попадает в новую организацию, гость без учётной записи задаёт имя и пароль полями мастера первого запуска. Ожидающие приглашения показаны в списке сотрудников строками «Приглашён» с меню «отправить ещё раз» и «отозвать» — теми же элементами, что строка сотрудника. e2e: проекту internal-ui задана русская локаль браузера, моки сессии отдают язык установки — сценарии перестали зависеть от языка машины; добавлены сценарии переключателя, строки приглашения и регистрации гостя. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
ac824e192c
commit
145c44a17d
23 files changed
+580
-39
No files matched your search
@@ -75,6 +75,10 @@ function identityFor(role: Role, memberships = [membershipFor(role)]) {
|
||||
totpEnabled: false,
|
||||
totpLastUsedAt: null,
|
||||
deliveryMode: "SELF_HOSTED",
|
||||
isInstanceAdmin: role === "OWNER",
|
||||
// Язык отдаёт сервер: без него интерфейс открылся бы на языке браузера.
|
||||
language: "ru",
|
||||
uiLanguage: "",
|
||||
memberships,
|
||||
uiTheme: "LIGHT",
|
||||
uiAccent: "#1677ff",
|
||||
@@ -142,8 +146,24 @@ const OWNER_STAFF = {
|
||||
|
||||
// Пустое, но валидное окружение экрана: чат, справочники и чек-лист запуска.
|
||||
// Без него любой экран падает в состояние ошибки и проверять на нём нечего.
|
||||
// Ожидающее приглашение существующей учётной записи: строка со статусом
|
||||
// «Приглашён» перед сотрудниками (кадры E1/E2).
|
||||
const INVITATION = {
|
||||
id: 9,
|
||||
email: "guest@example.com",
|
||||
fullName: "Пётр Приглашённый",
|
||||
avatarUrl: null,
|
||||
role: "EMPLOYEE",
|
||||
positionTitle: "Оператор поддержки",
|
||||
phone: "",
|
||||
groups: [{ id: 1, name: "Операторы" }],
|
||||
invitedAt: "2026-09-10T10:00:00Z",
|
||||
expiresAt: "2026-09-17T10:00:00Z",
|
||||
};
|
||||
|
||||
async function mockInstance(page: Page) {
|
||||
await page.route("**/api/v1/setup/", (route) => route.fulfill({ json: { needsSetup: false } }));
|
||||
await page.route("**/api/v1/instance/**", (route) => route.fulfill({ json: {} }));
|
||||
await page.route("**/api/v1/organizations/*/conversations/**", (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (path.endsWith("/conversations/counters/")) {
|
||||
@@ -192,14 +212,14 @@ async function mockEmployees(page: Page) {
|
||||
.filter((employee) => !role || employee.role === role)
|
||||
.filter((employee) => !query || `${employee.fullName} ${employee.email} ${employee.positionTitle}`.toLowerCase().includes(query));
|
||||
return route.fulfill({
|
||||
json: { items, page: 1, pageSize: 20, total: items.length, pageCount: 1 },
|
||||
json: { items, page: 1, pageSize: 20, total: items.length, pageCount: 1, invitations: [INVITATION] },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mockSession(page: Page, user: object | null) {
|
||||
await page.route("**/api/v1/auth/session/", (route) =>
|
||||
route.fulfill({ json: user ? { authenticated: true, user } : { authenticated: false } }),
|
||||
route.fulfill({ json: user ? { authenticated: true, user } : { authenticated: false, language: "ru" } }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -279,6 +299,86 @@ test("организация выбирается адресом, а не сох
|
||||
expect(requests.filter((url) => url.includes(ORGANIZATION_PUBLIC_ID))).toEqual([]);
|
||||
});
|
||||
|
||||
test("ожидающее приглашение показано строкой «Приглашён» с меню отправки и отзыва", async ({ page }) => {
|
||||
await mockSession(page, OWNER_IDENTITY);
|
||||
await mockInstance(page);
|
||||
await mockEmployees(page);
|
||||
const posted: string[] = [];
|
||||
await page.route("**/api/v1/organizations/*/employees/invitations/**", (route) => {
|
||||
posted.push(new URL(route.request().url()).pathname);
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
});
|
||||
|
||||
await page.goto(`/organizations/${ORGANIZATION_PUBLIC_ID}/employees`);
|
||||
|
||||
const row = page.locator(".employees-row.is-invited");
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(row.getByText("Пётр Приглашённый")).toBeVisible();
|
||||
await expect(row.locator(".employees-badge.has-dot")).toHaveText("Приглашён");
|
||||
await expect(row.getByText("Оператор поддержки")).toBeVisible();
|
||||
|
||||
await row.locator(".employees-row-menu").click();
|
||||
await page.getByRole("button", { name: "Отправить приглашение ещё раз" }).click();
|
||||
await expect.poll(() => posted).toContain(`/api/v1/organizations/${ORGANIZATION_PUBLIC_ID}/employees/invitations/9/resend/`);
|
||||
});
|
||||
|
||||
test("гость по ссылке-приглашению задаёт имя и пароль и попадает в организацию", async ({ page }) => {
|
||||
await mockSession(page, null);
|
||||
await mockInstance(page);
|
||||
await mockEmployees(page);
|
||||
await page.route("**/api/v1/auth/invitations/preview/**", (route) =>
|
||||
route.fulfill({ json: { valid: true, email: "new-owner@example.com", organizationName: "Новая организация", accountExists: false } }),
|
||||
);
|
||||
const registered: string[] = [];
|
||||
await page.route("**/api/v1/auth/invitations/register/", async (route) => {
|
||||
registered.push(route.request().postData() ?? "");
|
||||
const user = identityFor("OWNER", [membershipFor("OWNER", { organizationPublicId: SECOND_ORGANIZATION_PUBLIC_ID, organization: "new", organizationName: "Новая организация" })]);
|
||||
// После регистрации сессия уже есть — сессионный мок отвечает как для вошедшего.
|
||||
await mockSession(page, user);
|
||||
return route.fulfill({ status: 201, json: { authenticated: true, user, organizationPublicId: SECOND_ORGANIZATION_PUBLIC_ID } });
|
||||
});
|
||||
|
||||
await page.goto("/join?token=guest-token");
|
||||
|
||||
await expect(page.getByText("Вас приглашают в «Новая организация»")).toBeVisible();
|
||||
await page.getByPlaceholder("Елена Кузнецова").fill("Новый Владелец");
|
||||
await page.getByPlaceholder("Минимум 10 символов").fill("Very-strong-passphrase-42");
|
||||
await page.getByRole("button", { name: "Принять приглашение" }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/organizations/${SECOND_ORGANIZATION_PUBLIC_ID}/`));
|
||||
await expect(managerNav(page)).toHaveCount(MANAGER_NAV.length);
|
||||
expect(registered[0]).toContain("guest-token");
|
||||
});
|
||||
|
||||
test("с несколькими организациями вход открывает первую, переключатель ведёт во вторую", async ({ page }) => {
|
||||
const secondMembership = membershipFor("OWNER", {
|
||||
id: 3,
|
||||
organizationPublicId: SECOND_ORGANIZATION_PUBLIC_ID,
|
||||
organization: "second",
|
||||
organizationName: "Вторая организация",
|
||||
});
|
||||
await mockInstance(page);
|
||||
await mockEmployees(page);
|
||||
await mockSession(page, identityFor("OWNER", [membershipFor("OWNER"), secondMembership]));
|
||||
|
||||
// Адрес без организации: открывается первая по списку, а не экран «нет доступа».
|
||||
await page.goto("/");
|
||||
|
||||
await expect(managerNav(page)).toHaveCount(MANAGER_NAV.length);
|
||||
await expect(page).toHaveURL(new RegExp(`/organizations/${ORGANIZATION_PUBLIC_ID}/`));
|
||||
await expect(page.locator(".hub-brand-switch span")).toHaveText("Ателье Норд");
|
||||
|
||||
// Переключатель (A1): в списке обе организации, текущая отмечена.
|
||||
await page.locator(".hub-brand-switch").click();
|
||||
const menu = page.locator(".app-dropdown");
|
||||
await expect(menu.getByRole("button", { name: "Ателье Норд" })).toHaveClass(/is-checked/);
|
||||
await menu.getByRole("button", { name: "Вторая организация" }).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/organizations/${SECOND_ORGANIZATION_PUBLIC_ID}/`));
|
||||
await expect(page.locator(".hub-brand-switch span")).toHaveText("Вторая организация");
|
||||
await expect(managerNav(page)).toHaveCount(MANAGER_NAV.length);
|
||||
});
|
||||
|
||||
test("интерфейс работает на минимальной поддерживаемой ширине 1024px", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1024, height: 768 });
|
||||
await mockInstance(page);
|
||||
|
||||
Reference in new issue
Block a user