UI - NODEDC LAUNCHER: invite screen по auth design-code

This commit is contained in:
DCCONSTRUCTIONS 2026-05-05 16:26:19 +03:00
parent 821a4009f0
commit 745f0f928d
2 changed files with 174 additions and 47 deletions

View File

@ -587,6 +587,10 @@ export function LauncherApp() {
state={inviteFlow ?? { status: "loading" }} state={inviteFlow ?? { status: "loading" }}
currentEmail={authSession.user.email} currentEmail={authSession.user.email}
onAccept={() => void handleAcceptInvite()} onAccept={() => void handleAcceptInvite()}
onSwitchAccount={() => {
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.location.replace(`/auth/logout?global=1&returnTo=${encodeURIComponent(returnTo)}`);
}}
onGoHome={() => { onGoHome={() => {
window.history.replaceState(null, "", "/"); window.history.replaceState(null, "", "/");
window.location.replace("/"); window.location.replace("/");
@ -740,23 +744,16 @@ function InviteFlowScreen({
state, state,
currentEmail, currentEmail,
onAccept, onAccept,
onSwitchAccount,
onGoHome, onGoHome,
}: { }: {
state: InviteFlowState; state: InviteFlowState;
currentEmail: string; currentEmail: string;
onAccept: () => void; onAccept: () => void;
onSwitchAccount: () => void;
onGoHome: () => void; onGoHome: () => void;
}) { }) {
const payload = "payload" in state ? state.payload : undefined; const payload = "payload" in state ? state.payload : undefined;
const title =
state.status === "accepted"
? "Доступ подключён"
: state.status === "error"
? "Инвайт недоступен"
: "Приглашение в NODE.DC";
const description = payload
? `Клиент: ${payload.client.name}. Роль: ${membershipRoleLabel(payload.invite.role)}.`
: "Проверяем приглашение и платформенную сессию.";
const emailMismatch = payload && payload.invite.email.toLowerCase() !== currentEmail.toLowerCase(); const emailMismatch = payload && payload.invite.email.toLowerCase() !== currentEmail.toLowerCase();
const inviteStatus = payload?.invite.status; const inviteStatus = payload?.invite.status;
const isAccepting = state.status === "accepting"; const isAccepting = state.status === "accepting";
@ -767,52 +764,46 @@ function InviteFlowScreen({
inviteStatus !== "expired" && inviteStatus !== "expired" &&
inviteStatus !== "revoked" inviteStatus !== "revoked"
); );
const isTerminalInvite = inviteStatus === "accepted" || inviteStatus === "expired" || inviteStatus === "revoked";
const details = payload
? [
`Рабочая область: ${payload.client.name}`,
`Роль: ${membershipRoleLabel(payload.invite.role)}`,
`Почта инвайта: ${payload.invite.email}`,
]
: ["Проверяем приглашение и платформенную сессию"];
const statusMessage = resolveInviteStatusMessage(state, Boolean(emailMismatch), inviteStatus);
return ( return (
<div className="launcher-app"> <div className="launcher-app nodedc-auth-page">
<main <NodeDcAuthBrandHeader />
style={{ <main className="nodedc-auth-page__main">
display: "grid", <section className="nodedc-auth-card nodedc-invite-card" aria-live="polite">
minHeight: "100vh", <div className="nodedc-auth-card__copy">
placeItems: "center", <h1>Работайте во всех измерениях.</h1>
padding: "2rem", <p>Приглашение в NODE.DC.</p>
}} </div>
>
<section <div className="nodedc-invite-card__details">
style={{ {details.map((detail) => (
display: "grid", <span key={detail}>{detail}</span>
width: "min(34rem, 100%)", ))}
gap: "1rem", </div>
padding: "2rem",
borderRadius: "1.75rem", {statusMessage ? <p className="nodedc-auth-card__status">{statusMessage}</p> : null}
background: "linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.035))", {state.status === "error" ? <p className="nodedc-auth-card__status">{state.message}</p> : null}
textAlign: "center",
}}
>
<img src="/nodedc-logo.svg" alt="NODE.DC" style={{ justifySelf: "center", width: "11rem" }} />
<p className="eyebrow" style={{ margin: 0 }}>
Invite flow
</p>
<h1 style={{ margin: 0 }}>{title}</h1>
<p style={{ margin: 0, color: "var(--text-secondary)", lineHeight: 1.5 }}>{description}</p>
{payload ? (
<p style={{ margin: 0, color: "var(--text-secondary)", lineHeight: 1.5 }}>
Инвайт: <strong>{payload.invite.email}</strong>. Текущий вход: <strong>{currentEmail}</strong>.
</p>
) : null}
{emailMismatch ? ( {emailMismatch ? (
<p style={{ margin: 0, color: "var(--warning)", lineHeight: 1.45 }}> <button className="button button--primary" type="button" onClick={onSwitchAccount}>
Нужно войти под почтой, на которую выписан инвайт. Сменить аккаунт
</p> </button>
) : null} ) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (
{state.status === "error" ? <p style={{ margin: 0, color: "var(--warning)", lineHeight: 1.45 }}>{state.message}</p> : null}
{state.status === "accepted" ? (
<button className="button button--primary" type="button" onClick={onGoHome}> <button className="button button--primary" type="button" onClick={onGoHome}>
Перейти в витрину Перейти в витрину
</button> </button>
) : ( ) : (
<button className="button button--primary" type="button" disabled={!canAccept || isAccepting} onClick={onAccept}> <button className="button button--primary" type="button" disabled={!canAccept || isAccepting} onClick={onAccept}>
{isAccepting ? "Подключаем доступ" : "Принять приглашение"} {state.status === "loading" ? "Проверяем" : isAccepting ? "Подключаем доступ" : "Принять приглашение"}
</button> </button>
)} )}
</section> </section>
@ -821,6 +812,26 @@ function InviteFlowScreen({
); );
} }
function NodeDcAuthBrandHeader() {
return (
<header className="nodedc-auth-brand-shell">
<a href="/" className="nodedc-expanded-brand-link" aria-label="NODE.DC">
<img src="/nodedc-logo.svg" alt="NODE DC" className="nodedc-expanded-brand-logo" />
</a>
</header>
);
}
function resolveInviteStatusMessage(state: InviteFlowState, emailMismatch: boolean, inviteStatus?: Invite["status"]) {
if (state.status === "loading") return "Проверяем приглашение.";
if (state.status === "accepting") return "Подключаем доступ к рабочей области.";
if (state.status === "accepted" || inviteStatus === "accepted") return "Доступ уже подключён.";
if (inviteStatus === "expired") return "Срок действия приглашения истёк.";
if (inviteStatus === "revoked") return "Приглашение отозвано.";
if (emailMismatch) return "Нужно войти под почтой, на которую выписан инвайт.";
return null;
}
function AuthStateScreen({ function AuthStateScreen({
title, title,
description, description,

View File

@ -125,6 +125,122 @@ code {
background: #050506; background: #050506;
} }
.nodedc-auth-page {
--nodedc-auth-bg: #0e0f10;
--nodedc-auth-card-bg: rgba(9, 9, 12, 0.84);
--nodedc-auth-primary: rgb(195, 255, 102);
--nodedc-auth-primary-hover: rgb(218, 255, 139);
--nodedc-auth-on-primary: rgb(11, 17, 23);
--nodedc-auth-text-primary: #e4e6e7;
--nodedc-auth-text-secondary: #cacdce;
--nodedc-auth-text-placeholder: #959a9d;
background: var(--nodedc-auth-bg);
color: var(--nodedc-auth-text-primary);
font-synthesis: none;
-webkit-font-smoothing: antialiased;
text-rendering: geometricPrecision;
}
.nodedc-auth-brand-shell {
position: absolute;
z-index: 80;
top: 0;
left: 50%;
width: min(100%, calc(100vw - var(--nodedc-shell-frame-gutter-x)));
padding: var(--nodedc-shell-padding-top) var(--nodedc-shell-padding-x) var(--nodedc-shell-padding-bottom);
transform: translateX(-50%);
}
.nodedc-auth-page__main {
display: grid;
min-height: 100vh;
place-items: center;
padding: 0 1rem;
}
.nodedc-auth-card {
display: grid;
width: min(100%, 28rem);
gap: 1.05rem;
overflow: hidden;
padding: 2.2rem;
border: 0;
border-radius: 1.9rem;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.048) 0%, rgba(255, 255, 255, 0.015) 100%),
var(--nodedc-auth-card-bg);
box-shadow: none;
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
}
.nodedc-auth-card__copy {
display: grid;
gap: 0.75rem;
}
.nodedc-auth-card__copy h1,
.nodedc-auth-card__copy p {
max-width: 22rem;
margin: 0;
letter-spacing: -0.045em;
text-transform: none;
text-wrap: balance;
}
.nodedc-auth-card__copy h1 {
color: var(--nodedc-auth-text-primary);
font-size: 2rem;
font-weight: 650;
line-height: 2.5rem;
}
.nodedc-auth-card__copy p {
color: var(--nodedc-auth-text-placeholder);
font-size: 1.9rem;
font-weight: 650;
line-height: 2.5rem;
}
.nodedc-invite-card__details {
display: grid;
gap: 0.45rem;
margin-top: 0.45rem;
color: var(--nodedc-auth-text-secondary);
font-size: 0.875rem;
line-height: 1.4;
}
.nodedc-auth-card__status {
margin: 0;
color: var(--nodedc-auth-primary);
font-size: 0.75rem;
font-weight: 600;
line-height: 1rem;
}
.nodedc-auth-card .button {
width: 100%;
min-height: 3.25rem;
margin-top: 0.45rem;
border-radius: 1.25rem;
background: var(--nodedc-auth-primary);
color: var(--nodedc-auth-on-primary);
font-size: 0.95rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.nodedc-auth-card .button:hover:not(:disabled),
.nodedc-auth-card .button:focus-visible:not(:disabled) {
background: var(--nodedc-auth-primary-hover);
color: var(--nodedc-auth-on-primary);
}
.nodedc-auth-card .button:disabled {
opacity: 0.58;
}
.eyebrow { .eyebrow {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.72rem; font-size: 0.72rem;