diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx
index 79187d8..f3a667c 100644
--- a/src/app/LauncherApp.tsx
+++ b/src/app/LauncherApp.tsx
@@ -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 (
-
-
-
-
-
- Invite flow
-
- {title}
- {description}
- {payload ? (
-
- Инвайт: {payload.invite.email}. Текущий вход: {currentEmail}.
-
- ) : null}
+
+
+
+
+
+
Работайте во всех измерениях.
+
Приглашение в NODE.DC.
+
+
+
+ {details.map((detail) => (
+ {detail}
+ ))}
+
+
+ {statusMessage ? {statusMessage}
: null}
+ {state.status === "error" ? {state.message}
: null}
+
{emailMismatch ? (
-
- Нужно войти под почтой, на которую выписан инвайт.
-
- ) : null}
- {state.status === "error" ? {state.message}
: null}
- {state.status === "accepted" ? (
+
+ ) : state.status === "error" || state.status === "accepted" || isTerminalInvite ? (
) : (
)}
@@ -821,6 +812,26 @@ function InviteFlowScreen({
);
}
+function NodeDcAuthBrandHeader() {
+ return (
+
+ );
+}
+
+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,
diff --git a/src/styles/globals.css b/src/styles/globals.css
index c9360d4..15d72b8 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -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;