✨ 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:
AndreyandClaude Fable 5.1 committed 2026-09-12 14:37:52 +03:00
1 parent ac824e192c
commit 145c44a17d
23 files changed
+580 -39

No files matched your search

+43 -4
View File
@@ -12,7 +12,7 @@ import { api, setActiveOrganization } from "./api/client";
import { fetchAgentDirectory } from "./features/agents/model";
import { canAccess, defaultRoute, isManager } from "./auth/access";
import { activateOrganization, clearOrganizationPreference } from "./auth/session";
import { AuthChangePassword, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
import { AuthChangePassword, AuthJoin, AuthJoinGuest, AuthLogin, AuthPasswordRecovery, AuthResetPassword, AuthSetup, AuthTotpCode, AuthTotpSetup } from "./features/auth/AuthScreens";
import { Shell } from "./layout/Shell";
import { pathFromRoute, routeFromPath } from "./router";
import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui";
@@ -42,6 +42,14 @@ export function App() {
const [totpChallenge, setTotpChallenge] = useState<AuthChallenge | null>(null);
const [recovering, setRecovering] = useState(false);
const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password");
// Ссылка из письма-приглашения (/join?token=…): токен запоминается до входа
// и принимается, как только известна учётная запись.
const [joinToken, setJoinToken] = useState<string | null>(
() => (window.location.pathname === "/join" ? new URLSearchParams(window.location.search).get("token") : null),
);
// До входа ссылка ведёт либо на форму создания пароля (учётной записи ещё
// нет), либо на обычный вход — это решает предпросмотр приглашения.
const [joinNeedsLogin, setJoinNeedsLogin] = useState(false);
// Мастер первого запуска: пока в инстансе нет организации, вместо входа — форма создания.
const [needsSetup, setNeedsSetup] = useState(false);
const [data, setData] = useState<AppData>({ groups: [], agents: [] });
@@ -90,7 +98,8 @@ export function App() {
if (!payload.authenticated) acceptServerLanguage(payload.language);
if (payload.authenticated && payload.user) {
const activeUser = useIdentity(payload.user, initialRoute.organizationPublicId);
if (activeUser && !initialRoute.organizationPublicId) {
// Адрес без организации или с чужой: подставляем ту, что открылась.
if (activeUser && activeUser.organizationPublicId !== initialRoute.organizationPublicId) {
const nextPath = pathFromRoute(
initialRoute.route,
initialRoute.employeeId || initialRoute.agentId || initialRoute.knowledgeId || initialRoute.clientId || initialRoute.channelId || initialRoute.supportPortalId || initialRoute.portalSettingsSection || initialRoute.settingsSection,
@@ -133,6 +142,32 @@ export function App() {
useIdentity(nextIdentity, organizationPublicId);
}, [organizationPublicId, useIdentity]);
// Переключатель в сайдбаре: другая организация открывается со своего
// стартового экрана, Shell пересобирается по key — состояние страниц
// прежней организации в новую не переезжает.
const switchOrganization = useCallback((nextOrganizationId: string) => {
if (!identity) return;
const activeUser = useIdentity(identity, nextOrganizationId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, false, activeUser.organizationPublicId);
}
}, [identity, navigate, useIdentity]);
const finishJoin = useCallback((nextIdentity: AuthenticatedUser, organizationId: string) => {
setJoinToken(null);
const activeUser = useIdentity(nextIdentity, organizationId);
if (activeUser) {
navigate(defaultRoute(activeUser), null, true, activeUser.organizationPublicId);
}
}, [navigate, useIdentity]);
const joinUseLogin = useCallback(() => setJoinNeedsLogin(true), []);
const cancelJoin = useCallback(() => {
setJoinToken(null);
if (user) navigate(defaultRoute(user), null, true, user.organizationPublicId);
}, [navigate, user]);
async function logout() {
await api("/api/v1/auth/logout/", { method: "POST" }).catch(() => undefined);
setIdentity(null);
@@ -160,13 +195,17 @@ export function App() {
{totpChallenge ? (
<AuthTotpCode challenge={totpChallenge} onVerified={(nextUser) => { setTotpChallenge(null); landAfterAuth(nextUser); }} />
) : !identity ? (
needsSetup ? (
joinToken && !joinNeedsLogin && !needsSetup ? (
<AuthJoinGuest token={joinToken} onUseLogin={joinUseLogin} onRegistered={finishJoin} />
) : needsSetup ? (
<AuthSetup onDone={(nextUser) => { setNeedsSetup(false); landAfterAuth(nextUser); }} />
) : recovering ? (
<AuthPasswordRecovery onBackToLogin={() => setRecovering(false)} />
) : (
<AuthLogin onLogin={landAfterAuth} onTotpChallenge={setTotpChallenge} onRecover={() => setRecovering(true)} />
)
) : joinToken ? (
<AuthJoin token={joinToken} onAccepted={finishJoin} onBack={cancelJoin} />
) : !user ? (
<PermissionScreen onReturn={logout} />
) : user.mustChangePassword ? (
@@ -178,7 +217,7 @@ export function App() {
) : !canAccess(user, navigation.route) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user), null, true)} />
) : (
<Shell route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} />
<Shell key={user.organizationPublicId} route={navigation.route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={navigation.selectedEmployeeId} selectedAgentId={navigation.selectedAgentId} selectedKnowledgeId={navigation.selectedKnowledgeId} selectedConversationId={navigation.selectedConversationId} selectedClientId={navigation.selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedChannelId={navigation.selectedChannelId} openChannelRoute={(channelId) => navigate("agentDetail", channelId)} selectedSupportPortalId={navigation.selectedSupportPortalId} openSupportPortalRoute={(portalId) => navigate("supportPortalDetail", portalId)} portalSettingsSection={navigation.selectedPortalSection} openPortalSettingsRoute={(portalId, section) => navigate("supportPortalSettings", `${portalId}/${section ?? ""}`)} settingsSection={navigation.selectedSettingsSection} openSettingsRoute={(section) => navigate("settings", section)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openAgentRoute={(agentId) => navigate("agentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("knowledgeDetail", knowledgeId)} openKnowledgeEditorRoute={(knowledgeId) => (knowledgeId === null ? navigate("knowledgeCreate") : navigate("knowledgeEdit", knowledgeId))} openConversationRoute={(conversationId) => navigate("chat", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={refreshIdentity} onLogout={logout} onSwitchOrganization={switchOrganization} />
)}
</ConfigProvider>
);
+1
View File
@@ -12,6 +12,7 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser {
totpEnabled: false,
totpLastUsedAt: null,
deliveryMode: "CLOUD",
isInstanceAdmin: false,
uiTheme: "SYSTEM",
uiAccent: "",
uiLanguage: "",
+30 -6
View File
@@ -2,19 +2,43 @@ import type { AuthenticatedUser, SessionUser } from "../types";
const preferenceKey = "chatballs.organizationPublicId";
function storedPreference(): string | null {
try {
return window.localStorage.getItem(preferenceKey);
} catch {
return null;
}
}
function rememberPreference(publicId: string): void {
try {
window.localStorage.setItem(preferenceKey, publicId);
} catch {
// Хранилище недоступно (приватный режим, запрет сайта) — адрес всё равно
// несёт организацию, а без него откроется первая по списку.
}
}
/** Выбрать организацию сессии: из адреса, иначе последнюю открытую, иначе
* первую в списке членств (сервер отдаёт их по имени). Человек с несколькими
* организациями не упирается в «нет доступа» — он входит в одну из своих и
* меняет её переключателем в сайдбаре (дизайн-базлайн v2, A1). */
export function activateOrganization(
identity: AuthenticatedUser,
requestedPublicId: string | null,
): SessionUser | null {
const preferred = requestedPublicId || window.sessionStorage.getItem(preferenceKey);
const membership =
identity.memberships.find((item) => item.organizationPublicId === preferred) ||
(identity.memberships.length === 1 ? identity.memberships[0] : null);
const byId = (publicId: string | null) =>
publicId ? identity.memberships.find((item) => item.organizationPublicId === publicId) : undefined;
const membership = byId(requestedPublicId) || byId(storedPreference()) || identity.memberships[0];
if (!membership) return null;
window.sessionStorage.setItem(preferenceKey, membership.organizationPublicId);
rememberPreference(membership.organizationPublicId);
return { ...identity, ...membership };
}
export function clearOrganizationPreference(): void {
window.sessionStorage.removeItem(preferenceKey);
try {
window.localStorage.removeItem(preferenceKey);
} catch {
// См. rememberPreference.
}
}
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { api } from "../../api/client";
import { Icon } from "../../shared/icons";
import { AuthFrame } from "./AuthFrame";
import { t } from "../../i18n";
import type { AuthenticatedUser } from "../../types";
// Приглашение в организацию по ссылке из письма (/join?token=…): человек уже
// вошёл под своей учётной записью, приглашение принимается само, и приложение
// открывается в новой организации. Отказ сервера показывается его же словами.
type Status = "accepting" | "failed";
export function AuthJoin({ token, onAccepted, onBack }: {
token: string;
onAccepted: (user: AuthenticatedUser, organizationPublicId: string) => void;
onBack: () => void;
}) {
const [status, setStatus] = useState<Status>("accepting");
const [detail, setDetail] = useState("");
useEffect(() => {
let active = true;
api<{ user: AuthenticatedUser; organizationPublicId: string }>("/api/v1/auth/invitations/accept/", {
method: "POST",
body: JSON.stringify({ token }),
})
.then((payload) => { if (active) onAccepted(payload.user, payload.organizationPublicId); })
.catch((error) => {
if (!active) return;
setDetail(error instanceof Error ? error.message : "");
setStatus("failed");
});
return () => { active = false; };
}, [token, onAccepted]);
if (status === "accepting") {
return (
<AuthFrame title={t("auth.invitation_title")} subtitle={t("admin.one_moment")} logo="shield">
<div className="auth-card"><p className="auth-hint">{t("auth.invitation_accepting")}</p></div>
</AuthFrame>
);
}
const backLink = (
<button type="button" className="auth-back-login" onClick={onBack}><Icon name="arrow" size={14} />{t("profile.go_start_page")}</button>
);
return (
<AuthFrame title={t("auth.invitation_title")} subtitle={t("auth.invitation_not_accepted")} logo="shield" note={backLink}>
<div className="auth-card">
<div className="auth-recovery-sent">
<div className="auth-recovery-sent-icon warning"><Icon name="warning" size={26} /></div>
<h3>{t("auth.invitation_not_accepted")}</h3>
{detail && <p>{detail}</p>}
</div>
</div>
</AuthFrame>
);
}
@@ -0,0 +1,130 @@
import { type FormEvent, useEffect, useState } from "react";
import { api, ApiError } from "../../api/client";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { AuthField } from "./AuthField";
import { AuthFrame } from "./AuthFrame";
import { passwordIsValid, passwordLabel, passwordScore } from "./password";
import { t } from "../../i18n";
import type { AuthenticatedUser } from "../../types";
// Ссылка-приглашение без входа (/join?token=…). Сервер говорит, куда зовут и
// есть ли учётная запись: существующая идёт на обычный вход, новая задаёт имя
// и пароль прямо здесь — теми же полями, что в мастере первого запуска, — и
// сразу оказывается в организации.
type Preview = { valid: boolean; email?: string; organizationName?: string; accountExists?: boolean };
type Status = "checking" | "invalid" | "register";
type FieldErrors = { fullName?: string; password?: string };
export function AuthJoinGuest({ token, onUseLogin, onRegistered }: {
token: string;
onUseLogin: () => void;
onRegistered: (user: AuthenticatedUser, organizationPublicId: string) => void;
}) {
const [status, setStatus] = useState<Status>("checking");
const [preview, setPreview] = useState<Preview | null>(null);
const [fullName, setFullName] = useState("");
const [password, setPassword] = useState("");
const [show, setShow] = useState(false);
const [errors, setErrors] = useState<FieldErrors>({});
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
let active = true;
api<Preview>(`/api/v1/auth/invitations/preview/?token=${encodeURIComponent(token)}`)
.then((payload) => {
if (!active) return;
if (!payload.valid) { setStatus("invalid"); return; }
if (payload.accountExists) { onUseLogin(); return; }
setPreview(payload);
setStatus("register");
})
.catch(() => { if (active) setStatus("invalid"); });
return () => { active = false; };
}, [token, onUseLogin]);
const score = passwordScore(password);
const valid = fullName.trim() !== "" && passwordIsValid(password, false);
async function submit(event: FormEvent) {
event.preventDefault();
if (!valid || submitting) return;
setSubmitting(true);
setError("");
setErrors({});
try {
const payload = await api<{ user: AuthenticatedUser; organizationPublicId: string }>("/api/v1/auth/invitations/register/", {
method: "POST",
body: JSON.stringify({ token, fullName, password }),
});
onRegistered(payload.user, payload.organizationPublicId);
} catch (requestError) {
if (requestError instanceof ApiError) {
const fieldErrors = (requestError.payload as { errors?: FieldErrors }).errors;
if (fieldErrors) setErrors(fieldErrors);
setError(requestError.message);
} else {
setError(t("auth.invitation_not_accepted"));
}
} finally {
setSubmitting(false);
}
}
if (status === "checking") {
return (
<AuthFrame title={t("auth.invitation_title")} subtitle={t("admin.one_moment")} logo="shield">
<div className="auth-card"><p className="auth-hint">{t("admin.checking_link")}</p></div>
</AuthFrame>
);
}
if (status === "invalid") {
return (
<AuthFrame title={t("auth.invitation_title")} subtitle={t("auth.invitation_not_accepted")} logo="shield">
<div className="auth-card">
<div className="auth-recovery-sent">
<div className="auth-recovery-sent-icon warning"><Icon name="warning" size={26} /></div>
<h3>{t("admin.link_does_not_work")}</h3>
<p>{t("auth.invitation_invalid")}</p>
</div>
</div>
</AuthFrame>
);
}
return (
<AuthFrame
title={t("auth.invitation_title")}
subtitle={t("auth.join_subtitle", { organization: preview?.organizationName ?? "", email: preview?.email ?? "" })}
logo="shield"
width={420}
>
<form className="auth-card" onSubmit={submit}>
{error && <div className="auth-error"><span className="auth-error-dot">!</span><span>{error}</span></div>}
<label className="field-label">{t("admin.name")}</label>
<AuthField icon="user" value={fullName} onChange={setFullName} placeholder={t("admin.elena_kuznetsova")} error={Boolean(errors.fullName)} />
<label className="field-label">{t("common.password")}</label>
<AuthField icon="lock" value={password} onChange={setPassword} placeholder={t("common.at_least_10_characters")} type={show ? "text" : "password"} error={Boolean(errors.password)}>
<button type="button" onClick={() => setShow((value) => !value)} aria-label={show ? t("admin.hide_password") : t("admin.show_password")}>
<Icon name={show ? "eyeOff" : "eye"} size={17} />
</button>
</AuthField>
<div className={`password-strength score-${score}`}>
<div>{[0, 1, 2, 3].map((item) => <span className={item < score ? "active" : ""} key={item} />)}</div>
<p>{passwordLabel(score)}</p>
</div>
<div className="password-requirements">
<strong>{t("common.password_requirements")}</strong>
<span className={password.length >= 10 ? "done" : ""}>{t("common.at_least_10_characters_long")}</span>
<span className={/\d/.test(password) ? "done" : ""}>{t("common.contains_digit")}</span>
<span className={/[A-Za-zА-Яа-я]/.test(password) && /[^A-Za-zА-Яа-я0-9]/.test(password) ? "done" : ""}>{t("common.letters_special_character")}</span>
</div>
<Button className="auth-submit" type="submit" variant="primary" disabled={!valid || submitting}>{t("auth.join_accept")}</Button>
</form>
</AuthFrame>
);
}
@@ -1,4 +1,6 @@
export { AuthChangePassword } from "./AuthChangePassword";
export { AuthJoin } from "./AuthJoin";
export { AuthJoinGuest } from "./AuthJoinGuest";
export { AuthLogin } from "./AuthLogin";
export { AuthPasswordRecovery } from "./AuthPasswordRecovery";
export { AuthResetPassword } from "./AuthResetPassword";
@@ -0,0 +1,64 @@
import { Dropdown } from "antd";
import { useState } from "react";
import type { EmployeeGroup, EmployeeInvitation } from "../../types";
import { Icon } from "../../shared/icons";
import { Avatar } from "../../shared/ui";
import { groupColorOf } from "../conversations/model";
import { formatDate, invitationStatusBadge, roleAccessLabel, roleBadge } from "./model";
import { t } from "../../i18n";
// Строка ожидающего приглашения (кадры E1/E2, статус «Приглашён»): та же
// разметка, что у сотрудника, — роль, должность и группы уже известны, они
// придут вместе с членством после принятия. Карточки у приглашения нет,
// поэтому строка не открывается; меню — отправить ещё раз или отозвать.
export function EmployeeInvitationRow({ invitation, groups, canManage, onResend, onRevoke }: {
invitation: EmployeeInvitation;
groups: EmployeeGroup[];
canManage: boolean;
onResend: (invitation: EmployeeInvitation) => void;
onRevoke: (invitation: EmployeeInvitation) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const role = roleBadge(invitation.role);
const status = invitationStatusBadge();
const act = (run: () => void) => { setMenuOpen(false); run(); };
const menuItems = [
{ key: "resend", disabled: !canManage, label: <button type="button" onClick={() => act(() => onResend(invitation))}><Icon name="mail" size={15} />{t("admin.resend_invitation")}</button> },
{ type: "divider" as const },
{ key: "revoke", disabled: !canManage, label: <button className="danger" type="button" onClick={() => act(() => onRevoke(invitation))}><Icon name="xCircle" size={15} />{t("admin.revoke_invitation")}</button> },
];
return (
<div className="employees-row is-invited">
<div className="employees-person">
<Avatar employee={{ ...invitation, isActive: true, isBlocked: false, mustChangePassword: true, totpRequired: false, totpEnabled: false }} />
<span>
<strong>{invitation.fullName || invitation.email}</strong>
<small>{invitation.email}</small>
</span>
</div>
<b className="employees-badge" style={{ background: role.bg, color: role.color }}>{role.text}</b>
<span className="employees-position">{invitation.positionTitle}</span>
<div className="employees-groups">
{invitation.groups.length === 0
? <span className="employees-nogroup">{t("common.no_group")}</span>
: invitation.groups.map((group) => (
<span className="employees-group" key={group.id}>
<i style={{ background: groupColorOf(group.id, groups.find((item) => item.id === group.id)?.color) }} />
{group.name}
</span>
))}
</div>
<span className={`employees-access ${invitation.role === "EMPLOYEE" ? "is-limited" : ""}`}>{roleAccessLabel(invitation)}</span>
<b className="employees-badge has-dot" style={{ background: status.bg, color: status.color }}><i />{status.text}</b>
<span className="employees-login">{t("admin.invitation_valid_until", { date: formatDate(invitation.expiresAt) })}</span>
<Dropdown menu={{ items: menuItems }} open={menuOpen} onOpenChange={setMenuOpen} trigger={["click"]} overlayClassName="app-dropdown is-employee-menu">
<button className={`employees-row-menu ${menuOpen ? "is-open" : ""}`} type="button" aria-label={t("common.actions_for", { name: invitation.fullName || invitation.email })} title={t("common.actions")} onClick={(event) => event.stopPropagation()}>
<Icon name="more" size={16} />
</button>
</Dropdown>
</div>
);
}
@@ -1,9 +1,10 @@
import { Dropdown } from "antd";
import type { Employee, EmployeeGroup } from "../../types";
import type { Employee, EmployeeGroup, EmployeeInvitation } from "../../types";
import { Icon } from "../../shared/icons";
import { Avatar, EmptyState } from "../../shared/ui";
import { groupColorOf } from "../conversations/model";
import { EmployeeInvitationRow } from "./EmployeeInvitationRow";
import { employeeAvatarColor, formatLastLogin, roleAccessLabel, roleBadge, statusBadge } from "./model";
import { t } from "../../i18n";
@@ -12,21 +13,30 @@ import { t } from "../../i18n";
export function EmployeeTable({
employees,
invitations = [],
canManageInvitations = false,
groups,
menuId,
onBlock,
onResetPassword,
onTerminateSessions,
onResendInvitation,
onRevokeInvitation,
openEmployee,
setMenuId,
total,
}: {
employees: Employee[];
// Ожидающие приглашения — строками со статусом «Приглашён» перед сотрудниками.
invitations?: EmployeeInvitation[];
canManageInvitations?: boolean;
groups: EmployeeGroup[];
menuId: number | null;
onBlock: (employee: Employee) => void;
onResetPassword: (employee: Employee) => void;
onTerminateSessions: (employee: Employee) => void;
onResendInvitation?: (invitation: EmployeeInvitation) => void;
onRevokeInvitation?: (invitation: EmployeeInvitation) => void;
openEmployee: (employee: Employee) => void;
setMenuId: (id: number | null) => void;
total: number;
@@ -43,6 +53,16 @@ export function EmployeeTable({
<span>{t("admin.last_sign")}</span>
<span />
</div>
{invitations.map((invitation) => (
<EmployeeInvitationRow
invitation={invitation}
groups={groups}
canManage={canManageInvitations}
onResend={(item) => onResendInvitation?.(item)}
onRevoke={(item) => onRevokeInvitation?.(item)}
key={`invitation-${invitation.id}`}
/>
))}
{employees.map((employee) => (
<EmployeeRow
employee={employee}
@@ -56,7 +76,7 @@ export function EmployeeTable({
key={employee.id}
/>
))}
{!employees.length && <EmptyState title={t("admin.no_operators_found")} />}
{!employees.length && !invitations.length && <EmptyState title={t("admin.no_operators_found")} />}
<div className="employees-foot">
<small>{t("common.shown_of", { shown: employees.length, total })}</small>
<small>{t("admin.group_decides_which_conversations_visible_2")}</small>
@@ -11,7 +11,7 @@ import { EmployeeCreateDrawer } from "./EmployeeCreateDrawer";
import { EmployeePasswordDialog } from "./EmployeePasswordDialog";
import { EmployeeTable } from "./EmployeeTable";
import { EmployeesFilters } from "./EmployeesFilters";
import { blockEmployee, fetchEmployees, resetEmployeePassword, terminateEmployeeSessions, type IssuedPassword } from "./api";
import { blockEmployee, fetchEmployees, resendInvitation, resetEmployeePassword, revokeInvitation, terminateEmployeeSessions, type IssuedPassword } from "./api";
import type { EmployeeRoleFilter } from "./model";
import { t } from "../../i18n";
@@ -82,6 +82,10 @@ export function EmployeesPage({ groups, openEmployee, user }: {
<EmployeeTable
employees={employees.items}
invitations={employees.page === 1 ? employees.payload?.invitations ?? [] : []}
canManageInvitations={canManage}
onResendInvitation={(invitation) => void run(() => resendInvitation(invitation.id))}
onRevokeInvitation={(invitation) => void run(() => revokeInvitation(invitation.id))}
groups={groups}
menuId={menuId}
onBlock={(employee) => void run(() => blockEmployee(employee.id, !employee.isBlocked))}
+22 -5
View File
@@ -1,6 +1,6 @@
import { api } from "../../api/client";
import type { PagedPayload } from "../../shared/usePagedResource";
import type { Employee, Role } from "../../types";
import type { Employee, EmployeeInvitation, Role } from "../../types";
import type { EmployeeRoleFilter } from "./model";
// Действия над сотрудником (дизайн-базлайн v2, кадры E2, E3, E5–E8).
@@ -26,11 +26,16 @@ export type EmployeeCreateInput = {
};
export async function createEmployee(input: EmployeeCreateInput): Promise<IssuedPassword | null> {
const payload = await api<{ employee: Employee; password: string | null }>("/api/v1/employees/operators/", {
// Если адрес уже принадлежит учётной записи из другой организации, сервер
// вместо сотрудника выписывает приглашение: employee и password пусты,
// человек появится в списке после того, как примет его.
const payload = await api<{ employee: Employee | null; password: string | null; invited?: boolean }>("/api/v1/employees/operators/", {
method: "POST",
body: JSON.stringify(input),
});
return payload.password ? { employee: payload.employee, password: payload.password, reason: "created" } : null;
return payload.password && payload.employee
? { employee: payload.employee, password: payload.password, reason: "created" }
: null;
}
export async function resetEmployeePassword(userId: number, mode: PasswordMode): Promise<IssuedPassword | null> {
@@ -56,15 +61,27 @@ export type EmployeeListQuery = {
query: string;
};
// Вместе со страницей сервер отдаёт ожидающие приглашения — их мало и они
// не листаются.
export type EmployeesPayload = PagedPayload<Employee> & { invitations?: EmployeeInvitation[] };
export function fetchEmployees(
{ role, groupId, query }: EmployeeListQuery,
page: number,
): Promise<PagedPayload<Employee>> {
): Promise<EmployeesPayload> {
const params = new URLSearchParams({ page: String(page) });
if (role !== "all") params.set("role", role);
if (groupId !== "all") params.set("group", String(groupId));
if (query.trim()) params.set("q", query.trim());
return api<PagedPayload<Employee>>(`/api/v1/employees/?${params.toString()}`);
return api<EmployeesPayload>(`/api/v1/employees/?${params.toString()}`);
}
export async function resendInvitation(invitationId: number): Promise<void> {
await api(`/api/v1/employees/invitations/${invitationId}/resend/`, { method: "POST" });
}
export async function revokeInvitation(invitationId: number): Promise<void> {
await api(`/api/v1/employees/invitations/${invitationId}/revoke/`, { method: "POST" });
}
/** Кандидаты на владение (кадр E9) — только действующие администраторы.
@@ -59,6 +59,10 @@ export function statusBadge(employee: Employee) {
return STATUS_BADGE[employeeStatusKey(employee)];
}
export function invitationStatusBadge() {
return STATUS_BADGE.invited;
}
export function employeeStatusKey(employee: Employee): EmployeeStatus {
if (employee.isBlocked) return "blocked";
if (employee.mustChangePassword) return "invited";
@@ -83,7 +87,7 @@ export function groupsLabel(employee: Employee) {
return employee.groups.map((group) => group.name).join(", ");
}
export function roleAccessLabel(employee: Employee) {
export function roleAccessLabel(employee: { role: Role }) {
if (employee.role === "OWNER" || employee.role === "ADMIN") return t("common.full_access");
return t("admin.chat_only");
}
@@ -29,7 +29,9 @@ type Flag = "voiceMessages" | "audioCalls" | "videoCalls";
const BASE = "/api/v1/company/administration/communication/";
const COLUMNS: Array<[Flag, string]> = [["voiceMessages", t("settings.voice_messages")], ["audioCalls", t("settings.audio_calls")], ["videoCalls", t("settings.video_calls")]];
export function CommunicationSettingsCard({ canManage }: { canManage: boolean }) {
// Relay — настройка установки, а не организации: менять его может только
// администратор установки, поэтому право приходит отдельным флагом.
export function CommunicationSettingsCard({ canManage, canManageRelay }: { canManage: boolean; canManageRelay: boolean }) {
const [items, setItems] = useState<EntryPoint[] | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [errorText, setErrorText] = useState("");
@@ -85,7 +87,7 @@ export function CommunicationSettingsCard({ canManage }: { canManage: boolean })
{items.length > 0 && (
<p className="settings-section-note">{t("settings.voice_messages_sent_by_customer")}</p>
)}
<CallsRelayCard canManage={canManage} />
<CallsRelayCard canManage={canManageRelay} />
</>
);
}
@@ -17,7 +17,7 @@ import { CommunicationSettingsCard } from "./CommunicationSettingsCard";
import { PlatformSettingsCard } from "./PlatformSettingsCard";
import { StorageSettingsCard } from "./StorageSettingsCard";
import { GroupsSettingsCard } from "./GroupsSettingsCard";
import { DEFAULT_SETTINGS_SECTION, SETTINGS_SECTIONS, type SettingsSectionKey } from "./sections";
import { DEFAULT_SETTINGS_SECTION, visibleSettingsSections, type SettingsSectionKey } from "./sections";
import { useIntegrations } from "./useIntegrations";
import { t } from "../../i18n";
@@ -54,7 +54,9 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
integrations: connections.length,
};
const active = section ?? (mobile ? null : DEFAULT_SETTINGS_SECTION);
const current = SETTINGS_SECTIONS.find((item) => item.key === active) ?? null;
// Разделы про саму установку видит только её администратор.
const sections = visibleSettingsSections(user);
const current = sections.find((item) => item.key === active) ?? null;
const links = (
<>
@@ -70,7 +72,7 @@ export function SettingsPage({ user, onUserUpdated, reload, groups = [], section
<nav className={`settings-subnav ${mobile && current ? "is-hidden" : ""}`}>
<div className="settings-subnav-head"><h2>{t("common.settings")}</h2></div>
<div className="settings-subnav-list">
{SETTINGS_SECTIONS.map((item) => (
{sections.map((item) => (
<button
className={`settings-subnav-item ${item.key === active ? "is-active" : ""}`}
key={item.key}
@@ -153,9 +155,9 @@ function SectionBody({ section, user, onUserUpdated, reload, groups, integration
}): ReactNode {
if (section === "organization") return <OrganizationSection user={user} onUserUpdated={onUserUpdated} />;
if (section === "groups") return <GroupsSettingsCard groups={groups} reload={reload} />;
if (section === "communication") return <CommunicationSettingsCard canManage={canManageSettings(user)} />;
if (section === "platform") return <PlatformSettingsCard canManage={canManageSettings(user)} />;
if (section === "storage") return <StorageSettingsCard canManage={canManageSettings(user)} />;
if (section === "communication") return <CommunicationSettingsCard canManage={canManageSettings(user)} canManageRelay={user.isInstanceAdmin} />;
if (section === "platform") return <PlatformSettingsCard canManage={user.isInstanceAdmin} />;
if (section === "storage") return <StorageSettingsCard canManage={user.isInstanceAdmin} />;
if (section === "demo") return <DemoDataCard reload={reload} />;
if (integrations.loading) return <LoadingState />;
if (integrations.failed) return <EmptyState title={t("settings.could_not_load_integrations")} />;
@@ -37,7 +37,7 @@ type Draft = {
s3AddressingStyle: string;
};
const BASE = "/api/v1/company/administration/storage/";
const BASE = "/api/v1/instance/storage/";
function draftOf(payload: StoragePayload): Draft {
return {
@@ -3,6 +3,8 @@ import { t } from "../../i18n";
// Настройки инсталляции (раздел «Платформа» и карточка relay в «Голосовых и
// звонках»). Один эндпоинт на всю установку: адрес, исходящая почта и TURN.
// Путь без организации: менять его вправе только администратор установки,
// читать — менеджер любой организации.
// PATCH частичный — блоки email и turn применяются, только если пришли в теле,
// поэтому каждая карточка сохраняет свою часть и не затирает соседние.
@@ -28,7 +30,7 @@ export type InstancePayload = {
turn: { urls: string[]; ttlSeconds: number; secretReady: boolean };
};
export const INSTANCE_BASE = "/api/v1/company/administration/instance/";
export const INSTANCE_BASE = "/api/v1/instance/settings/";
export function loadInstance(): Promise<InstancePayload> {
return api<{ instance: InstancePayload }>(INSTANCE_BASE).then((payload) => payload.instance);
@@ -1,4 +1,5 @@
import type { Icon } from "../../shared/icons";
import type { SessionUser } from "../../types";
import { t } from "../../i18n";
// Разделы «Настроек» (дизайн-базлайн v2, кадры N1–N7): субменю 240px и один
@@ -21,6 +22,8 @@ export type SettingsSection = {
icon: Parameters<typeof Icon>[0]["name"];
heading: string;
lead: string;
// Раздел про саму установку: виден только администратору установки.
instanceOnly?: boolean;
};
export const SETTINGS_SECTIONS: SettingsSection[] = [
@@ -67,6 +70,7 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
icon: "database",
heading: t("settings.file_storage"),
lead: t("settings.attachments_voice_messages_photos_logos"),
instanceOnly: true,
},
{
key: "platform",
@@ -74,6 +78,7 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
icon: "globe",
heading: t("settings.platform"),
lead: t("settings.properties_installation_itself_address_opened"),
instanceOnly: true,
},
{
key: "demo",
@@ -86,6 +91,10 @@ export const SETTINGS_SECTIONS: SettingsSection[] = [
export const DEFAULT_SETTINGS_SECTION: SettingsSectionKey = "organization";
export function visibleSettingsSections(user: SessionUser): SettingsSection[] {
return SETTINGS_SECTIONS.filter((section) => !section.instanceOnly || user.isInstanceAdmin);
}
export function settingsSectionKey(value: string | null | undefined): SettingsSectionKey | null {
const found = SETTINGS_SECTIONS.find((section) => section.key === value);
return found ? found.key : null;
+10
View File
@@ -124,6 +124,9 @@ export const en: Record<MessageKey, Message> = {
"admin.off_operator": "Off for this operator",
"admin.one_moment": "One moment.",
"admin.open_card": "Open the card",
"admin.resend_invitation": "Resend the invitation",
"admin.revoke_invitation": "Revoke the invitation",
"admin.invitation_valid_until": "Invited until {date}",
"admin.operator_blocked": "Operator blocked",
"admin.operator_blocked_cannot_sign_unblocking": "The operator is blocked and cannot sign in. Unblocking restores access according to their active assignments.",
"admin.operator_created": "Operator created",
@@ -1042,6 +1045,13 @@ export const en: Record<MessageKey, Message> = {
"profile.getting_started": "Getting started",
"profile.getting_started_progress": "{done} of {total}",
"profile.go_start_page": "Go to the start page",
"profile.switch_organization": "Switch organization",
"auth.invitation_title": "Organization invitation",
"auth.invitation_accepting": "Accepting the invitation…",
"auth.invitation_not_accepted": "The invitation was not accepted",
"auth.invitation_invalid": "The invitation is invalid or has expired",
"auth.join_subtitle": "You are invited to \u201c{organization}\u201d as {email}. Enter your name and set a password to sign in.",
"auth.join_accept": "Accept the invitation",
"profile.groups_list": "· groups: {groups}",
"profile.in_organization_since": "· in the organization since {date}",
"profile.interface_language": "Interface language",
+10
View File
@@ -125,6 +125,9 @@ export const ru = {
"admin.off_operator": "Отключена для этого сотрудника",
"admin.one_moment": "Подождите немного.",
"admin.open_card": "Открыть карточку",
"admin.resend_invitation": "Отправить приглашение ещё раз",
"admin.revoke_invitation": "Отозвать приглашение",
"admin.invitation_valid_until": "Приглашение до {date}",
"admin.operator_blocked": "Сотрудник заблокирован",
"admin.operator_blocked_cannot_sign_unblocking": "Сотрудник заблокирован и не может войти. Разблокировка восстановит доступ по активным назначениям.",
"admin.operator_created": "Сотрудник создан",
@@ -1043,6 +1046,13 @@ export const ru = {
"profile.getting_started": "Запуск",
"profile.getting_started_progress": "{done} из {total}",
"profile.go_start_page": "На главную",
"profile.switch_organization": "Переключить организацию",
"auth.invitation_title": "Приглашение в организацию",
"auth.invitation_accepting": "Принимаем приглашение…",
"auth.invitation_not_accepted": "Приглашение не принято",
"auth.invitation_invalid": "Приглашение недействительно или истекло",
"auth.join_subtitle": "Вас приглашают в «{organization}» как {email}. Укажите имя и задайте пароль для входа.",
"auth.join_accept": "Принять приглашение",
"profile.groups_list": "· группы: {groups}",
"profile.in_organization_since": "· в организации с {date}",
"profile.interface_language": "Язык интерфейса",
+3 -3
View File
@@ -13,8 +13,8 @@ import { DemoInstallBanner } from "../features/settings/DemoInstallBanner";
import { Sidebar } from "./Sidebar";
import { ShellRouteContent } from "./ShellRouteContent";
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
openKnowledgeEditorRoute: (knowledgeId: number | null) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
export function Shell({ route, setRoute, settingsSection, openSettingsRoute, selectedEmployeeId, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedChannelId, selectedSupportPortalId, portalSettingsSection, openChannelRoute, openSupportPortalRoute, openPortalSettingsRoute, openEmployeeRoute, openAgentRoute, openKnowledgeRoute, openKnowledgeEditorRoute, openConversationRoute, openClientRoute, user, data, reload, onUserUpdated, onLogout, onSwitchOrganization }: { route: RouteKey; setRoute: (route: RouteKey) => void; settingsSection: SettingsSectionKey | null; openSettingsRoute: (section: SettingsSectionKey | null) => void; selectedEmployeeId: number | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedChannelId: number | null; selectedSupportPortalId: number | null; portalSettingsSection: PortalSettingsSectionKey | null; openEmployeeRoute: (employeeId: number) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void;
openKnowledgeEditorRoute: (knowledgeId: number | null) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openChannelRoute: (channelId: number) => void; openSupportPortalRoute: (portalId: number) => void; openPortalSettingsRoute: (portalId: number, section?: PortalSettingsSectionKey) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void; onSwitchOrganization: (organizationPublicId: string) => void }) {
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [notifOpen, setNotifOpen] = useState(false);
@@ -112,7 +112,7 @@ export function Shell({ route, setRoute, settingsSection, openSettingsRoute, sel
const isPortals = route === "supportPortals" || route === "supportPortalDetail" || route === "supportPortalSettings";
return (
<div className={`hub-shell ${isDialogsWorkspace ? "is-chat-route" : ""} ${isSettings ? "is-settings-route" : ""} ${isProfile ? "is-profile-route" : ""} ${isContacts ? "is-contacts-route" : ""}`}>
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<Sidebar route={route} user={user} setRoute={setRoute} openSettings={openSettingsRoute} onLogout={onLogout} onSwitchOrganization={onSwitchOrganization} waitingCount={waitingCount} chatScope={chatScope.scope} setChatScope={chatScope.setScope} chatCounters={chatScope.counters} unreadCount={unreadCount} onOpenNotifications={() => { setNotifOpen(true); void loadNotifications(); }} expanded={sidebarExpanded} setExpanded={setSidebarExpanded} />
<div className="hub-main">
{manager && <DemoInstallBanner reload={reload} />}
{/* Верхней панели нет ни у одной роли (дизайн-базлайн v2): заголовок и
+24 -4
View File
@@ -47,6 +47,7 @@ export function Sidebar({
setRoute,
openSettings,
onLogout,
onSwitchOrganization,
waitingCount = 0,
chatScope,
setChatScope,
@@ -61,6 +62,7 @@ export function Sidebar({
setRoute: (route: RouteKey) => void;
openSettings: (section: SettingsSectionKey | null) => void;
onLogout: () => void;
onSwitchOrganization: (organizationPublicId: string) => void;
waitingCount?: number;
chatScope: DialogScope;
setChatScope: (scope: DialogScope) => void;
@@ -114,15 +116,33 @@ export function Sidebar({
? <img src={user.organizationLogoUrl} alt="" />
: <LogoIcon />}
</button>
{/* Переключатель организации (дизайн-базлайн v2, A1): пока у пользователя
одна организация — в списке она одна, отмечена. */}
{/* Переключатель организации (дизайн-базлайн v2, A1): все организации
человека, текущая отмечена; выбор другой открывает её стартовый экран. */}
<Dropdown
trigger={["click"]}
placement="bottomLeft"
overlayClassName="app-dropdown is-wide"
menu={{ items: [{ key: "current", label: <button type="button" className="is-checked"><Icon name="building" size={15} />{user.organizationName || "Chatballs"}</button> }] }}
menu={{
items: user.memberships.map((membership) => ({
key: membership.organizationPublicId,
label: (
<button
type="button"
className={membership.organizationPublicId === user.organizationPublicId ? "is-checked" : ""}
onClick={() => {
if (membership.organizationPublicId !== user.organizationPublicId) {
onSwitchOrganization(membership.organizationPublicId);
}
}}
>
<Icon name="building" size={15} />
{membership.organizationName || "Chatballs"}
</button>
),
})),
}}
>
<button className="hub-brand-switch" type="button">
<button className="hub-brand-switch" type="button" title={t("profile.switch_organization")}>
<span>{user.organizationName || "Chatballs"}</span>
<Icon name="chevron" size={14} />
</button>
+18
View File
@@ -42,6 +42,9 @@ export type AuthenticatedUser = {
// Когда последний раз принимался код аутентификатора (кадр P1).
totpLastUsedAt: string | null;
deliveryMode: DeliveryMode;
// Администратор установки: разделы «Платформа» и «Хранилище файлов» — его,
// это свойства инсталляции, а не организации.
isInstanceAdmin: boolean;
memberships: OrganizationMembership[];
uiTheme: "LIGHT" | "DARK" | "SYSTEM";
uiAccent: string;
@@ -100,6 +103,21 @@ export type Employee = {
auditEvents?: EmployeeAuditEvent[];
};
// Ожидающее приглашение существующей учётной записи (статус «Приглашён»):
// роль, должность и группы уже известны и придут вместе с членством.
export type EmployeeInvitation = {
id: number;
email: string;
fullName: string;
avatarUrl?: string | null;
role: Role;
positionTitle: string;
phone: string;
groups: EmployeeGroupRef[];
invitedAt: string;
expiresAt: string;
};
export type EmployeeAuditEvent = {
action: string;
result: string;
+3 -1
View File
@@ -27,7 +27,9 @@ export default defineConfig({
projects: [
{
name: "internal-ui",
use: { ...devices["Desktop Chrome"], baseURL: "http://localhost:5173" },
// Русская локаль браузера: интерфейс открывается на языке установки из
// моков без перезагрузки, которую иначе делает acceptServerLanguage.
use: { ...devices["Desktop Chrome"], baseURL: "http://localhost:5173", locale: "ru-RU" },
},
{
name: "web-chat-mobile",
+102 -2
View File
@@ -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);