vojo/src/app/pages/auth/AuthLayout.tsx
v.lagerev 6a8c4bc2ef feat(auth): rebrand auth pages to Vojo with mascot, glassmorphism, and i18n
Rework the entire authentication UI to match the Vojo brand:

- Add mascot video with purple gradient halo behind the auth form
- Glassmorphism card (backdrop-blur, semi-transparent bg) with JS-driven
  layout ported from element-web (ResizeObserver + requestAnimationFrame)
- Custom folds theme overrides via color token references (not hardcoded
  CSS variable hashes) for transparent inputs and white primary button
- Server edit modal dialog replacing browser prompt, with proper
  role="dialog", aria-modal, and Escape key support
- Footer: "Powered by Matrix · Hosted on Yandex Cloud"

Localization:
- Add ru.json, update en.json and de.json with all auth page keys
- Wire up react-i18next t() across all auth components
- Set fallbackLng to 'ru' while preserving LanguageDetector for en/de

Cleanup:
- Remove SSO login flow (SSOLogin, getSSOFlow, SSO rendering)
- Remove token login flow (TokenLogin.tsx, getTokenFlow, loginToken param)
- Strip unused imports (useState, usePathWithOrigin, useClientConfig)
- Fix ESLint: nested ternary → if-return, consistent-return, a11y

Config: vojo.chat as default and only homeserver.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:27:34 +03:00

403 lines
14 KiB
TypeScript

import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Box, Button, Input, Spinner, Text, color } from 'folds';
import {
Outlet,
generatePath,
matchPath,
useLocation,
useNavigate,
useParams,
} from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AuthFooter } from './AuthFooter';
import * as css from './styles.css';
import {
clientAllowedServer,
clientDefaultServer,
useClientConfig,
} from '../../hooks/useClientConfig';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
import { SpecVersionsProvider } from '../../hooks/useSpecVersions';
import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo';
import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer';
import { tryDecodeURIComponent } from '../../utils/dom';
import mascotPoster from '../../../../public/res/img/mascot.png';
import mascotWebm from '../../../../public/res/img/mascot.webm';
const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) return LOGIN_PATH;
if (matchPath(RESET_PASSWORD_PATH, pathname)) return RESET_PASSWORD_PATH;
if (matchPath(REGISTER_PATH, pathname)) return REGISTER_PATH;
return LOGIN_PATH;
};
function AuthLayoutLoading({ message }: { message: string }) {
return (
<Box justifyContent="Center" alignItems="Center" gap="200">
<Spinner size="100" variant="Secondary" />
<Text align="Center" size="T300">
{message}
</Text>
</Box>
);
}
function AuthLayoutError({ message }: { message: string }) {
return (
<Box justifyContent="Center" alignItems="Center" gap="200">
<Text align="Center" style={{ color: color.Critical.Main }} size="T300">
{message}
</Text>
</Box>
);
}
/* ── Server edit dialog ── */
function ServerEditDialog({
currentServer,
onConfirm,
onCancel,
}: {
currentServer: string;
onConfirm: (server: string) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [value, setValue] = useState(currentServer);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = value.trim();
if (trimmed) onConfirm(trimmed);
};
return (
<div
className={css.ServerDialog}
role="dialog"
aria-modal="true"
onKeyDown={(e) => { if (e.key === 'Escape') onCancel(); }}
>
<div className={css.ServerDialogBackdrop} onClick={onCancel} aria-hidden="true" />
<div className={css.ServerDialogCard}>
<Text size="H3" style={{ color: '#e8e4df', fontWeight: 600 }}>
{t('Auth.homeserver_dialog_title')}
</Text>
<Text size="T300" style={{ color: 'rgba(232, 228, 223, 0.65)' }}>
{t('Auth.homeserver_dialog_desc')}
</Text>
<form onSubmit={handleSubmit}>
<Box direction="Column" gap="400">
<Input
ref={inputRef}
variant="Background"
size="500"
outlined
value={value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
placeholder={t('Auth.homeserver_dialog_placeholder')}
/>
<Box gap="300" justifyContent="End">
<Button
type="button"
variant="Secondary"
fill="Soft"
size="400"
onClick={onCancel}
>
<Text size="B400">{t('Auth.homeserver_dialog_cancel')}</Text>
</Button>
<Button type="submit" variant="Primary" size="400">
<Text size="B400">{t('Auth.homeserver_dialog_confirm')}</Text>
</Button>
</Box>
</Box>
</form>
</div>
</div>
);
}
/* ── Layout helpers (from element-web) ── */
function readPx(s: CSSStyleDeclaration, name: string, fallback = 0): number {
const v = Number.parseFloat(s.getPropertyValue(name));
return Number.isFinite(v) ? v : fallback;
}
function readNum(s: CSSStyleDeclaration, name: string, fallback: number): number {
const v = Number.parseFloat(s.getPropertyValue(name));
return Number.isFinite(v) ? v : fallback;
}
function calculateModalLayout(input: {
pageHeight: number;
mascotTopOffset: number;
mascotHeight: number;
modalHeight: number;
footerHeight: number;
anchorRatio: number;
minTop: number;
bottomGap: number;
}) {
const desiredTop = input.mascotTopOffset + input.mascotHeight * input.anchorRatio;
const maxTop = input.pageHeight - input.footerHeight - input.bottomGap - input.modalHeight;
const top = Math.max(input.minTop, Math.min(desiredTop, maxTop));
const maxHeight = Math.max(0, input.pageHeight - input.footerHeight - input.bottomGap - top);
return {
top: Math.round(top),
maxHeight: Math.floor(maxHeight),
constrained: input.modalHeight > maxHeight,
};
}
const rootVars: React.CSSProperties = {
'--vojo-mascot-size': 'clamp(28rem, 57dvh, 48rem)',
'--vojo-mascot-top': 'clamp(1.5rem, 4dvh, 3rem)',
'--vojo-stack-pad': '1.5rem',
'--vojo-anchor-ratio': '0.71',
'--vojo-modal-min-top': 'clamp(12rem, 26dvh, 18rem)',
'--vojo-modal-gap': 'clamp(0.75rem, 2dvh, 1.5rem)',
'--vojo-footer-space': 'clamp(4.5rem, 9dvh, 6.5rem)',
} as React.CSSProperties;
export function AuthLayout() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { server: urlEncodedServer } = useParams();
const clientConfig = useClientConfig();
const defaultServer = clientDefaultServer(clientConfig);
let server: string = urlEncodedServer ? tryDecodeURIComponent(urlEncodedServer) : defaultServer;
if (!clientAllowedServer(clientConfig, server)) {
server = defaultServer;
}
const [showServerDialog, setShowServerDialog] = useState(false);
const [discoveryState, discoverServer] = useAsyncCallback(
useCallback(async (serverName: string) => {
const response = await autoDiscovery(fetch, serverName);
return { serverName, response };
}, [])
);
useEffect(() => {
if (server) discoverServer(server);
}, [discoverServer, server]);
useEffect(() => {
if (!urlEncodedServer || tryDecodeURIComponent(urlEncodedServer) !== server) {
navigate(
generatePath(currentAuthPath(location.pathname), {
server: encodeURIComponent(server),
}),
{ replace: true }
);
}
}, [urlEncodedServer, navigate, location, server]);
const selectServer = useCallback(
(newServer: string) => {
if (newServer === server) {
if (discoveryState.status === AsyncStatus.Loading) return;
discoverServer(server);
return;
}
navigate(
generatePath(currentAuthPath(location.pathname), {
server: encodeURIComponent(newServer),
})
);
},
[navigate, location, discoveryState, server, discoverServer]
);
const [autoDiscoveryError, autoDiscoveryInfo] =
discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : [];
/* ── Page title based on route ── */
const getPageTitle = () => {
if (matchPath(LOGIN_PATH, location.pathname)) return t('Auth.title_login');
if (matchPath(REGISTER_PATH, location.pathname)) return t('Auth.title_register');
return t('Auth.title_reset_password');
};
const pageTitle = getPageTitle();
/* ── Refs for JS-driven layout ── */
const pageRef = useRef<HTMLDivElement>(null);
const mascotRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const footerRef = useRef<HTMLElement>(null);
const cardContentRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const page = pageRef.current;
const mascot = mascotRef.current;
const modal = modalRef.current;
const footer = footerRef.current;
if (!page || !mascot || !modal || !footer) return undefined;
let frameId = 0;
const update = (): void => {
cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(() => {
const s = getComputedStyle(page);
const layout = calculateModalLayout({
pageHeight: page.clientHeight,
mascotTopOffset: readPx(s, '--vojo-mascot-top'),
mascotHeight: mascot.offsetHeight,
modalHeight: modal.offsetHeight,
footerHeight: footer.offsetHeight || readPx(s, '--vojo-footer-space', footer.offsetHeight),
anchorRatio: readNum(s, '--vojo-anchor-ratio', 0.58),
minTop: readPx(s, '--vojo-modal-min-top'),
bottomGap: readPx(s, '--vojo-modal-gap'),
});
page.style.setProperty('--vojo-modal-top', `${layout.top}px`);
page.style.setProperty('--vojo-modal-max-h', `${layout.maxHeight}px`);
if (cardContentRef.current) {
cardContentRef.current.classList.toggle(css.AuthCardContentConstrained, layout.constrained);
}
});
};
update();
const ro = new ResizeObserver(update);
ro.observe(page);
ro.observe(mascot);
ro.observe(modal);
ro.observe(footer);
window.addEventListener('resize', update);
return () => {
cancelAnimationFrame(frameId);
ro.disconnect();
window.removeEventListener('resize', update);
};
}, []);
return (
<div className={css.AuthLayout} style={rootVars} ref={pageRef}>
<div className={css.AuthStack}>
<div className={css.AuthMascot} aria-hidden="true" ref={mascotRef}>
<video
className={css.AuthMascotVideo}
autoPlay
loop
muted
playsInline
preload="auto"
poster={mascotPoster}
>
<source src={mascotWebm} type="video/webm" />
</video>
</div>
<div className={css.AuthModalZone}>
<div className={css.AuthCard} ref={modalRef}>
<div className={css.AuthCardContent} ref={cardContentRef}>
{/* Title */}
<Text size="H2" style={{ color: '#e8e4df', fontWeight: 700 }}>
{pageTitle}
</Text>
{/* Server info row with separator */}
<div className={css.ServerRow}>
<span className={css.ServerLabel}>{t('Auth.homeserver')}</span>
<span className={css.ServerName}>{server}</span>
{clientConfig.allowCustomHomeservers && (
<button
type="button"
className={css.ServerEdit}
onClick={() => setShowServerDialog(true)}
>
{t('Auth.homeserver_edit')}
</button>
)}
</div>
{/* Loading / error / form */}
{discoveryState.status === AsyncStatus.Loading && (
<AuthLayoutLoading message={t('Auth.loading_server')} />
)}
{discoveryState.status === AsyncStatus.Error && (
<AuthLayoutError message={t('Auth.error_server_not_found')} />
)}
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
<AuthLayoutError
message={t('Auth.error_server_config_invalid', { host: autoDiscoveryError.host })}
/>
)}
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
<AuthLayoutError message={t('Auth.error_server_base_url_invalid')} />
)}
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
<AuthServerProvider value={discoveryState.data.serverName}>
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
<SpecVersionsLoader
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
fallback={() => (
<AuthLayoutLoading
message={t('Auth.loading_connecting', { url: autoDiscoveryInfo['m.homeserver'].base_url })}
/>
)}
error={() => (
<AuthLayoutError message={t('Auth.error_server_unavailable')} />
)}
>
{(specVersions) => (
<SpecVersionsProvider value={specVersions}>
<AuthFlowsLoader
fallback={() => (
<AuthLayoutLoading message={t('Auth.loading_auth_flows')} />
)}
error={() => (
<AuthLayoutError message={t('Auth.error_auth_flows')} />
)}
>
{(authFlows) => (
<AuthFlowsProvider value={authFlows}>
<Outlet />
</AuthFlowsProvider>
)}
</AuthFlowsLoader>
</SpecVersionsProvider>
)}
</SpecVersionsLoader>
</AutoDiscoveryInfoProvider>
</AuthServerProvider>
)}
</div>
</div>
</div>
</div>
<AuthFooter footerRef={footerRef} />
{/* Server edit dialog */}
{showServerDialog && (
<ServerEditDialog
currentServer={server}
onConfirm={(newServer) => {
setShowServerDialog(false);
selectServer(newServer);
}}
onCancel={() => setShowServerDialog(false)}
/>
)}
</div>
);
}