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" }}
currentEmail={authSession.user.email}
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={() => {
window.history.replaceState(null, "", "/");
window.location.replace("/");
@ -740,23 +744,16 @@ function InviteFlowScreen({
state,
currentEmail,
onAccept,
onSwitchAccount,
onGoHome,
}: {
state: InviteFlowState;
currentEmail: string;
onAccept: () => void;
onSwitchAccount: () => void;
onGoHome: () => void;
}) {
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 inviteStatus = payload?.invite.status;
const isAccepting = state.status === "accepting";
@ -767,52 +764,46 @@ function InviteFlowScreen({
inviteStatus !== "expired" &&
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 (
<div className="launcher-app">
<main
style={{
display: "grid",
minHeight: "100vh",
placeItems: "center",
padding: "2rem",
}}
>
<section
style={{
display: "grid",
width: "min(34rem, 100%)",
gap: "1rem",
padding: "2rem",
borderRadius: "1.75rem",
background: "linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.035))",
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}
<div className="launcher-app nodedc-auth-page">
<NodeDcAuthBrandHeader />
<main className="nodedc-auth-page__main">
<section className="nodedc-auth-card nodedc-invite-card" aria-live="polite">
<div className="nodedc-auth-card__copy">
<h1>Работайте во всех измерениях.</h1>
<p>Приглашение в NODE.DC.</p>
</div>
<div className="nodedc-invite-card__details">
{details.map((detail) => (
<span key={detail}>{detail}</span>
))}
</div>
{statusMessage ? <p className="nodedc-auth-card__status">{statusMessage}</p> : null}
{state.status === "error" ? <p className="nodedc-auth-card__status">{state.message}</p> : null}
{emailMismatch ? (
<p style={{ margin: 0, color: "var(--warning)", lineHeight: 1.45 }}>
Нужно войти под почтой, на которую выписан инвайт.
</p>
) : null}
{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={onSwitchAccount}>
Сменить аккаунт
</button>
) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (
<button className="button button--primary" type="button" onClick={onGoHome}>
Перейти в витрину
</button>
) : (
<button className="button button--primary" type="button" disabled={!canAccept || isAccepting} onClick={onAccept}>
{isAccepting ? "Подключаем доступ" : "Принять приглашение"}
{state.status === "loading" ? "Проверяем" : isAccepting ? "Подключаем доступ" : "Принять приглашение"}
</button>
)}
</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({
title,
description,

View File

@ -125,6 +125,122 @@ code {
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 {
color: var(--text-muted);
font-size: 0.72rem;