diff --git a/apps/internal-ui/src/App.tsx b/apps/internal-ui/src/App.tsx index a02f579..361dfa3 100644 --- a/apps/internal-ui/src/App.tsx +++ b/apps/internal-ui/src/App.tsx @@ -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(null); const [recovering, setRecovering] = useState(false); const [resetting, setResetting] = useState(() => window.location.pathname === "/reset-password"); + // Ссылка из письма-приглашения (/join?token=…): токен запоминается до входа + // и принимается, как только известна учётная запись. + const [joinToken, setJoinToken] = useState( + () => (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({ 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 ? ( { setTotpChallenge(null); landAfterAuth(nextUser); }} /> ) : !identity ? ( - needsSetup ? ( + joinToken && !joinNeedsLogin && !needsSetup ? ( + + ) : needsSetup ? ( { setNeedsSetup(false); landAfterAuth(nextUser); }} /> ) : recovering ? ( setRecovering(false)} /> ) : ( setRecovering(true)} /> ) + ) : joinToken ? ( + ) : !user ? ( ) : user.mustChangePassword ? ( @@ -178,7 +217,7 @@ export function App() { ) : !canAccess(user, navigation.route) ? ( navigate(defaultRoute(user), null, true)} /> ) : ( - 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} /> + 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} /> )} ); diff --git a/apps/internal-ui/src/auth/access.test.ts b/apps/internal-ui/src/auth/access.test.ts index e2cd1d3..d074fbf 100644 --- a/apps/internal-ui/src/auth/access.test.ts +++ b/apps/internal-ui/src/auth/access.test.ts @@ -12,6 +12,7 @@ function userWith(role: Role, capabilities: string[] = []): SessionUser { totpEnabled: false, totpLastUsedAt: null, deliveryMode: "CLOUD", + isInstanceAdmin: false, uiTheme: "SYSTEM", uiAccent: "", uiLanguage: "", diff --git a/apps/internal-ui/src/auth/session.ts b/apps/internal-ui/src/auth/session.ts index a6789b2..d4e4356 100644 --- a/apps/internal-ui/src/auth/session.ts +++ b/apps/internal-ui/src/auth/session.ts @@ -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. + } } diff --git a/apps/internal-ui/src/features/auth/AuthJoin.tsx b/apps/internal-ui/src/features/auth/AuthJoin.tsx new file mode 100644 index 0000000..284bfa8 --- /dev/null +++ b/apps/internal-ui/src/features/auth/AuthJoin.tsx @@ -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("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 ( + +

{t("auth.invitation_accepting")}

+
+ ); + } + + const backLink = ( + + ); + + return ( + +
+
+
+

{t("auth.invitation_not_accepted")}

+ {detail &&

{detail}

} +
+
+
+ ); +} diff --git a/apps/internal-ui/src/features/auth/AuthJoinGuest.tsx b/apps/internal-ui/src/features/auth/AuthJoinGuest.tsx new file mode 100644 index 0000000..7c111b8 --- /dev/null +++ b/apps/internal-ui/src/features/auth/AuthJoinGuest.tsx @@ -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("checking"); + const [preview, setPreview] = useState(null); + const [fullName, setFullName] = useState(""); + const [password, setPassword] = useState(""); + const [show, setShow] = useState(false); + const [errors, setErrors] = useState({}); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + let active = true; + api(`/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 ( + +

{t("admin.checking_link")}

+
+ ); + } + + if (status === "invalid") { + return ( + +
+
+
+

{t("admin.link_does_not_work")}

+

{t("auth.invitation_invalid")}

+
+
+
+ ); + } + + return ( + +
+ {error &&
!{error}
} + + + + + + +
+
{[0, 1, 2, 3].map((item) => )}
+

{passwordLabel(score)}

+
+
+ {t("common.password_requirements")} + = 10 ? "done" : ""}>{t("common.at_least_10_characters_long")} + {t("common.contains_digit")} + {t("common.letters_special_character")} +
+ + +
+ ); +} diff --git a/apps/internal-ui/src/features/auth/AuthScreens.tsx b/apps/internal-ui/src/features/auth/AuthScreens.tsx index bd262d0..f15ab34 100644 --- a/apps/internal-ui/src/features/auth/AuthScreens.tsx +++ b/apps/internal-ui/src/features/auth/AuthScreens.tsx @@ -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"; diff --git a/apps/internal-ui/src/features/employees/EmployeeInvitationRow.tsx b/apps/internal-ui/src/features/employees/EmployeeInvitationRow.tsx new file mode 100644 index 0000000..902ef12 --- /dev/null +++ b/apps/internal-ui/src/features/employees/EmployeeInvitationRow.tsx @@ -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: }, + { type: "divider" as const }, + { key: "revoke", disabled: !canManage, label: }, + ]; + + return ( +
+
+ + + {invitation.fullName || invitation.email} + {invitation.email} + +
+ {role.text} + {invitation.positionTitle} +
+ {invitation.groups.length === 0 + ? {t("common.no_group")} + : invitation.groups.map((group) => ( + + item.id === group.id)?.color) }} /> + {group.name} + + ))} +
+ {roleAccessLabel(invitation)} + {status.text} + {t("admin.invitation_valid_until", { date: formatDate(invitation.expiresAt) })} + + + +
+ ); +} diff --git a/apps/internal-ui/src/features/employees/EmployeeTable.tsx b/apps/internal-ui/src/features/employees/EmployeeTable.tsx index c5271c1..3e4e91f 100644 --- a/apps/internal-ui/src/features/employees/EmployeeTable.tsx +++ b/apps/internal-ui/src/features/employees/EmployeeTable.tsx @@ -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({ {t("admin.last_sign")} + {invitations.map((invitation) => ( + onResendInvitation?.(item)} + onRevoke={(item) => onRevokeInvitation?.(item)} + key={`invitation-${invitation.id}`} + /> + ))} {employees.map((employee) => ( ))} - {!employees.length && } + {!employees.length && !invitations.length && }
{t("common.shown_of", { shown: employees.length, total })} {t("admin.group_decides_which_conversations_visible_2")} diff --git a/apps/internal-ui/src/features/employees/EmployeesListPage.tsx b/apps/internal-ui/src/features/employees/EmployeesListPage.tsx index 02bc618..d91149f 100644 --- a/apps/internal-ui/src/features/employees/EmployeesListPage.tsx +++ b/apps/internal-ui/src/features/employees/EmployeesListPage.tsx @@ -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 }: { 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))} diff --git a/apps/internal-ui/src/features/employees/api.ts b/apps/internal-ui/src/features/employees/api.ts index a6922ea..d80f16c 100644 --- a/apps/internal-ui/src/features/employees/api.ts +++ b/apps/internal-ui/src/features/employees/api.ts @@ -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 { - 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 { @@ -56,15 +61,27 @@ export type EmployeeListQuery = { query: string; }; +// Вместе со страницей сервер отдаёт ожидающие приглашения — их мало и они +// не листаются. +export type EmployeesPayload = PagedPayload & { invitations?: EmployeeInvitation[] }; + export function fetchEmployees( { role, groupId, query }: EmployeeListQuery, page: number, -): Promise> { +): Promise { 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>(`/api/v1/employees/?${params.toString()}`); + return api(`/api/v1/employees/?${params.toString()}`); +} + +export async function resendInvitation(invitationId: number): Promise { + await api(`/api/v1/employees/invitations/${invitationId}/resend/`, { method: "POST" }); +} + +export async function revokeInvitation(invitationId: number): Promise { + await api(`/api/v1/employees/invitations/${invitationId}/revoke/`, { method: "POST" }); } /** Кандидаты на владение (кадр E9) — только действующие администраторы. diff --git a/apps/internal-ui/src/features/employees/model.ts b/apps/internal-ui/src/features/employees/model.ts index 5b84ac9..c2fb368 100644 --- a/apps/internal-ui/src/features/employees/model.ts +++ b/apps/internal-ui/src/features/employees/model.ts @@ -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"); } diff --git a/apps/internal-ui/src/features/settings/CommunicationSettingsCard.tsx b/apps/internal-ui/src/features/settings/CommunicationSettingsCard.tsx index 996d6ea..8c87efb 100644 --- a/apps/internal-ui/src/features/settings/CommunicationSettingsCard.tsx +++ b/apps/internal-ui/src/features/settings/CommunicationSettingsCard.tsx @@ -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(null); const [busyId, setBusyId] = useState(null); const [errorText, setErrorText] = useState(""); @@ -85,7 +87,7 @@ export function CommunicationSettingsCard({ canManage }: { canManage: boolean }) {items.length > 0 && (

{t("settings.voice_messages_sent_by_customer")}

)} - + ); } diff --git a/apps/internal-ui/src/features/settings/SettingsPage.tsx b/apps/internal-ui/src/features/settings/SettingsPage.tsx index 46b48bb..e3c8768 100644 --- a/apps/internal-ui/src/features/settings/SettingsPage.tsx +++ b/apps/internal-ui/src/features/settings/SettingsPage.tsx @@ -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