БЕЗОПАСНОСТЬ - NODEDC LAUNCHER: скрыть email публичного инвайта
This commit is contained in:
parent
d15e90b9b5
commit
90249208b8
|
|
@ -1171,7 +1171,6 @@ function isInviteExpired(invite) {
|
||||||
function toPublicInvite(invite) {
|
function toPublicInvite(invite) {
|
||||||
return {
|
return {
|
||||||
id: invite.id,
|
id: invite.id,
|
||||||
email: invite.email,
|
|
||||||
role: invite.role,
|
role: invite.role,
|
||||||
expiresAt: invite.expiresAt,
|
expiresAt: invite.expiresAt,
|
||||||
status: isInviteExpired(invite) && invite.status !== "accepted" && invite.status !== "revoked" ? "expired" : invite.status,
|
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 invite = findInviteByToken(data, token);
|
||||||
const client = findById(data.clients, invite.clientId, "client");
|
const client = findById(data.clients, invite.clientId, "client");
|
||||||
const now = isoNow();
|
const now = isoNow();
|
||||||
|
const requestedEmail = normalizeInviteRegistrationEmail(payload?.email);
|
||||||
const email = invite.email.toLowerCase();
|
const email = invite.email.toLowerCase();
|
||||||
const name = optionalString(payload?.name, email.split("@")[0]);
|
const name = optionalString(payload?.name, requestedEmail.split("@")[0]);
|
||||||
|
|
||||||
validateInviteCanBeRegistered(invite);
|
validateInviteCanBeRegistered(invite);
|
||||||
|
|
||||||
|
if (!requestedEmail || requestedEmail !== email) {
|
||||||
|
throw new Error("Для этой почты нет активного инвайта");
|
||||||
|
}
|
||||||
|
|
||||||
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
||||||
|
|
||||||
if (user?.authentikUserId && !provisioning) {
|
if (user?.authentikUserId && !provisioning) {
|
||||||
|
|
@ -1260,6 +1264,10 @@ function applyInviteRegistration(data, token, payload, { commit, provisioning =
|
||||||
return { invite, client, user, membership, data };
|
return { invite, client, user, membership, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeInviteRegistrationEmail(value) {
|
||||||
|
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
function validateInviteCanBeRegistered(invite) {
|
function validateInviteCanBeRegistered(invite) {
|
||||||
if (invite.status === "accepted") {
|
if (invite.status === "accepted") {
|
||||||
throw new Error("Инвайт уже принят");
|
throw new Error("Инвайт уже принят");
|
||||||
|
|
|
||||||
|
|
@ -830,20 +830,34 @@ function sanitizeNewPassword(value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeInviteRegistrationPayload(payload) {
|
function sanitizeInviteRegistrationPayload(payload) {
|
||||||
|
const email = typeof payload?.email === "string" ? payload.email.trim().toLowerCase() : "";
|
||||||
const name = typeof payload?.name === "string" ? payload.name.trim() : "";
|
const name = typeof payload?.name === "string" ? payload.name.trim() : "";
|
||||||
|
|
||||||
|
if (!isValidInviteRegistrationEmail(email)) {
|
||||||
|
throw new Error("Введите почту, на которую выписан инвайт");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
throw new Error("Введите имя");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
email,
|
||||||
name: name.slice(0, 120),
|
name: name.slice(0, 120),
|
||||||
password: sanitizeNewPassword(payload?.password),
|
password: sanitizeNewPassword(payload?.password),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidInviteRegistrationEmail(email) {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
function sendInviteApiError(res, error) {
|
function sendInviteApiError(res, error) {
|
||||||
const message = error instanceof Error ? error.message : "Инвайт недоступен";
|
const message = error instanceof Error ? error.message : "Инвайт недоступен";
|
||||||
const status =
|
const status =
|
||||||
message.includes("не найден")
|
message.includes("не найден")
|
||||||
? 404
|
? 404
|
||||||
: message.includes("другую почту")
|
: message.includes("другую почту") || message.includes("нет активного инвайта")
|
||||||
? 403
|
? 403
|
||||||
: message.includes("истёк") || message.includes("отозван")
|
: message.includes("истёк") || message.includes("отозван")
|
||||||
? 410
|
? 410
|
||||||
|
|
|
||||||
|
|
@ -464,20 +464,22 @@ export function LauncherApp() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRegisterInvite(command: RegisterInviteCommand) {
|
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 {
|
try {
|
||||||
const result = await registerInvite(inviteToken, command);
|
const result = await registerInvite(inviteToken, command);
|
||||||
|
|
||||||
setData(syncLauncherServiceLinks(result.data));
|
setData(syncLauncherServiceLinks(result.data));
|
||||||
setInviteFlow({ status: "registered", payload: inviteFlow.payload, loginUrl: result.loginUrl });
|
setInviteFlow({ status: "registered", payload, loginUrl: result.loginUrl });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setInviteFlow({
|
setInviteFlow({
|
||||||
status: "error",
|
status: "error",
|
||||||
message: error instanceof Error ? error.message : "Не удалось зарегистрироваться по инвайту",
|
message: error instanceof Error ? error.message : "Не удалось зарегистрироваться по инвайту",
|
||||||
payload: inviteFlow.payload,
|
payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -599,7 +601,6 @@ export function LauncherApp() {
|
||||||
return (
|
return (
|
||||||
<InviteFlowScreen
|
<InviteFlowScreen
|
||||||
state={inviteFlow ?? { status: "loading" }}
|
state={inviteFlow ?? { status: "loading" }}
|
||||||
currentEmail={authSession?.authenticated ? authSession.user.email : null}
|
|
||||||
isAuthenticated={Boolean(authSession?.authenticated)}
|
isAuthenticated={Boolean(authSession?.authenticated)}
|
||||||
onAccept={() => void handleAcceptInvite()}
|
onAccept={() => void handleAcceptInvite()}
|
||||||
onRegister={(command) => void handleRegisterInvite(command)}
|
onRegister={(command) => void handleRegisterInvite(command)}
|
||||||
|
|
@ -767,7 +768,6 @@ function resolveDefaultClientId(data: LauncherData, userId: string, requestedCli
|
||||||
|
|
||||||
function InviteFlowScreen({
|
function InviteFlowScreen({
|
||||||
state,
|
state,
|
||||||
currentEmail,
|
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
onAccept,
|
onAccept,
|
||||||
onRegister,
|
onRegister,
|
||||||
|
|
@ -776,7 +776,6 @@ function InviteFlowScreen({
|
||||||
onGoHome,
|
onGoHome,
|
||||||
}: {
|
}: {
|
||||||
state: InviteFlowState;
|
state: InviteFlowState;
|
||||||
currentEmail: string | null;
|
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
onAccept: () => void;
|
onAccept: () => void;
|
||||||
onRegister: (command: RegisterInviteCommand) => void;
|
onRegister: (command: RegisterInviteCommand) => void;
|
||||||
|
|
@ -784,30 +783,36 @@ function InviteFlowScreen({
|
||||||
onSwitchAccount: () => void;
|
onSwitchAccount: () => void;
|
||||||
onGoHome: () => void;
|
onGoHome: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||||
const payload = "payload" in state ? state.payload : undefined;
|
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 inviteStatus = payload?.invite.status;
|
||||||
const isAccepting = state.status === "accepting";
|
const isAccepting = state.status === "accepting";
|
||||||
const isRegistering = state.status === "registering";
|
const isRegistering = state.status === "registering";
|
||||||
|
const requiresAccountSwitch = state.status === "error" && state.message.includes("другую почту");
|
||||||
const canAccept = Boolean(
|
const canAccept = Boolean(
|
||||||
state.status === "ready" &&
|
state.status === "ready" &&
|
||||||
isAuthenticated &&
|
isAuthenticated &&
|
||||||
!emailMismatch &&
|
|
||||||
inviteStatus !== "accepted" &&
|
inviteStatus !== "accepted" &&
|
||||||
inviteStatus !== "expired" &&
|
inviteStatus !== "expired" &&
|
||||||
inviteStatus !== "revoked"
|
inviteStatus !== "revoked"
|
||||||
);
|
);
|
||||||
const isTerminalInvite = inviteStatus === "accepted" || inviteStatus === "expired" || inviteStatus === "revoked";
|
const isTerminalInvite = inviteStatus === "accepted" || inviteStatus === "expired" || inviteStatus === "revoked";
|
||||||
const passwordMismatch = Boolean(passwordConfirm && password !== passwordConfirm);
|
const canShowRegistrationForm = Boolean(
|
||||||
const canRegister = Boolean(
|
|
||||||
payload &&
|
payload &&
|
||||||
state.status === "ready" &&
|
|
||||||
!isAuthenticated &&
|
!isAuthenticated &&
|
||||||
!isTerminalInvite &&
|
!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.length >= 8 &&
|
||||||
password === passwordConfirm
|
password === passwordConfirm
|
||||||
);
|
);
|
||||||
|
|
@ -815,16 +820,9 @@ function InviteFlowScreen({
|
||||||
? [
|
? [
|
||||||
`Рабочая область: ${payload.client.name}`,
|
`Рабочая область: ${payload.client.name}`,
|
||||||
`Роль: ${membershipRoleLabel(payload.invite.role)}`,
|
`Роль: ${membershipRoleLabel(payload.invite.role)}`,
|
||||||
`Почта инвайта: ${payload.invite.email}`,
|
|
||||||
]
|
]
|
||||||
: ["Проверяем приглашение и платформенную сессию"];
|
: ["Проверяем приглашение и платформенную сессию"];
|
||||||
const statusMessage = resolveInviteStatusMessage(state, Boolean(emailMismatch), isAuthenticated, inviteStatus);
|
const statusMessage = resolveInviteStatusMessage(state, isAuthenticated, inviteStatus);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!defaultInviteName || name) return;
|
|
||||||
|
|
||||||
setName(defaultInviteName);
|
|
||||||
}, [defaultInviteName, name]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="launcher-app nodedc-auth-page">
|
<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}
|
{state.status === "error" ? <p className="nodedc-auth-card__status">{state.message}</p> : null}
|
||||||
{passwordMismatch ? <p className="nodedc-auth-card__status">Пароли не совпадают.</p> : null}
|
{passwordMismatch ? <p className="nodedc-auth-card__status">Пароли не совпадают.</p> : null}
|
||||||
|
|
||||||
{payload && !isAuthenticated && (state.status === "ready" || state.status === "registering") && !isTerminalInvite ? (
|
{canShowRegistrationForm ? (
|
||||||
<form
|
<form
|
||||||
className="nodedc-auth-card__form"
|
className="nodedc-auth-card__form"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!canRegister) return;
|
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">
|
<label className="nodedc-auth-card__field">
|
||||||
<span>Имя</span>
|
<span>Имя</span>
|
||||||
<input value={name} placeholder="Ваше имя" autoComplete="name" onChange={(event) => setName(event.target.value)} />
|
<input value={name} placeholder="Ваше имя" autoComplete="name" onChange={(event) => setName(event.target.value)} />
|
||||||
|
|
@ -886,7 +894,7 @@ function InviteFlowScreen({
|
||||||
Уже есть аккаунт
|
Уже есть аккаунт
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : emailMismatch ? (
|
) : requiresAccountSwitch ? (
|
||||||
<button className="button button--primary" type="button" onClick={onSwitchAccount}>
|
<button className="button button--primary" type="button" onClick={onSwitchAccount}>
|
||||||
Сменить аккаунт
|
Сменить аккаунт
|
||||||
</button>
|
</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 === "loading") return "Проверяем приглашение.";
|
||||||
if (state.status === "accepting") return "Подключаем доступ к рабочей области.";
|
if (state.status === "accepting") return "Подключаем доступ к рабочей области.";
|
||||||
if (state.status === "registering") return "Создаём аккаунт и подключаем доступ.";
|
if (state.status === "registering") return "Создаём аккаунт и подключаем доступ.";
|
||||||
|
|
@ -927,8 +935,7 @@ function resolveInviteStatusMessage(state: InviteFlowState, emailMismatch: boole
|
||||||
if (state.status === "accepted" || inviteStatus === "accepted") return "Доступ уже подключён.";
|
if (state.status === "accepted" || inviteStatus === "accepted") return "Доступ уже подключён.";
|
||||||
if (inviteStatus === "expired") return "Срок действия приглашения истёк.";
|
if (inviteStatus === "expired") return "Срок действия приглашения истёк.";
|
||||||
if (inviteStatus === "revoked") return "Приглашение отозвано.";
|
if (inviteStatus === "revoked") return "Приглашение отозвано.";
|
||||||
if (emailMismatch) return "Нужно войти под почтой, на которую выписан инвайт.";
|
if (!isAuthenticated) return "Введите почту, имя и пароль для регистрации по приглашению.";
|
||||||
if (!isAuthenticated) return "Создайте аккаунт для почты, на которую выписан инвайт.";
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { Invite } from "../../entities/invite/types";
|
||||||
import type { LauncherData } from "./mockApi";
|
import type { LauncherData } from "./mockApi";
|
||||||
|
|
||||||
export interface PublicInviteResponse {
|
export interface PublicInviteResponse {
|
||||||
invite: Pick<Invite, "id" | "email" | "role" | "expiresAt" | "status">;
|
invite: Pick<Invite, "id" | "role" | "expiresAt" | "status">;
|
||||||
client: Pick<Client, "id" | "name" | "status">;
|
client: Pick<Client, "id" | "name" | "status">;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,6 +17,7 @@ export interface AcceptInviteResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterInviteCommand {
|
export interface RegisterInviteCommand {
|
||||||
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue