diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 63f7e4e..1f996f7 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -450,6 +450,101 @@ export function createControlPlaneStore({ projectRoot }) { return { invite, data }; } + function getInviteByToken(token) { + const data = readData(); + const invite = findInviteByToken(data, token); + const client = findById(data.clients, invite.clientId, "client"); + + return { + invite: toPublicInvite(invite), + client: toPublicClient(client), + }; + } + + async function acceptInvite(token, identity) { + const data = readData(); + const invite = findInviteByToken(data, token); + const client = findById(data.clients, invite.clientId, "client"); + const email = requireInviteIdentityEmail(identity); + const now = isoNow(); + + if (invite.email.toLowerCase() !== email) { + throw new Error("Этот инвайт выписан на другую почту"); + } + + if (invite.status === "revoked") { + throw new Error("Инвайт отозван"); + } + + if (invite.status === "expired" || isInviteExpired(invite)) { + invite.status = "expired"; + invite.updatedAt = now; + await writeData(data); + throw new Error("Срок действия инвайта истёк"); + } + + const actor = resolveActor(data, identity); + let user = data.users.find((item) => item.email.toLowerCase() === email); + + if (user) { + user.authentikUserId = optionalString(identity?.sub, user.authentikUserId ?? null); + user.name = optionalString(identity?.name, user.name); + user.avatarUrl = nullableStringWithFallback(identity?.avatarUrl, user.avatarUrl ?? null); + user.globalStatus = "active"; + user.updatedAt = now; + } else { + user = { + id: uniqueId(data.users, "user", email), + authentikUserId: nullableString(identity?.sub), + name: optionalString(identity?.name, email.split("@")[0]), + email, + phone: null, + position: null, + notes: `Создан через инвайт клиента ${client.name}.`, + avatarUrl: nullableString(identity?.avatarUrl), + globalStatus: "active", + createdAt: now, + updatedAt: now, + }; + data.users.push(user); + } + + let membership = data.memberships.find((item) => item.clientId === invite.clientId && item.userId === user.id); + + if (membership) { + membership.role = invite.role; + membership.status = "active"; + membership.updatedAt = now; + } else { + membership = { + id: uniqueId(data.memberships, "mem", `${invite.clientId}-${email}`), + clientId: invite.clientId, + userId: user.id, + role: invite.role, + status: "active", + createdAt: now, + updatedAt: now, + }; + data.memberships.push(membership); + } + + invite.status = "accepted"; + invite.updatedAt = now; + + addAuditEvent(data, actor, { + action: "Инвайт принят", + objectType: "invite", + objectName: invite.email, + clientId: client.id, + result: "success", + details: `Role: ${invite.role}`, + }); + markPendingSync(data, user, "user", email); + + await writeData(data); + return { invite, client, user, membership, data }; + } + async function createGroup(payload, identity) { const data = readData(); const actor = resolveActor(data, identity); @@ -901,6 +996,8 @@ export function createControlPlaneStore({ projectRoot }) { deleteInvite, deleteMembership, deleteService, + acceptInvite, + getInviteByToken, getSnapshot, readData, replaceData, @@ -1028,6 +1125,50 @@ function assertGrantTargetExists(data, targetType, targetId) { } } +function findInviteByToken(data, token) { + const normalizedToken = requireString(token, "token"); + const invite = data.invites.find((candidate) => candidate.token === normalizedToken); + + if (!invite) { + throw new Error("Инвайт не найден"); + } + + return invite; +} + +function requireInviteIdentityEmail(identity) { + const email = typeof identity?.email === "string" ? identity.email.trim().toLowerCase() : ""; + + if (!email) { + throw new Error("Для принятия инвайта нужна подтверждённая почта"); + } + + return email; +} + +function isInviteExpired(invite) { + if (!invite.expiresAt) return false; + return Number.isFinite(Date.parse(invite.expiresAt)) && Date.parse(invite.expiresAt) <= Date.now(); +} + +function toPublicInvite(invite) { + return { + id: invite.id, + email: invite.email, + role: invite.role, + expiresAt: invite.expiresAt, + status: isInviteExpired(invite) && invite.status !== "accepted" && invite.status !== "revoked" ? "expired" : invite.status, + }; +} + +function toPublicClient(client) { + return { + id: client.id, + name: client.name, + status: client.status, + }; +} + function findById(items, id, label) { const item = items.find((candidate) => candidate.id === id); diff --git a/server/dev-server.mjs b/server/dev-server.mjs index e4ebe5c..d11fda3 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -353,6 +353,30 @@ app.post("/api/profile/password", requireSession, asyncRoute(async (req, res) => res.json({ data: result.data, ok: true }); })); +app.get("/api/invites/:token", (req, res) => { + try { + res.json(controlPlaneStore.getInviteByToken(req.params.token)); + } catch (error) { + sendInviteApiError(res, error); + } +}); + +app.post("/api/invites/:token/accept", requireSession, asyncRoute(async (req, res) => { + let result; + + try { + result = await controlPlaneStore.acceptInvite(req.params.token, req.nodedcSession.user); + } catch (error) { + sendInviteApiError(res, error); + return; + } + + const syncResult = await syncUsersToAuthentik(result.data, [result.user.id], req.nodedcSession.user); + + publishControlPlaneEvent("invite.accepted", syncResult.userIds); + res.json({ ...result, data: syncResult.data }); +})); + app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => { res.json(controlPlaneStore.getSnapshot(req.nodedcSession.user)); }); @@ -759,6 +783,20 @@ function sanitizeNewPassword(value) { return value; } +function sendInviteApiError(res, error) { + const message = error instanceof Error ? error.message : "Инвайт недоступен"; + const status = + message.includes("не найден") + ? 404 + : message.includes("другую почту") + ? 403 + : message.includes("истёк") || message.includes("отозван") + ? 410 + : 400; + + res.status(status).json({ error: message }); +} + function sanitizeSelfProfilePatch(payload) { return { name: payload?.name, diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index 6e54e36..79187d8 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -45,6 +45,7 @@ import { type LauncherAuthApp, } from "../shared/api/authApi"; import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi"; +import { acceptInvite, fetchPublicInvite, type PublicInviteResponse } from "../shared/api/inviteApi"; import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync"; import { loadPersistedLauncherData } from "../shared/api/storageApi"; import { @@ -60,7 +61,15 @@ import { TopBar } from "../widgets/top-bar/TopBar"; let lastAuthRedirect: { url: string; startedAt: number } | null = null; +type InviteFlowState = + | { status: "loading" } + | { status: "ready"; payload: PublicInviteResponse } + | { status: "accepting"; payload: PublicInviteResponse } + | { status: "accepted"; payload: PublicInviteResponse } + | { status: "error"; message: string; payload?: PublicInviteResponse }; + export function LauncherApp() { + const inviteToken = useMemo(() => parseInviteToken(window.location.pathname), []); const [data, setData] = useState(() => syncLauncherServiceLinks(initialLauncherData)); const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId); const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId); @@ -70,6 +79,7 @@ export function LauncherApp() { const [authApps, setAuthApps] = useState(null); const [profileSettingsOpen, setProfileSettingsOpen] = useState(false); const [pendingAccessAssignments, setPendingAccessAssignments] = useState>({}); + const [inviteFlow, setInviteFlow] = useState(() => (inviteToken ? { status: "loading" } : null)); const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]); const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0]; @@ -196,6 +206,27 @@ export function LauncherApp() { redirectToLogin(authSession.loginUrl); }, [authSession]); + useEffect(() => { + if (!inviteToken || !authSession?.authenticated) return; + + let isMounted = true; + setInviteFlow({ status: "loading" }); + + fetchPublicInvite(inviteToken) + .then((payload) => { + if (!isMounted) return; + setInviteFlow({ status: "ready", payload }); + }) + .catch((error: unknown) => { + if (!isMounted) return; + setInviteFlow({ status: "error", message: error instanceof Error ? error.message : "Инвайт не найден" }); + }); + + return () => { + isMounted = false; + }; + }, [authSession, inviteToken]); + useEffect(() => { let isRedirecting = false; @@ -411,6 +442,24 @@ export function LauncherApp() { applyControlPlaneMutation(createAdminInvite(invite)); } + async function handleAcceptInvite() { + if (!inviteToken || inviteFlow?.status !== "ready") return; + + setInviteFlow({ status: "accepting", payload: inviteFlow.payload }); + + try { + const result = await acceptInvite(inviteToken); + setData(syncLauncherServiceLinks(result.data)); + setInviteFlow({ status: "accepted", payload: inviteFlow.payload }); + } catch (error) { + setInviteFlow({ + status: "error", + payload: inviteFlow.payload, + message: error instanceof Error ? error.message : "Не удалось принять инвайт", + }); + } + } + function handleUpdateInvite(inviteId: string, patch: Partial) { applyControlPlaneMutation(updateAdminInvite(inviteId, patch)); } @@ -532,6 +581,20 @@ export function LauncherApp() { return null; } + if (inviteToken) { + return ( + void handleAcceptInvite()} + onGoHome={() => { + window.history.replaceState(null, "", "/"); + window.location.replace("/"); + }} + /> + ); + } + const handleLogout = () => { window.location.replace(authSession.logoutUrl); }; @@ -673,6 +736,91 @@ function resolveDefaultClientId(data: LauncherData, userId: string, requestedCli return availableClientIds[0] ?? data.clients[0]?.id ?? requestedClientId; } +function InviteFlowScreen({ + state, + currentEmail, + onAccept, + onGoHome, +}: { + state: InviteFlowState; + currentEmail: string; + onAccept: () => void; + onGoHome: () => void; +}) { + const payload = "payload" in state ? state.payload : undefined; + const title = + state.status === "accepted" + ? "Доступ подключён" + : state.status === "error" + ? "Инвайт недоступен" + : "Приглашение в NODE.DC"; + const description = payload + ? `Клиент: ${payload.client.name}. Роль: ${membershipRoleLabel(payload.invite.role)}.` + : "Проверяем приглашение и платформенную сессию."; + const emailMismatch = payload && payload.invite.email.toLowerCase() !== currentEmail.toLowerCase(); + const inviteStatus = payload?.invite.status; + const isAccepting = state.status === "accepting"; + const canAccept = Boolean( + state.status === "ready" && + !emailMismatch && + inviteStatus !== "accepted" && + inviteStatus !== "expired" && + inviteStatus !== "revoked" + ); + + return ( +
+
+
+ NODE.DC +

+ Invite flow +

+

{title}

+

{description}

+ {payload ? ( +

+ Инвайт: {payload.invite.email}. Текущий вход: {currentEmail}. +

+ ) : null} + {emailMismatch ? ( +

+ Нужно войти под почтой, на которую выписан инвайт. +

+ ) : null} + {state.status === "error" ?

{state.message}

: null} + {state.status === "accepted" ? ( + + ) : ( + + )} +
+
+
+ ); +} + function AuthStateScreen({ title, description, @@ -720,6 +868,19 @@ function AuthStateScreen({ ); } +function parseInviteToken(pathname: string) { + const match = /^\/invite\/([^/?#]+)\/?$/.exec(pathname); + return match?.[1] ? decodeURIComponent(match[1]) : null; +} + +function membershipRoleLabel(role: ClientMembership["role"]) { + return { + client_owner: "Владелец клиента", + client_admin: "Администратор клиента", + member: "Участник", + }[role]; +} + function buildLoginRedirectUrl(loginUrl?: string) { const url = new URL(loginUrl || "/auth/login", window.location.origin); diff --git a/src/shared/api/inviteApi.ts b/src/shared/api/inviteApi.ts new file mode 100644 index 0000000..fc4a862 --- /dev/null +++ b/src/shared/api/inviteApi.ts @@ -0,0 +1,55 @@ +import type { ClientMembership, LauncherUser } from "../../entities/user/types"; +import type { Client } from "../../entities/client/types"; +import type { Invite } from "../../entities/invite/types"; +import type { LauncherData } from "./mockApi"; + +export interface PublicInviteResponse { + invite: Pick; + client: Pick; +} + +export interface AcceptInviteResponse { + invite: Invite; + client: Client; + user: LauncherUser; + membership: ClientMembership; + data: LauncherData; +} + +export async function fetchPublicInvite(token: string): Promise { + return requestJson(`/api/invites/${encodeURIComponent(token)}`); +} + +export async function acceptInvite(token: string): Promise { + return requestJson(`/api/invites/${encodeURIComponent(token)}/accept`, { + method: "POST", + }); +} + +async function requestJson(url: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + + if (!headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(url, { + ...init, + headers, + }); + + if (!response.ok) { + throw new Error(await readErrorMessage(response)); + } + + return (await response.json()) as T; +} + +async function readErrorMessage(response: Response) { + try { + const payload = (await response.json()) as { error?: string }; + return payload.error ?? response.statusText; + } catch { + return response.statusText; + } +}