ФУНКЦИИ - NODEDC LAUNCHER: регистрация по инвайту
This commit is contained in:
parent
745f0f928d
commit
d15e90b9b5
|
|
@ -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) {
|
async function acceptInvite(token, identity) {
|
||||||
const data = readData();
|
const data = readData();
|
||||||
const invite = findInviteByToken(data, token);
|
const invite = findInviteByToken(data, token);
|
||||||
|
|
@ -997,8 +1012,10 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
deleteMembership,
|
deleteMembership,
|
||||||
deleteService,
|
deleteService,
|
||||||
acceptInvite,
|
acceptInvite,
|
||||||
|
commitInviteRegistration,
|
||||||
getInviteByToken,
|
getInviteByToken,
|
||||||
getSnapshot,
|
getSnapshot,
|
||||||
|
prepareInviteRegistration,
|
||||||
readData,
|
readData,
|
||||||
replaceData,
|
replaceData,
|
||||||
reorderServices,
|
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) {
|
function findById(items, id, label) {
|
||||||
const item = items.find((candidate) => candidate.id === id);
|
const item = items.find((candidate) => candidate.id === id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
app.post("/api/invites/:token/accept", requireSession, asyncRoute(async (req, res) => {
|
||||||
let result;
|
let result;
|
||||||
|
|
||||||
|
|
@ -783,6 +829,15 @@ function sanitizeNewPassword(value) {
|
||||||
return 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) {
|
function sendInviteApiError(res, error) {
|
||||||
const message = error instanceof Error ? error.message : "Инвайт недоступен";
|
const message = error instanceof Error ? error.message : "Инвайт недоступен";
|
||||||
const status =
|
const status =
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ import {
|
||||||
type LauncherAuthApp,
|
type LauncherAuthApp,
|
||||||
} from "../shared/api/authApi";
|
} from "../shared/api/authApi";
|
||||||
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
|
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 { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
|
||||||
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
||||||
import {
|
import {
|
||||||
|
|
@ -66,6 +66,8 @@ type InviteFlowState =
|
||||||
| { status: "ready"; payload: PublicInviteResponse }
|
| { status: "ready"; payload: PublicInviteResponse }
|
||||||
| { status: "accepting"; payload: PublicInviteResponse }
|
| { status: "accepting"; payload: PublicInviteResponse }
|
||||||
| { status: "accepted"; payload: PublicInviteResponse }
|
| { status: "accepted"; payload: PublicInviteResponse }
|
||||||
|
| { status: "registering"; payload: PublicInviteResponse }
|
||||||
|
| { status: "registered"; payload: PublicInviteResponse; loginUrl: string }
|
||||||
| { status: "error"; message: string; payload?: PublicInviteResponse };
|
| { status: "error"; message: string; payload?: PublicInviteResponse };
|
||||||
|
|
||||||
export function LauncherApp() {
|
export function LauncherApp() {
|
||||||
|
|
@ -202,12 +204,13 @@ export function LauncherApp() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authSession || authSession.authenticated) return;
|
if (!authSession || authSession.authenticated) return;
|
||||||
|
if (inviteToken) return;
|
||||||
|
|
||||||
redirectToLogin(authSession.loginUrl);
|
redirectToLogin(authSession.loginUrl);
|
||||||
}, [authSession]);
|
}, [authSession, inviteToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inviteToken || !authSession?.authenticated) return;
|
if (!inviteToken) return;
|
||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
setInviteFlow({ status: "loading" });
|
setInviteFlow({ status: "loading" });
|
||||||
|
|
@ -225,7 +228,7 @@ export function LauncherApp() {
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
}, [authSession, inviteToken]);
|
}, [inviteToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isRedirecting = false;
|
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<Invite>) {
|
function handleUpdateInvite(inviteId: string, patch: Partial<Invite>) {
|
||||||
applyControlPlaneMutation(updateAdminInvite(inviteId, patch));
|
applyControlPlaneMutation(updateAdminInvite(inviteId, patch));
|
||||||
}
|
}
|
||||||
|
|
@ -573,20 +595,15 @@ export function LauncherApp() {
|
||||||
setSelectedServiceId((current) => (current === serviceId ? undefined : current));
|
setSelectedServiceId((current) => (current === serviceId ? undefined : current));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authSession) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!authSession.authenticated) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inviteToken) {
|
if (inviteToken) {
|
||||||
return (
|
return (
|
||||||
<InviteFlowScreen
|
<InviteFlowScreen
|
||||||
state={inviteFlow ?? { status: "loading" }}
|
state={inviteFlow ?? { status: "loading" }}
|
||||||
currentEmail={authSession.user.email}
|
currentEmail={authSession?.authenticated ? authSession.user.email : null}
|
||||||
|
isAuthenticated={Boolean(authSession?.authenticated)}
|
||||||
onAccept={() => void handleAcceptInvite()}
|
onAccept={() => void handleAcceptInvite()}
|
||||||
|
onRegister={(command) => void handleRegisterInvite(command)}
|
||||||
|
onLogin={() => redirectToLogin(authSession?.authenticated ? "/auth/login?prompt=login" : authSession?.loginUrl)}
|
||||||
onSwitchAccount={() => {
|
onSwitchAccount={() => {
|
||||||
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
window.location.replace(`/auth/logout?global=1&returnTo=${encodeURIComponent(returnTo)}`);
|
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 = () => {
|
const handleLogout = () => {
|
||||||
window.location.replace(authSession.logoutUrl);
|
window.location.replace(authSession.logoutUrl);
|
||||||
};
|
};
|
||||||
|
|
@ -743,28 +768,49 @@ function resolveDefaultClientId(data: LauncherData, userId: string, requestedCli
|
||||||
function InviteFlowScreen({
|
function InviteFlowScreen({
|
||||||
state,
|
state,
|
||||||
currentEmail,
|
currentEmail,
|
||||||
|
isAuthenticated,
|
||||||
onAccept,
|
onAccept,
|
||||||
|
onRegister,
|
||||||
|
onLogin,
|
||||||
onSwitchAccount,
|
onSwitchAccount,
|
||||||
onGoHome,
|
onGoHome,
|
||||||
}: {
|
}: {
|
||||||
state: InviteFlowState;
|
state: InviteFlowState;
|
||||||
currentEmail: string;
|
currentEmail: string | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
onAccept: () => void;
|
onAccept: () => void;
|
||||||
|
onRegister: (command: RegisterInviteCommand) => void;
|
||||||
|
onLogin: () => void;
|
||||||
onSwitchAccount: () => void;
|
onSwitchAccount: () => void;
|
||||||
onGoHome: () => void;
|
onGoHome: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||||
const payload = "payload" in state ? state.payload : undefined;
|
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 inviteStatus = payload?.invite.status;
|
||||||
const isAccepting = state.status === "accepting";
|
const isAccepting = state.status === "accepting";
|
||||||
|
const isRegistering = state.status === "registering";
|
||||||
const canAccept = Boolean(
|
const canAccept = Boolean(
|
||||||
state.status === "ready" &&
|
state.status === "ready" &&
|
||||||
|
isAuthenticated &&
|
||||||
!emailMismatch &&
|
!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 canRegister = Boolean(
|
||||||
|
payload &&
|
||||||
|
state.status === "ready" &&
|
||||||
|
!isAuthenticated &&
|
||||||
|
!isTerminalInvite &&
|
||||||
|
password.length >= 8 &&
|
||||||
|
password === passwordConfirm
|
||||||
|
);
|
||||||
const details = payload
|
const details = payload
|
||||||
? [
|
? [
|
||||||
`Рабочая область: ${payload.client.name}`,
|
`Рабочая область: ${payload.client.name}`,
|
||||||
|
|
@ -772,7 +818,13 @@ function InviteFlowScreen({
|
||||||
`Почта инвайта: ${payload.invite.email}`,
|
`Почта инвайта: ${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 (
|
return (
|
||||||
<div className="launcher-app nodedc-auth-page">
|
<div className="launcher-app nodedc-auth-page">
|
||||||
|
|
@ -792,11 +844,56 @@ function InviteFlowScreen({
|
||||||
|
|
||||||
{statusMessage ? <p className="nodedc-auth-card__status">{statusMessage}</p> : null}
|
{statusMessage ? <p className="nodedc-auth-card__status">{statusMessage}</p> : null}
|
||||||
{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}
|
||||||
|
|
||||||
{emailMismatch ? (
|
{payload && !isAuthenticated && (state.status === "ready" || state.status === "registering") && !isTerminalInvite ? (
|
||||||
|
<form
|
||||||
|
className="nodedc-auth-card__form"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canRegister) return;
|
||||||
|
onRegister({ name: name.trim() || defaultInviteName, password });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="nodedc-auth-card__field">
|
||||||
|
<span>Имя</span>
|
||||||
|
<input value={name} placeholder="Ваше имя" autoComplete="name" onChange={(event) => setName(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="nodedc-auth-card__field">
|
||||||
|
<span>Пароль</span>
|
||||||
|
<input
|
||||||
|
value={password}
|
||||||
|
type="password"
|
||||||
|
placeholder="Минимум 8 символов"
|
||||||
|
autoComplete="new-password"
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="nodedc-auth-card__field">
|
||||||
|
<span>Повторите пароль</span>
|
||||||
|
<input
|
||||||
|
value={passwordConfirm}
|
||||||
|
type="password"
|
||||||
|
placeholder="Повторите пароль"
|
||||||
|
autoComplete="new-password"
|
||||||
|
onChange={(event) => setPasswordConfirm(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="button button--primary" type="submit" disabled={!canRegister || isRegistering}>
|
||||||
|
{isRegistering ? "Создаём аккаунт" : "Создать аккаунт"}
|
||||||
|
</button>
|
||||||
|
<button className="button button--secondary" type="button" onClick={onLogin}>
|
||||||
|
Уже есть аккаунт
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : emailMismatch ? (
|
||||||
<button className="button button--primary" type="button" onClick={onSwitchAccount}>
|
<button className="button button--primary" type="button" onClick={onSwitchAccount}>
|
||||||
Сменить аккаунт
|
Сменить аккаунт
|
||||||
</button>
|
</button>
|
||||||
|
) : state.status === "registered" ? (
|
||||||
|
<button className="button button--primary" type="button" onClick={() => redirectToLogin(state.loginUrl)}>
|
||||||
|
Войти в NODE.DC
|
||||||
|
</button>
|
||||||
) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (
|
) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (
|
||||||
<button className="button button--primary" type="button" onClick={onGoHome}>
|
<button className="button button--primary" type="button" onClick={onGoHome}>
|
||||||
Перейти в витрину
|
Перейти в витрину
|
||||||
|
|
@ -822,13 +919,16 @@ function NodeDcAuthBrandHeader() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveInviteStatusMessage(state: InviteFlowState, emailMismatch: boolean, inviteStatus?: Invite["status"]) {
|
function resolveInviteStatusMessage(state: InviteFlowState, emailMismatch: boolean, 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 === "registered") return "Аккаунт создан. Теперь войдите в NODE.DC.";
|
||||||
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 (emailMismatch) return "Нужно войти под почтой, на которую выписан инвайт.";
|
||||||
|
if (!isAuthenticated) return "Создайте аккаунт для почты, на которую выписан инвайт.";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,26 @@ export interface AcceptInviteResponse {
|
||||||
data: LauncherData;
|
data: LauncherData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegisterInviteCommand {
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterInviteResponse extends AcceptInviteResponse {
|
||||||
|
loginUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPublicInvite(token: string): Promise<PublicInviteResponse> {
|
export async function fetchPublicInvite(token: string): Promise<PublicInviteResponse> {
|
||||||
return requestJson<PublicInviteResponse>(`/api/invites/${encodeURIComponent(token)}`);
|
return requestJson<PublicInviteResponse>(`/api/invites/${encodeURIComponent(token)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function registerInvite(token: string, command: RegisterInviteCommand): Promise<RegisterInviteResponse> {
|
||||||
|
return requestJson<RegisterInviteResponse>(`/api/invites/${encodeURIComponent(token)}/register`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(command),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function acceptInvite(token: string): Promise<AcceptInviteResponse> {
|
export async function acceptInvite(token: string): Promise<AcceptInviteResponse> {
|
||||||
return requestJson<AcceptInviteResponse>(`/api/invites/${encodeURIComponent(token)}/accept`, {
|
return requestJson<AcceptInviteResponse>(`/api/invites/${encodeURIComponent(token)}/accept`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,47 @@ code {
|
||||||
line-height: 1rem;
|
line-height: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-auth-card__form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-auth-card__field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.42rem;
|
||||||
|
color: var(--nodedc-auth-text-placeholder);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-auth-card__field input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 1.15rem;
|
||||||
|
outline: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
|
||||||
|
rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--nodedc-auth-text-primary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 400;
|
||||||
|
caret-color: var(--nodedc-auth-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-auth-card__field input::placeholder {
|
||||||
|
color: var(--nodedc-auth-text-placeholder);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-auth-card__field input:focus {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.028) 0%, rgba(255, 255, 255, 0.012) 100%),
|
||||||
|
rgba(255, 255, 255, 0.03);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-auth-card .button {
|
.nodedc-auth-card .button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 3.25rem;
|
min-height: 3.25rem;
|
||||||
|
|
@ -237,6 +278,19 @@ code {
|
||||||
color: var(--nodedc-auth-on-primary);
|
color: var(--nodedc-auth-on-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-auth-card .button.button--secondary {
|
||||||
|
min-height: 2.25rem;
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nodedc-auth-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-auth-card .button.button--secondary:hover:not(:disabled),
|
||||||
|
.nodedc-auth-card .button.button--secondary:focus-visible:not(:disabled) {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nodedc-auth-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-auth-card .button:disabled {
|
.nodedc-auth-card .button:disabled {
|
||||||
opacity: 0.58;
|
opacity: 0.58;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue