ФУНКЦИИ - NODEDC LAUNCHER: регистрация по инвайту

This commit is contained in:
DCCONSTRUCTIONS
2026-05-05 16:58:37 +03:00
parent 745f0f928d
commit d15e90b9b5
5 changed files with 348 additions and 18 deletions
+118 -18
View File
@@ -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<Invite>) {
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 (
<InviteFlowScreen
state={inviteFlow ?? { status: "loading" }}
currentEmail={authSession.user.email}
currentEmail={authSession?.authenticated ? authSession.user.email : null}
isAuthenticated={Boolean(authSession?.authenticated)}
onAccept={() => 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 (
<div className="launcher-app nodedc-auth-page">
@@ -792,11 +844,56 @@ function InviteFlowScreen({
{statusMessage ? <p className="nodedc-auth-card__status">{statusMessage}</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>
) : state.status === "registered" ? (
<button className="button button--primary" type="button" onClick={() => redirectToLogin(state.loginUrl)}>
Войти в NODE.DC
</button>
) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (
<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 === "accepting") return "Подключаем доступ к рабочей области.";
if (state.status === "registering") return "Создаём аккаунт и подключаем доступ.";
if (state.status === "registered") return "Аккаунт создан. Теперь войдите в NODE.DC.";
if (state.status === "accepted" || inviteStatus === "accepted") return "Доступ уже подключён.";
if (inviteStatus === "expired") return "Срок действия приглашения истёк.";
if (inviteStatus === "revoked") return "Приглашение отозвано.";
if (emailMismatch) return "Нужно войти под почтой, на которую выписан инвайт.";
if (!isAuthenticated) return "Создайте аккаунт для почты, на которую выписан инвайт.";
return null;
}
+16
View File
@@ -16,10 +16,26 @@ export interface AcceptInviteResponse {
data: LauncherData;
}
export interface RegisterInviteCommand {
name: string;
password: string;
}
export interface RegisterInviteResponse extends AcceptInviteResponse {
loginUrl: string;
}
export async function fetchPublicInvite(token: string): Promise<PublicInviteResponse> {
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> {
return requestJson<AcceptInviteResponse>(`/api/invites/${encodeURIComponent(token)}/accept`, {
method: "POST",
+54
View File
@@ -219,6 +219,47 @@ code {
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 {
width: 100%;
min-height: 3.25rem;
@@ -237,6 +278,19 @@ code {
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 {
opacity: 0.58;
}