diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index 1f996f7..a3cab86 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -461,6 +461,21 @@ export function createControlPlaneStore({ projectRoot }) { }; } + function prepareInviteRegistration(token, payload = {}) { + const data = readData(); + const result = applyInviteRegistration(data, token, payload, { commit: false }); + + return result; + } + + async function commitInviteRegistration(token, payload = {}, provisioning) { + const data = readData(); + const result = applyInviteRegistration(data, token, payload, { commit: true, provisioning }); + + await writeData(data); + return result; + } + async function acceptInvite(token, identity) { const data = readData(); const invite = findInviteByToken(data, token); @@ -997,8 +1012,10 @@ export function createControlPlaneStore({ projectRoot }) { deleteMembership, deleteService, acceptInvite, + commitInviteRegistration, getInviteByToken, getSnapshot, + prepareInviteRegistration, readData, replaceData, reorderServices, @@ -1169,6 +1186,94 @@ function toPublicClient(client) { }; } +function applyInviteRegistration(data, token, payload, { commit, provisioning = null } = {}) { + const invite = findInviteByToken(data, token); + const client = findById(data.clients, invite.clientId, "client"); + const now = isoNow(); + const email = invite.email.toLowerCase(); + const name = optionalString(payload?.name, email.split("@")[0]); + + validateInviteCanBeRegistered(invite); + + let user = data.users.find((item) => item.email.toLowerCase() === email); + + if (user?.authentikUserId && !provisioning) { + throw new Error("Аккаунт уже существует. Войдите под почтой инвайта."); + } + + if (user) { + user.name = name; + user.globalStatus = "active"; + user.authentikUserId = provisioning?.authentikUserId ?? user.authentikUserId ?? null; + user.updatedAt = now; + } else { + user = { + id: uniqueId(data.users, "user", email), + authentikUserId: provisioning?.authentikUserId ?? null, + name, + email, + phone: null, + position: null, + notes: `Создан через публичную регистрацию по инвайту клиента ${client.name}.`, + avatarUrl: null, + 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; + markPendingSync(data, user, "user", email); + + if (commit) { + addAuditEvent(data, { id: user.id, name: user.name, email: user.email, source: "invite" }, { + action: "Регистрация по инвайту", + objectType: "invite", + objectName: invite.email, + clientId: client.id, + result: "success", + details: `Role: ${invite.role}`, + }); + } + + return { invite, client, user, membership, data }; +} + +function validateInviteCanBeRegistered(invite) { + if (invite.status === "accepted") { + throw new Error("Инвайт уже принят"); + } + + if (invite.status === "revoked") { + throw new Error("Инвайт отозван"); + } + + if (invite.status === "expired" || isInviteExpired(invite)) { + throw new Error("Срок действия инвайта истёк"); + } +} + 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 d11fda3..0282ac2 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -361,6 +361,52 @@ app.get("/api/invites/:token", (req, res) => { } }); +app.post("/api/invites/:token/register", asyncRoute(async (req, res) => { + let payload; + + try { + payload = sanitizeInviteRegistrationPayload(req.body); + } catch (error) { + sendInviteApiError(res, error); + return; + } + + if (!authentikSyncClient.isConfigured()) { + res.status(503).json({ error: "Регистрация временно недоступна: Authentik API не настроен" }); + return; + } + + let draft; + + try { + draft = controlPlaneStore.prepareInviteRegistration(req.params.token, payload); + } catch (error) { + sendInviteApiError(res, error); + return; + } + + const provisionedUser = await authentikSyncClient.provisionUser({ + data: draft.data, + userId: draft.user.id, + password: payload.password, + }); + const result = await controlPlaneStore.commitInviteRegistration(req.params.token, payload, provisionedUser); + const storeResult = await controlPlaneStore.markUserAuthentikProvisioned(result.user.id, provisionedUser, { + sub: provisionedUser.authentikUserId, + email: result.user.email, + name: result.user.name, + }); + + publishControlPlaneEvent("invite.registered", [result.user.id]); + res.json({ + ...result, + user: storeResult.user, + data: storeResult.data, + provisioning: toProvisioningResponse(provisionedUser), + loginUrl: buildLoginRedirectUrl("/", { forceLogin: true }), + }); +})); + app.post("/api/invites/:token/accept", requireSession, asyncRoute(async (req, res) => { let result; @@ -783,6 +829,15 @@ function sanitizeNewPassword(value) { return value; } +function sanitizeInviteRegistrationPayload(payload) { + const name = typeof payload?.name === "string" ? payload.name.trim() : ""; + + return { + name: name.slice(0, 120), + password: sanitizeNewPassword(payload?.password), + }; +} + function sendInviteApiError(res, error) { const message = error instanceof Error ? error.message : "Инвайт недоступен"; const status = diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index f3a667c..ddf55dc 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -45,7 +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 { acceptInvite, fetchPublicInvite, registerInvite, type PublicInviteResponse, type RegisterInviteCommand } from "../shared/api/inviteApi"; import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync"; import { loadPersistedLauncherData } from "../shared/api/storageApi"; import { @@ -66,6 +66,8 @@ type InviteFlowState = | { status: "ready"; payload: PublicInviteResponse } | { status: "accepting"; payload: PublicInviteResponse } | { status: "accepted"; payload: PublicInviteResponse } + | { status: "registering"; payload: PublicInviteResponse } + | { status: "registered"; payload: PublicInviteResponse; loginUrl: string } | { status: "error"; message: string; payload?: PublicInviteResponse }; export function LauncherApp() { @@ -202,12 +204,13 @@ export function LauncherApp() { useEffect(() => { if (!authSession || authSession.authenticated) return; + if (inviteToken) return; redirectToLogin(authSession.loginUrl); - }, [authSession]); + }, [authSession, inviteToken]); useEffect(() => { - if (!inviteToken || !authSession?.authenticated) return; + if (!inviteToken) return; let isMounted = true; setInviteFlow({ status: "loading" }); @@ -225,7 +228,7 @@ export function LauncherApp() { return () => { isMounted = false; }; - }, [authSession, inviteToken]); + }, [inviteToken]); useEffect(() => { let isRedirecting = false; @@ -460,6 +463,25 @@ export function LauncherApp() { } } + async function handleRegisterInvite(command: RegisterInviteCommand) { + if (!inviteToken || inviteFlow?.status !== "ready") return; + + setInviteFlow({ status: "registering", payload: inviteFlow.payload }); + + try { + const result = await registerInvite(inviteToken, command); + + setData(syncLauncherServiceLinks(result.data)); + setInviteFlow({ status: "registered", payload: inviteFlow.payload, loginUrl: result.loginUrl }); + } catch (error) { + setInviteFlow({ + status: "error", + message: error instanceof Error ? error.message : "Не удалось зарегистрироваться по инвайту", + payload: inviteFlow.payload, + }); + } + } + function handleUpdateInvite(inviteId: string, patch: Partial) { applyControlPlaneMutation(updateAdminInvite(inviteId, patch)); } @@ -573,20 +595,15 @@ export function LauncherApp() { setSelectedServiceId((current) => (current === serviceId ? undefined : current)); } - if (!authSession) { - return null; - } - - if (!authSession.authenticated) { - return null; - } - if (inviteToken) { return ( void handleAcceptInvite()} + onRegister={(command) => void handleRegisterInvite(command)} + onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl)} onSwitchAccount={() => { const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`; window.location.replace(`/auth/logout?global=1&returnTo=${encodeURIComponent(returnTo)}`); @@ -599,6 +616,14 @@ export function LauncherApp() { ); } + if (!authSession) { + return null; + } + + if (!authSession.authenticated) { + return null; + } + const handleLogout = () => { window.location.replace(authSession.logoutUrl); }; @@ -743,28 +768,49 @@ function resolveDefaultClientId(data: LauncherData, userId: string, requestedCli function InviteFlowScreen({ state, currentEmail, + isAuthenticated, onAccept, + onRegister, + onLogin, onSwitchAccount, onGoHome, }: { state: InviteFlowState; - currentEmail: string; + currentEmail: string | null; + isAuthenticated: boolean; onAccept: () => void; + onRegister: (command: RegisterInviteCommand) => void; + onLogin: () => void; onSwitchAccount: () => void; onGoHome: () => void; }) { + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); const payload = "payload" in state ? state.payload : undefined; - const emailMismatch = payload && payload.invite.email.toLowerCase() !== currentEmail.toLowerCase(); + const defaultInviteName = payload ? payload.invite.email.split("@")[0] : ""; + const emailMismatch = payload && currentEmail ? payload.invite.email.toLowerCase() !== currentEmail.toLowerCase() : false; const inviteStatus = payload?.invite.status; const isAccepting = state.status === "accepting"; + const isRegistering = state.status === "registering"; const canAccept = Boolean( state.status === "ready" && + isAuthenticated && !emailMismatch && inviteStatus !== "accepted" && inviteStatus !== "expired" && inviteStatus !== "revoked" ); const isTerminalInvite = inviteStatus === "accepted" || inviteStatus === "expired" || inviteStatus === "revoked"; + const passwordMismatch = Boolean(passwordConfirm && password !== passwordConfirm); + const canRegister = Boolean( + payload && + state.status === "ready" && + !isAuthenticated && + !isTerminalInvite && + password.length >= 8 && + password === passwordConfirm + ); const details = payload ? [ `Рабочая область: ${payload.client.name}`, @@ -772,7 +818,13 @@ function InviteFlowScreen({ `Почта инвайта: ${payload.invite.email}`, ] : ["Проверяем приглашение и платформенную сессию"]; - const statusMessage = resolveInviteStatusMessage(state, Boolean(emailMismatch), inviteStatus); + const statusMessage = resolveInviteStatusMessage(state, Boolean(emailMismatch), isAuthenticated, inviteStatus); + + useEffect(() => { + if (!defaultInviteName || name) return; + + setName(defaultInviteName); + }, [defaultInviteName, name]); return (
@@ -792,11 +844,56 @@ function InviteFlowScreen({ {statusMessage ?

{statusMessage}

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

{state.message}

: null} + {passwordMismatch ?

Пароли не совпадают.

: null} - {emailMismatch ? ( + {payload && !isAuthenticated && (state.status === "ready" || state.status === "registering") && !isTerminalInvite ? ( +
{ + event.preventDefault(); + if (!canRegister) return; + onRegister({ name: name.trim() || defaultInviteName, password }); + }} + > + + + + + +
+ ) : emailMismatch ? ( + ) : state.status === "registered" ? ( + ) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (