mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01: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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser {
|
||||
totpEnabled: false,
|
||||
totpLastUsedAt: null,
|
||||
deliveryMode: "CLOUD",
|
||||
isInstanceAdmin: false,
|
||||
uiTheme: "SYSTEM",
|
||||
uiAccent: "",
|
||||
uiLanguage: "",
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Язык интерфейса",
|
||||
|
||||
@@ -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): заголовок и
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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