БЕЗОПАСНОСТЬ - NODEDC LAUNCHER: скрыть email публичного инвайта

This commit is contained in:
DCCONSTRUCTIONS 2026-05-05 17:50:32 +03:00
parent d15e90b9b5
commit 90249208b8
4 changed files with 61 additions and 31 deletions

View File

@ -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("Инвайт уже принят");

View File

@ -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

View File

@ -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 (
<InviteFlowScreen
state={inviteFlow ?? { status: "loading" }}
currentEmail={authSession?.authenticated ? authSession.user.email : null}
isAuthenticated={Boolean(authSession?.authenticated)}
onAccept={() => 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 (
<div className="launcher-app nodedc-auth-page">
@ -846,15 +844,25 @@ function InviteFlowScreen({
{state.status === "error" ? <p className="nodedc-auth-card__status">{state.message}</p> : null}
{passwordMismatch ? <p className="nodedc-auth-card__status">Пароли не совпадают.</p> : null}
{payload && !isAuthenticated && (state.status === "ready" || state.status === "registering") && !isTerminalInvite ? (
{canShowRegistrationForm ? (
<form
className="nodedc-auth-card__form"
onSubmit={(event) => {
event.preventDefault();
if (!canRegister) return;
onRegister({ name: name.trim() || defaultInviteName, password });
onRegister({ email: normalizedEmail, name: name.trim(), password });
}}
>
<label className="nodedc-auth-card__field">
<span>Эл. почта</span>
<input
value={email}
type="email"
placeholder="email@company.ru"
autoComplete="email"
onChange={(event) => setEmail(event.target.value)}
/>
</label>
<label className="nodedc-auth-card__field">
<span>Имя</span>
<input value={name} placeholder="Ваше имя" autoComplete="name" onChange={(event) => setName(event.target.value)} />
@ -886,7 +894,7 @@ function InviteFlowScreen({
Уже есть аккаунт
</button>
</form>
) : emailMismatch ? (
) : requiresAccountSwitch ? (
<button className="button button--primary" type="button" onClick={onSwitchAccount}>
Сменить аккаунт
</button>
@ -919,7 +927,7 @@ function NodeDcAuthBrandHeader() {
);
}
function resolveInviteStatusMessage(state: InviteFlowState, emailMismatch: boolean, isAuthenticated: boolean, inviteStatus?: Invite["status"]) {
function resolveInviteStatusMessage(state: InviteFlowState, isAuthenticated: boolean, inviteStatus?: Invite["status"]) {
if (state.status === "loading") return "Проверяем приглашение.";
if (state.status === "accepting") return "Подключаем доступ к рабочей области.";
if (state.status === "registering") return "Создаём аккаунт и подключаем доступ.";
@ -927,8 +935,7 @@ function resolveInviteStatusMessage(state: InviteFlowState, emailMismatch: boole
if (state.status === "accepted" || inviteStatus === "accepted") return "Доступ уже подключён.";
if (inviteStatus === "expired") return "Срок действия приглашения истёк.";
if (inviteStatus === "revoked") return "Приглашение отозвано.";
if (emailMismatch) return "Нужно войти под почтой, на которую выписан инвайт.";
if (!isAuthenticated) return "Создайте аккаунт для почты, на которую выписан инвайт.";
if (!isAuthenticated) return "Введите почту, имя и пароль для регистрации по приглашению.";
return null;
}

View File

@ -4,7 +4,7 @@ import type { Invite } from "../../entities/invite/types";
import type { LauncherData } from "./mockApi";
export interface PublicInviteResponse {
invite: Pick<Invite, "id" | "email" | "role" | "expiresAt" | "status">;
invite: Pick<Invite, "id" | "role" | "expiresAt" | "status">;
client: Pick<Client, "id" | "name" | "status">;
}
@ -17,6 +17,7 @@ export interface AcceptInviteResponse {
}
export interface RegisterInviteCommand {
email: string;
name: string;
password: string;
}