diff --git a/server/control-plane-store.mjs b/server/control-plane-store.mjs index a3cab86..b8c5041 100644 --- a/server/control-plane-store.mjs +++ b/server/control-plane-store.mjs @@ -1171,7 +1171,6 @@ function isInviteExpired(invite) { 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, @@ -1190,11 +1189,16 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning = const invite = findInviteByToken(data, token); const client = findById(data.clients, invite.clientId, "client"); const now = isoNow(); + const requestedEmail = normalizeInviteRegistrationEmail(payload?.email); const email = invite.email.toLowerCase(); - const name = optionalString(payload?.name, email.split("@")[0]); + const name = optionalString(payload?.name, requestedEmail.split("@")[0]); validateInviteCanBeRegistered(invite); + if (!requestedEmail || requestedEmail !== email) { + throw new Error("Для этой почты нет активного инвайта"); + } + let user = data.users.find((item) => item.email.toLowerCase() === email); if (user?.authentikUserId && !provisioning) { @@ -1260,6 +1264,10 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning = return { invite, client, user, membership, data }; } +function normalizeInviteRegistrationEmail(value) { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + function validateInviteCanBeRegistered(invite) { if (invite.status === "accepted") { throw new Error("Инвайт уже принят"); diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 0282ac2..791db53 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -830,20 +830,34 @@ function sanitizeNewPassword(value) { } function sanitizeInviteRegistrationPayload(payload) { + const email = typeof payload?.email === "string" ? payload.email.trim().toLowerCase() : ""; const name = typeof payload?.name === "string" ? payload.name.trim() : ""; + if (!isValidInviteRegistrationEmail(email)) { + throw new Error("Введите почту, на которую выписан инвайт"); + } + + if (!name) { + throw new Error("Введите имя"); + } + return { + email, name: name.slice(0, 120), password: sanitizeNewPassword(payload?.password), }; } +function isValidInviteRegistrationEmail(email) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} + function sendInviteApiError(res, error) { const message = error instanceof Error ? error.message : "Инвайт недоступен"; const status = message.includes("не найден") ? 404 - : message.includes("другую почту") + : message.includes("другую почту") || message.includes("нет активного инвайта") ? 403 : message.includes("истёк") || message.includes("отозван") ? 410 diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index ddf55dc..87772c5 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -464,20 +464,22 @@ export function LauncherApp() { } async function handleRegisterInvite(command: RegisterInviteCommand) { - if (!inviteToken || inviteFlow?.status !== "ready") return; + if (!inviteToken || !inviteFlow || !("payload" in inviteFlow) || !inviteFlow.payload || (inviteFlow.status !== "ready" && inviteFlow.status !== "error")) return; - setInviteFlow({ status: "registering", payload: inviteFlow.payload }); + const payload = inviteFlow.payload; + + setInviteFlow({ status: "registering", payload }); try { const result = await registerInvite(inviteToken, command); setData(syncLauncherServiceLinks(result.data)); - setInviteFlow({ status: "registered", payload: inviteFlow.payload, loginUrl: result.loginUrl }); + setInviteFlow({ status: "registered", payload, loginUrl: result.loginUrl }); } catch (error) { setInviteFlow({ status: "error", message: error instanceof Error ? error.message : "Не удалось зарегистрироваться по инвайту", - payload: inviteFlow.payload, + payload, }); } } @@ -599,7 +601,6 @@ export function LauncherApp() { return ( void handleAcceptInvite()} onRegister={(command) => void handleRegisterInvite(command)} @@ -767,7 +768,6 @@ function resolveDefaultClientId(data: LauncherData, userId: string, requestedCli function InviteFlowScreen({ state, - currentEmail, isAuthenticated, onAccept, onRegister, @@ -776,7 +776,6 @@ function InviteFlowScreen({ onGoHome, }: { state: InviteFlowState; - currentEmail: string | null; isAuthenticated: boolean; onAccept: () => void; onRegister: (command: RegisterInviteCommand) => void; @@ -784,30 +783,36 @@ function InviteFlowScreen({ onSwitchAccount: () => void; onGoHome: () => void; }) { + const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [password, setPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); const payload = "payload" in state ? state.payload : undefined; - 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 requiresAccountSwitch = state.status === "error" && state.message.includes("другую почту"); 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( + const canShowRegistrationForm = Boolean( payload && - state.status === "ready" && !isAuthenticated && !isTerminalInvite && + (state.status === "ready" || state.status === "registering" || state.status === "error") + ); + const passwordMismatch = Boolean(passwordConfirm && password !== passwordConfirm); + const normalizedEmail = email.trim(); + const canRegister = Boolean( + canShowRegistrationForm && + state.status !== "registering" && + normalizedEmail.includes("@") && + name.trim() && password.length >= 8 && password === passwordConfirm ); @@ -815,16 +820,9 @@ function InviteFlowScreen({ ? [ `Рабочая область: ${payload.client.name}`, `Роль: ${membershipRoleLabel(payload.invite.role)}`, - `Почта инвайта: ${payload.invite.email}`, ] : ["Проверяем приглашение и платформенную сессию"]; - const statusMessage = resolveInviteStatusMessage(state, Boolean(emailMismatch), isAuthenticated, inviteStatus); - - useEffect(() => { - if (!defaultInviteName || name) return; - - setName(defaultInviteName); - }, [defaultInviteName, name]); + const statusMessage = resolveInviteStatusMessage(state, isAuthenticated, inviteStatus); return (
@@ -846,15 +844,25 @@ function InviteFlowScreen({ {state.status === "error" ?

{state.message}

: null} {passwordMismatch ?

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

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