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>
This commit is contained in:
parent
92fe17b3a3
commit
6a8c4bc2ef
18 changed files with 828 additions and 543 deletions
25
config.json
25
config.json
|
|
@ -1,28 +1,13 @@
|
|||
{
|
||||
"defaultHomeserver": 1,
|
||||
"homeserverList": ["converser.eu", "matrix.org", "mozilla.org", "unredacted.org", "xmr.se"],
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": ["vojo.chat"],
|
||||
"allowCustomHomeservers": true,
|
||||
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [
|
||||
"#cinny-space:matrix.org",
|
||||
"#community:matrix.org",
|
||||
"#space:unredacted.org",
|
||||
"#science-space:matrix.org",
|
||||
"#libregaming-games:tchncs.de",
|
||||
"#mathematics-on:matrix.org",
|
||||
"#stickers-and-emojis:tastytea.de"
|
||||
],
|
||||
"rooms": [
|
||||
"#cinny:matrix.org",
|
||||
"#freesoftware:matrix.org",
|
||||
"#pcapdroid:matrix.org",
|
||||
"#gentoo:matrix.org",
|
||||
"#PrivSec.dev:arcticfoxes.net",
|
||||
"#disroot:aria-net.org"
|
||||
],
|
||||
"servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": ["vojo.chat"]
|
||||
},
|
||||
|
||||
"hashRouter": {
|
||||
|
|
|
|||
|
|
@ -3,5 +3,53 @@
|
|||
"RoomCommon": {
|
||||
"changed_room_name": " hat den Raum Name geändert"
|
||||
}
|
||||
},
|
||||
"Auth": {
|
||||
"title_login": "Anmelden",
|
||||
"title_register": "Registrieren",
|
||||
"title_reset_password": "Passwort zurücksetzen",
|
||||
"homeserver": "Homeserver",
|
||||
"homeserver_edit": "Bearbeiten",
|
||||
"homeserver_dialog_title": "Homeserver",
|
||||
"homeserver_dialog_desc": "Geben Sie die Adresse des Matrix-Homeservers ein, mit dem Sie sich verbinden möchten.",
|
||||
"homeserver_dialog_placeholder": "example.com",
|
||||
"homeserver_dialog_cancel": "Abbrechen",
|
||||
"homeserver_dialog_confirm": "Weiter",
|
||||
"username_placeholder": "Benutzername",
|
||||
"password_placeholder": "Passwort",
|
||||
"forgot_password": "Passwort vergessen?",
|
||||
"login_button": "Anmelden",
|
||||
"new_here": "Neu hier?",
|
||||
"create_account": "Konto erstellen",
|
||||
"already_have_account": "Bereits ein Konto?",
|
||||
"remember_password": "Passwort wieder eingefallen?",
|
||||
"loading_server": "Server wird gesucht...",
|
||||
"loading_connecting": "Verbindung zu {{url}}...",
|
||||
"loading_auth_flows": "Laden...",
|
||||
"error_server_not_found": "Server konnte nicht gefunden werden.",
|
||||
"error_server_config_invalid": "Verbindung fehlgeschlagen. Die Konfiguration des Servers {{host}} ist ungültig.",
|
||||
"error_server_base_url_invalid": "Verbindung fehlgeschlagen. Ungültige base_url des Servers.",
|
||||
"error_server_unavailable": "Verbindung zum Server konnte nicht hergestellt werden.",
|
||||
"error_auth_flows": "Autorisierungsabläufe konnten nicht geladen werden.",
|
||||
"error_client_unsupported": "Dieser Client unterstützt keine Autorisierung auf dem Server \"{{server}}\".",
|
||||
"error_custom_server_not_allowed": "Anmeldung mit benutzerdefiniertem Server ist nicht erlaubt.",
|
||||
"error_matrix_id_server": "Matrix-ID-Server konnte nicht gefunden werden.",
|
||||
"error_invalid_credentials": "Ungültiger Benutzername oder Passwort.",
|
||||
"error_account_deactivated": "Dieses Konto wurde deaktiviert.",
|
||||
"error_invalid_request": "Anmeldung fehlgeschlagen. Ein Teil der Anfragedaten ist ungültig.",
|
||||
"error_rate_limited": "Zu viele Versuche. Bitte versuchen Sie es später erneut.",
|
||||
"error_unknown": "Anmeldung fehlgeschlagen. Unbekannter Fehler.",
|
||||
"register_disabled": "Die Registrierung ist auf diesem Server deaktiviert.",
|
||||
"register_rate_limited": "Zu viele Versuche. Bitte versuchen Sie es später erneut.",
|
||||
"register_invalid_request": "Ungültige Anfrage. Registrierungsparameter konnten nicht abgerufen werden.",
|
||||
"register_unsupported": "Diese Anwendung unterstützt keine Registrierung auf diesem Server.",
|
||||
"reset_description": "Der Homeserver <strong>{{server}}</strong> sendet Ihnen eine E-Mail, um Ihr Passwort zurückzusetzen.",
|
||||
"reset_email_label": "E-Mail",
|
||||
"reset_new_password": "Neues Passwort",
|
||||
"reset_confirm_password": "Passwort bestätigen",
|
||||
"reset_button": "Passwort zurücksetzen",
|
||||
"reset_error_fallback": "Passwort konnte nicht zurückgesetzt werden.",
|
||||
"reset_success_message": "Das Passwort wurde erfolgreich zurückgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an.",
|
||||
"reset_success_login": "Anmelden"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,5 +3,53 @@
|
|||
"RoomCommon": {
|
||||
"changed_room_name": " changed room name"
|
||||
}
|
||||
},
|
||||
"Auth": {
|
||||
"title_login": "Log In",
|
||||
"title_register": "Register",
|
||||
"title_reset_password": "Reset Password",
|
||||
"homeserver": "Homeserver",
|
||||
"homeserver_edit": "Edit",
|
||||
"homeserver_dialog_title": "Homeserver",
|
||||
"homeserver_dialog_desc": "Enter the address of the Matrix homeserver you want to connect to.",
|
||||
"homeserver_dialog_placeholder": "example.com",
|
||||
"homeserver_dialog_cancel": "Cancel",
|
||||
"homeserver_dialog_confirm": "Continue",
|
||||
"username_placeholder": "Username",
|
||||
"password_placeholder": "Password",
|
||||
"forgot_password": "Forgot password?",
|
||||
"login_button": "Log In",
|
||||
"new_here": "New here?",
|
||||
"create_account": "Create account",
|
||||
"already_have_account": "Already have an account?",
|
||||
"remember_password": "Remember your password?",
|
||||
"loading_server": "Looking for server...",
|
||||
"loading_connecting": "Connecting to {{url}}...",
|
||||
"loading_auth_flows": "Loading...",
|
||||
"error_server_not_found": "Failed to find server.",
|
||||
"error_server_config_invalid": "Failed to connect. Server configuration for {{host}} is invalid.",
|
||||
"error_server_base_url_invalid": "Failed to connect. Invalid server base_url.",
|
||||
"error_server_unavailable": "Failed to connect to the server.",
|
||||
"error_auth_flows": "Failed to load authorization flows.",
|
||||
"error_client_unsupported": "This client does not support authorization on server \"{{server}}\".",
|
||||
"error_custom_server_not_allowed": "Login with a custom server is not allowed.",
|
||||
"error_matrix_id_server": "Failed to find Matrix ID server.",
|
||||
"error_invalid_credentials": "Invalid username or password.",
|
||||
"error_account_deactivated": "This account has been deactivated.",
|
||||
"error_invalid_request": "Failed to log in. Part of the request data is invalid.",
|
||||
"error_rate_limited": "Too many attempts. Please try again later.",
|
||||
"error_unknown": "Failed to log in. Unknown error.",
|
||||
"register_disabled": "Registration is disabled on this server.",
|
||||
"register_rate_limited": "Too many attempts. Please try again later.",
|
||||
"register_invalid_request": "Invalid request. Failed to get registration parameters.",
|
||||
"register_unsupported": "This application does not support registration on this server.",
|
||||
"reset_description": "Homeserver <strong>{{server}}</strong> will send you an email to let you reset your password.",
|
||||
"reset_email_label": "Email",
|
||||
"reset_new_password": "New Password",
|
||||
"reset_confirm_password": "Confirm Password",
|
||||
"reset_button": "Reset Password",
|
||||
"reset_error_fallback": "Failed to reset password.",
|
||||
"reset_success_message": "Password has been reset successfully. Please login with your new password.",
|
||||
"reset_success_login": "Login"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
public/locales/ru.json
Normal file
55
public/locales/ru.json
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"Organisms": {
|
||||
"RoomCommon": {
|
||||
"changed_room_name": " изменил(а) название комнаты"
|
||||
}
|
||||
},
|
||||
"Auth": {
|
||||
"title_login": "Войти",
|
||||
"title_register": "Регистрация",
|
||||
"title_reset_password": "Сброс пароля",
|
||||
"homeserver": "Домашний сервер",
|
||||
"homeserver_edit": "Редактировать",
|
||||
"homeserver_dialog_title": "Домашний сервер",
|
||||
"homeserver_dialog_desc": "Укажите адрес домашнего сервера Matrix, к которому хотите подключиться.",
|
||||
"homeserver_dialog_placeholder": "example.com",
|
||||
"homeserver_dialog_cancel": "Отмена",
|
||||
"homeserver_dialog_confirm": "Продолжить",
|
||||
"username_placeholder": "Имя пользователя",
|
||||
"password_placeholder": "Пароль",
|
||||
"forgot_password": "Забыли пароль?",
|
||||
"login_button": "Войти",
|
||||
"new_here": "Впервые здесь?",
|
||||
"create_account": "Создать учётную запись",
|
||||
"already_have_account": "Уже есть аккаунт?",
|
||||
"remember_password": "Вспомнили пароль?",
|
||||
"loading_server": "Поиск сервера...",
|
||||
"loading_connecting": "Подключение к {{url}}...",
|
||||
"loading_auth_flows": "Загрузка...",
|
||||
"error_server_not_found": "Не удалось найти сервер.",
|
||||
"error_server_config_invalid": "Не удалось подключиться. Конфигурация сервера {{host}} некорректна.",
|
||||
"error_server_base_url_invalid": "Не удалось подключиться. Неверный base_url сервера.",
|
||||
"error_server_unavailable": "Не удалось подключиться к серверу.",
|
||||
"error_auth_flows": "Не удалось загрузить потоки авторизации.",
|
||||
"error_client_unsupported": "Этот клиент не поддерживает авторизацию на сервере \"{{server}}\".",
|
||||
"error_custom_server_not_allowed": "Вход с пользовательским сервером не разрешён.",
|
||||
"error_matrix_id_server": "Не удалось найти сервер Matrix ID.",
|
||||
"error_invalid_credentials": "Неверное имя пользователя или пароль.",
|
||||
"error_account_deactivated": "Эта учётная запись была деактивирована.",
|
||||
"error_invalid_request": "Не удалось войти. Часть данных запроса некорректна.",
|
||||
"error_rate_limited": "Слишком много попыток. Попробуйте позже.",
|
||||
"error_unknown": "Не удалось войти. Неизвестная ошибка.",
|
||||
"register_disabled": "Регистрация отключена на этом сервере.",
|
||||
"register_rate_limited": "Слишком много попыток. Попробуйте позже.",
|
||||
"register_invalid_request": "Неверный запрос. Не удалось получить параметры регистрации.",
|
||||
"register_unsupported": "Это приложение не поддерживает регистрацию на данном сервере.",
|
||||
"reset_description": "Сервер <strong>{{server}}</strong> отправит вам письмо для сброса пароля.",
|
||||
"reset_email_label": "Email",
|
||||
"reset_new_password": "Новый пароль",
|
||||
"reset_confirm_password": "Подтвердите пароль",
|
||||
"reset_button": "Сбросить пароль",
|
||||
"reset_error_fallback": "Не удалось сбросить пароль.",
|
||||
"reset_success_message": "Пароль успешно сброшен. Войдите с новым паролем.",
|
||||
"reset_success_login": "Войти"
|
||||
}
|
||||
}
|
||||
BIN
public/res/img/mascot.png
Normal file
BIN
public/res/img/mascot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
BIN
public/res/img/mascot.webm
Normal file
BIN
public/res/img/mascot.webm
Normal file
Binary file not shown.
|
|
@ -1,29 +1,16 @@
|
|||
import { useMemo } from 'react';
|
||||
import { ILoginFlow, IPasswordFlow, ISSOFlow, LoginFlow } from 'matrix-js-sdk/lib/@types/auth';
|
||||
|
||||
export const getSSOFlow = (loginFlows: LoginFlow[]): ISSOFlow | undefined =>
|
||||
loginFlows.find((flow) => flow.type === 'm.login.sso' || flow.type === 'm.login.cas') as
|
||||
| ISSOFlow
|
||||
| undefined;
|
||||
import { IPasswordFlow, LoginFlow } from 'matrix-js-sdk/lib/@types/auth';
|
||||
|
||||
export const getPasswordFlow = (loginFlows: LoginFlow[]): IPasswordFlow | undefined =>
|
||||
loginFlows.find((flow) => flow.type === 'm.login.password') as IPasswordFlow;
|
||||
export const getTokenFlow = (loginFlows: LoginFlow[]): LoginFlow | undefined =>
|
||||
loginFlows.find((flow) => flow.type === 'm.login.token') as ILoginFlow & {
|
||||
type: 'm.login.token';
|
||||
};
|
||||
|
||||
export type ParsedLoginFlows = {
|
||||
password?: LoginFlow;
|
||||
token?: LoginFlow;
|
||||
sso?: ISSOFlow;
|
||||
};
|
||||
export const useParsedLoginFlows = (loginFlows: LoginFlow[]) => {
|
||||
const parsedFlow: ParsedLoginFlows = useMemo<ParsedLoginFlows>(
|
||||
() => ({
|
||||
password: getPasswordFlow(loginFlows),
|
||||
token: getTokenFlow(loginFlows),
|
||||
sso: getSSOFlow(loginFlows),
|
||||
}),
|
||||
[loginFlows]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ i18n
|
|||
// for all options read: https://www.i18next.com/overview/configuration-options
|
||||
.init<HttpBackendOptions>({
|
||||
debug: false,
|
||||
fallbackLng: 'en',
|
||||
fallbackLng: 'ru',
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,28 +1,18 @@
|
|||
import React from 'react';
|
||||
import { Box, Text } from 'folds';
|
||||
import React, { Ref } from 'react';
|
||||
import * as css from './styles.css';
|
||||
|
||||
export function AuthFooter() {
|
||||
interface AuthFooterProps {
|
||||
footerRef?: Ref<HTMLElement>;
|
||||
}
|
||||
|
||||
export function AuthFooter({ footerRef }: AuthFooterProps) {
|
||||
return (
|
||||
<Box className={css.AuthFooter} justifyContent="Center" gap="400" wrap="Wrap">
|
||||
<Text as="a" size="T300" href="https://cinny.in" target="_blank" rel="noreferrer">
|
||||
About
|
||||
</Text>
|
||||
<Text
|
||||
as="a"
|
||||
size="T300"
|
||||
href="https://github.com/ajbura/cinny/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
v4.11.1
|
||||
</Text>
|
||||
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
|
||||
Twitter
|
||||
</Text>
|
||||
<Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
|
||||
Powered by Matrix
|
||||
</Text>
|
||||
</Box>
|
||||
<footer className={css.AuthFooter} role="contentinfo" ref={footerRef}>
|
||||
<span>Powered by Matrix</span>
|
||||
<span className={css.AuthFooterSeparator} aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span>Hosted on Yandex Cloud</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { Box, Header, Scroll, Spinner, Text, color } from 'folds';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Box, Button, Input, Spinner, Text, color } from 'folds';
|
||||
import {
|
||||
Outlet,
|
||||
generatePath,
|
||||
|
|
@ -8,11 +8,10 @@ import {
|
|||
useNavigate,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AuthFooter } from './AuthFooter';
|
||||
import * as css from './styles.css';
|
||||
import * as PatternsCss from '../../styles/Patterns.css';
|
||||
import {
|
||||
clientAllowedServer,
|
||||
clientDefaultServer,
|
||||
|
|
@ -20,8 +19,6 @@ import {
|
|||
} from '../../hooks/useClientConfig';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
|
||||
import CinnySVG from '../../../../public/res/svg/cinny.svg';
|
||||
import { ServerPicker } from './ServerPicker';
|
||||
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
||||
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
||||
import { SpecVersionsProvider } from '../../hooks/useSpecVersions';
|
||||
|
|
@ -30,17 +27,13 @@ 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;
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
@ -65,13 +58,128 @@ function AuthLayoutError({ message }: { message: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
|
||||
|
|
@ -79,13 +187,12 @@ export function AuthLayout() {
|
|||
server = defaultServer;
|
||||
}
|
||||
|
||||
const [showServerDialog, setShowServerDialog] = useState(false);
|
||||
|
||||
const [discoveryState, discoverServer] = useAsyncCallback(
|
||||
useCallback(async (serverName: string) => {
|
||||
const response = await autoDiscovery(fetch, serverName);
|
||||
return {
|
||||
serverName,
|
||||
response,
|
||||
};
|
||||
return { serverName, response };
|
||||
}, [])
|
||||
);
|
||||
|
||||
|
|
@ -93,7 +200,6 @@ export function AuthLayout() {
|
|||
if (server) discoverServer(server);
|
||||
}, [discoverServer, server]);
|
||||
|
||||
// if server is mismatches with path server, update path
|
||||
useEffect(() => {
|
||||
if (!urlEncodedServer || tryDecodeURIComponent(urlEncodedServer) !== server) {
|
||||
navigate(
|
||||
|
|
@ -113,7 +219,9 @@ export function AuthLayout() {
|
|||
return;
|
||||
}
|
||||
navigate(
|
||||
generatePath(currentAuthPath(location.pathname), { server: encodeURIComponent(newServer) })
|
||||
generatePath(currentAuthPath(location.pathname), {
|
||||
server: encodeURIComponent(newServer),
|
||||
})
|
||||
);
|
||||
},
|
||||
[navigate, location, discoveryState, server, discoverServer]
|
||||
|
|
@ -122,88 +230,174 @@ export function AuthLayout() {
|
|||
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 (
|
||||
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
|
||||
<Box
|
||||
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
gap="400"
|
||||
>
|
||||
<Box direction="Column" className={css.AuthCard}>
|
||||
<Header className={css.AuthHeader} size="600" variant="Surface">
|
||||
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
||||
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
|
||||
<Text size="H3">Cinny</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
<Box className={css.AuthCardContent} direction="Column">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text as="label" size="L400" priority="300">
|
||||
Homeserver
|
||||
<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>
|
||||
<ServerPicker
|
||||
server={server}
|
||||
serverList={clientConfig.homeserverList ?? []}
|
||||
allowCustomServer={clientConfig.allowCustomHomeservers}
|
||||
onServerChange={selectServer}
|
||||
/>
|
||||
</Box>
|
||||
{discoveryState.status === AsyncStatus.Loading && (
|
||||
<AuthLayoutLoading message="Looking for homeserver..." />
|
||||
)}
|
||||
{discoveryState.status === AsyncStatus.Error && (
|
||||
<AuthLayoutError message="Failed to find homeserver." />
|
||||
)}
|
||||
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
|
||||
<AuthLayoutError
|
||||
message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
|
||||
/>
|
||||
)}
|
||||
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
|
||||
<AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
|
||||
)}
|
||||
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
|
||||
<AuthServerProvider value={discoveryState.data.serverName}>
|
||||
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
|
||||
<SpecVersionsLoader
|
||||
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
|
||||
fallback={() => (
|
||||
<AuthLayoutLoading
|
||||
message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
|
||||
/>
|
||||
)}
|
||||
error={() => (
|
||||
<AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
|
||||
)}
|
||||
|
||||
{/* 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)}
|
||||
>
|
||||
{(specVersions) => (
|
||||
<SpecVersionsProvider value={specVersions}>
|
||||
<AuthFlowsLoader
|
||||
fallback={() => (
|
||||
<AuthLayoutLoading message="Loading authentication flow..." />
|
||||
)}
|
||||
error={() => (
|
||||
<AuthLayoutError message="Failed to get authentication flow information." />
|
||||
)}
|
||||
>
|
||||
{(authFlows) => (
|
||||
<AuthFlowsProvider value={authFlows}>
|
||||
<Outlet />
|
||||
</AuthFlowsProvider>
|
||||
)}
|
||||
</AuthFlowsLoader>
|
||||
</SpecVersionsProvider>
|
||||
)}
|
||||
</SpecVersionsLoader>
|
||||
</AutoDiscoveryInfoProvider>
|
||||
</AuthServerProvider>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<AuthFooter />
|
||||
</Box>
|
||||
</Scroll>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,98 +1,50 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Box, Text, color } from 'folds';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { SSOAction } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthFlows } from '../../../hooks/useAuthFlows';
|
||||
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
||||
import { PasswordLoginForm } from './PasswordLoginForm';
|
||||
import { SSOLogin } from '../SSOLogin';
|
||||
import { TokenLogin } from './TokenLogin';
|
||||
import { OrDivider } from '../OrDivider';
|
||||
import { getLoginPath, getRegisterPath, withSearchParam } from '../../pathUtils';
|
||||
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||
import { getRegisterPath } from '../../pathUtils';
|
||||
import { LoginPathSearchParams } from '../../paths';
|
||||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||
|
||||
const getLoginTokenSearchParam = () => {
|
||||
// when using hasRouter query params in existing route
|
||||
// gets ignored by react-router, so we need to read it ourself
|
||||
// we only need to read loginToken as it's the only param that
|
||||
// is provided by external entity. example: SSO login
|
||||
const parmas = new URLSearchParams(window.location.search);
|
||||
const loginToken = parmas.get('loginToken');
|
||||
return loginToken ?? undefined;
|
||||
};
|
||||
|
||||
const useLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchParams =>
|
||||
useMemo(
|
||||
() => ({
|
||||
username: searchParams.get('username') ?? undefined,
|
||||
email: searchParams.get('email') ?? undefined,
|
||||
loginToken: searchParams.get('loginToken') ?? undefined,
|
||||
}),
|
||||
[searchParams]
|
||||
);
|
||||
|
||||
export function Login() {
|
||||
const { t } = useTranslation();
|
||||
const server = useAuthServer();
|
||||
const { hashRouter } = useClientConfig();
|
||||
const { loginFlows } = useAuthFlows();
|
||||
const [searchParams] = useSearchParams();
|
||||
const loginSearchParams = useLoginSearchParams(searchParams);
|
||||
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
||||
const loginTokenForHashRouter = getLoginTokenSearchParam();
|
||||
const absoluteLoginPath = usePathWithOrigin(getLoginPath(server));
|
||||
|
||||
if (hashRouter?.enabled && loginTokenForHashRouter) {
|
||||
window.location.replace(
|
||||
withSearchParam(absoluteLoginPath, {
|
||||
loginToken: loginTokenForHashRouter,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="500">
|
||||
<Text size="H2" priority="400">
|
||||
Login
|
||||
</Text>
|
||||
{parsedFlows.token && loginSearchParams.loginToken && (
|
||||
<TokenLogin token={loginSearchParams.loginToken} />
|
||||
)}
|
||||
{parsedFlows.password && (
|
||||
<>
|
||||
<PasswordLoginForm
|
||||
defaultUsername={loginSearchParams.username}
|
||||
defaultEmail={loginSearchParams.email}
|
||||
/>
|
||||
<span data-spacing-node />
|
||||
{parsedFlows.sso && <OrDivider />}
|
||||
</>
|
||||
<PasswordLoginForm
|
||||
defaultUsername={loginSearchParams.username}
|
||||
defaultEmail={loginSearchParams.email}
|
||||
/>
|
||||
)}
|
||||
{parsedFlows.sso && (
|
||||
<>
|
||||
<SSOLogin
|
||||
providers={parsedFlows.sso.identity_providers}
|
||||
redirectUrl={ssoRedirectUrl}
|
||||
action={SSOAction.LOGIN}
|
||||
saveScreenSpace={parsedFlows.password !== undefined}
|
||||
/>
|
||||
<span data-spacing-node />
|
||||
</>
|
||||
{!parsedFlows.password && (
|
||||
<Text style={{ color: color.Critical.Main }}>
|
||||
{t('Auth.error_client_unsupported', { server })}
|
||||
</Text>
|
||||
)}
|
||||
{!parsedFlows.password && !parsedFlows.sso && (
|
||||
<>
|
||||
<Text style={{ color: color.Critical.Main }}>
|
||||
{`This client does not support login on "${server}" homeserver. Password and SSO based login method not found.`}
|
||||
</Text>
|
||||
<span data-spacing-node />
|
||||
</>
|
||||
)}
|
||||
<Text align="Center">
|
||||
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
|
||||
<Text align="Center" size="T300" style={{ color: 'rgba(232, 228, 223, 0.55)' }}>
|
||||
{t('Auth.new_here')}{' '}
|
||||
<Link to={getRegisterPath(server)} style={{ fontWeight: 600 }}>
|
||||
{t('Auth.create_account')}
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,24 +1,16 @@
|
|||
import React, { FormEventHandler, MouseEventHandler, useCallback, useState } from 'react';
|
||||
import React, { FormEventHandler, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Menu,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Spinner,
|
||||
Text,
|
||||
config,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
|
||||
import { EMAIL_REGEX } from '../../../utils/regex';
|
||||
|
|
@ -36,81 +28,14 @@ import {
|
|||
import { PasswordInput } from '../../../components/password-input';
|
||||
import { FieldError } from '../FiledError';
|
||||
import { getResetPasswordPath } from '../../pathUtils';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
|
||||
function UsernameHint({ server }: { server: string }) {
|
||||
const [anchor, setAnchor] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
setAnchor(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
return (
|
||||
<PopOut
|
||||
anchor={anchor}
|
||||
position="Top"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
|
||||
<Text size="L400">Hint</Text>
|
||||
</Header>
|
||||
<Box
|
||||
style={{ padding: config.space.S200, paddingTop: 0 }}
|
||||
direction="Column"
|
||||
tabIndex={0}
|
||||
gap="100"
|
||||
>
|
||||
<Text size="T300">
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
Username:
|
||||
</Text>{' '}
|
||||
user123
|
||||
</Text>
|
||||
<Text size="T300">
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
Matrix ID:
|
||||
</Text>
|
||||
{` @user123:${server}`}
|
||||
</Text>
|
||||
<Text size="T300">
|
||||
<Text as="span" size="Inherit" priority="300">
|
||||
Email:
|
||||
</Text>
|
||||
{` user123@${server}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
onClick={handleOpenMenu}
|
||||
type="button"
|
||||
variant="Background"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-pressed={!!anchor}
|
||||
>
|
||||
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
);
|
||||
}
|
||||
|
||||
type PasswordLoginFormProps = {
|
||||
defaultUsername?: string;
|
||||
defaultEmail?: string;
|
||||
};
|
||||
|
||||
export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLoginFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const server = useAuthServer();
|
||||
const clientConfig = useClientConfig();
|
||||
|
||||
|
|
@ -133,7 +58,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
|
|||
user: username,
|
||||
},
|
||||
password,
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
initial_device_display_name: 'Vojo Web',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -151,9 +76,10 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
|
|||
user: mxIdUsername,
|
||||
},
|
||||
password,
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
initial_device_display_name: 'Vojo Web',
|
||||
});
|
||||
};
|
||||
|
||||
const handleEmailLogin = (email: string, password: string) => {
|
||||
startLogin(baseUrl, {
|
||||
type: 'm.login.password',
|
||||
|
|
@ -163,7 +89,7 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
|
|||
address: email,
|
||||
},
|
||||
password,
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
initial_device_display_name: 'Vojo Web',
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -199,65 +125,63 @@ export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLog
|
|||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} direction="Inherit" gap="400">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text as="label" size="L400" priority="300">
|
||||
Username
|
||||
</Text>
|
||||
<Input
|
||||
defaultValue={defaultUsername ?? defaultEmail}
|
||||
style={{ paddingRight: config.space.S300 }}
|
||||
name="usernameInput"
|
||||
variant="Background"
|
||||
size="500"
|
||||
required
|
||||
outlined
|
||||
after={<UsernameHint server={server} />}
|
||||
placeholder={t('Auth.username_placeholder')}
|
||||
/>
|
||||
{loginState.status === AsyncStatus.Error && (
|
||||
<>
|
||||
{loginState.error.errcode === LoginError.ServerNotAllowed && (
|
||||
<FieldError message="Login with custom server not allowed by your client instance." />
|
||||
<FieldError message={t('Auth.error_custom_server_not_allowed')} />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.InvalidServer && (
|
||||
<FieldError message="Failed to find your Matrix ID server." />
|
||||
<FieldError message={t('Auth.error_matrix_id_server')} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text as="label" size="L400" priority="300">
|
||||
Password
|
||||
</Text>
|
||||
<PasswordInput name="passwordInput" variant="Background" size="500" outlined required />
|
||||
<Box alignItems="Start" justifyContent="SpaceBetween" gap="200">
|
||||
{loginState.status === AsyncStatus.Error && (
|
||||
<>
|
||||
{loginState.error.errcode === LoginError.Forbidden && (
|
||||
<FieldError message="Invalid Username or Password." />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.UserDeactivated && (
|
||||
<FieldError message="This account has been deactivated." />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.InvalidRequest && (
|
||||
<FieldError message="Failed to login. Part of your request data is invalid." />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.RateLimited && (
|
||||
<FieldError message="Failed to login. Your login request has been rate-limited by server, Please try after some time." />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.Unknown && (
|
||||
<FieldError message="Failed to login. Unknown reason." />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Box grow="Yes" shrink="No" justifyContent="End">
|
||||
<Text as="span" size="T200" priority="400" align="Right">
|
||||
<Link to={getResetPasswordPath(server)}>Forget Password?</Link>
|
||||
</Text>
|
||||
<PasswordInput
|
||||
name="passwordInput"
|
||||
variant="Background"
|
||||
size="500"
|
||||
outlined
|
||||
required
|
||||
placeholder={t('Auth.password_placeholder')}
|
||||
/>
|
||||
{loginState.status === AsyncStatus.Error && (
|
||||
<Box direction="Column" gap="100">
|
||||
{loginState.error.errcode === LoginError.Forbidden && (
|
||||
<FieldError message={t('Auth.error_invalid_credentials')} />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.UserDeactivated && (
|
||||
<FieldError message={t('Auth.error_account_deactivated')} />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.InvalidRequest && (
|
||||
<FieldError message={t('Auth.error_invalid_request')} />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.RateLimited && (
|
||||
<FieldError message={t('Auth.error_rate_limited')} />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.Unknown && (
|
||||
<FieldError message={t('Auth.error_unknown')} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Text align="Center" size="T200">
|
||||
<Link to={getResetPasswordPath(server)}>
|
||||
{t('Auth.forgot_password')}
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
<Button type="submit" variant="Primary" size="500">
|
||||
<Text as="span" size="B500">
|
||||
Login
|
||||
{t('Auth.login_button')}
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
import {
|
||||
Box,
|
||||
Icon,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Spinner,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { CustomLoginResponse, LoginError, login, useLoginComplete } from './loginUtil';
|
||||
|
||||
function LoginTokenError({ message }: { message: string }) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
backgroundColor: color.Critical.Container,
|
||||
color: color.Critical.OnContainer,
|
||||
padding: config.space.S300,
|
||||
borderRadius: config.radii.R400,
|
||||
}}
|
||||
justifyContent="Start"
|
||||
alignItems="Start"
|
||||
gap="300"
|
||||
>
|
||||
<Icon size="300" filled src={Icons.Warning} />
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Token Login</Text>
|
||||
<Text size="T300">
|
||||
<b>{message}</b>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type TokenLoginProps = {
|
||||
token: string;
|
||||
};
|
||||
export function TokenLogin({ token }: TokenLoginProps) {
|
||||
const discovery = useAutoDiscoveryInfo();
|
||||
const baseUrl = discovery['m.homeserver'].base_url;
|
||||
|
||||
const [loginState, startLogin] = useAsyncCallback<
|
||||
CustomLoginResponse,
|
||||
MatrixError,
|
||||
Parameters<typeof login>
|
||||
>(useCallback(login, []));
|
||||
|
||||
useEffect(() => {
|
||||
startLogin(baseUrl, {
|
||||
type: 'm.login.token',
|
||||
token,
|
||||
initial_device_display_name: 'Cinny Web',
|
||||
});
|
||||
}, [baseUrl, token, startLogin]);
|
||||
|
||||
useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loginState.status === AsyncStatus.Error && (
|
||||
<>
|
||||
{loginState.error.errcode === LoginError.Forbidden && (
|
||||
<LoginTokenError message="Invalid login token." />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.UserDeactivated && (
|
||||
<LoginTokenError message="This account has been deactivated." />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.InvalidRequest && (
|
||||
<LoginTokenError message="Failed to login. Part of your request data is invalid." />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.RateLimited && (
|
||||
<LoginTokenError message="Failed to login. Your login request has been rate-limited by server, Please try after some time." />
|
||||
)}
|
||||
{loginState.error.errcode === LoginError.Unknown && (
|
||||
<LoginTokenError message="Failed to login. Unknown reason." />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Overlay open={loginState.status !== AsyncStatus.Error} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<Spinner size="600" variant="Secondary" />
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,12 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Box, Text, color } from 'folds';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { SSOAction } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||
import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows';
|
||||
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
|
||||
import { PasswordRegisterForm, SUPPORTED_REGISTER_STAGES } from '../register/PasswordRegisterForm';
|
||||
import { OrDivider } from '../OrDivider';
|
||||
import { SSOLogin } from '../SSOLogin';
|
||||
import { SupportedUIAFlowsLoader } from '../../../components/SupportedUIAFlowsLoader';
|
||||
import { getLoginPath } from '../../pathUtils';
|
||||
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
|
||||
import { RegisterPathSearchParams } from '../../paths';
|
||||
|
||||
const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams =>
|
||||
|
|
@ -24,74 +20,56 @@ const useRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSea
|
|||
);
|
||||
|
||||
export function Register() {
|
||||
const { t } = useTranslation();
|
||||
const server = useAuthServer();
|
||||
const { loginFlows, registerFlows } = useAuthFlows();
|
||||
const { registerFlows } = useAuthFlows();
|
||||
const [searchParams] = useSearchParams();
|
||||
const registerSearchParams = useRegisterSearchParams(searchParams);
|
||||
const { sso } = useParsedLoginFlows(loginFlows.flows);
|
||||
|
||||
// redirect to /login because only that path handle m.login.token
|
||||
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="500">
|
||||
<Text size="H2" priority="400">
|
||||
Register
|
||||
</Text>
|
||||
{registerFlows.status === RegisterFlowStatus.RegistrationDisabled && !sso && (
|
||||
{registerFlows.status === RegisterFlowStatus.RegistrationDisabled && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
Registration has been disabled on this homeserver.
|
||||
{t('Auth.register_disabled')}
|
||||
</Text>
|
||||
)}
|
||||
{registerFlows.status === RegisterFlowStatus.RateLimited && !sso && (
|
||||
{registerFlows.status === RegisterFlowStatus.RateLimited && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
You have been rate-limited! Please try after some time.
|
||||
{t('Auth.register_rate_limited')}
|
||||
</Text>
|
||||
)}
|
||||
{registerFlows.status === RegisterFlowStatus.InvalidRequest && !sso && (
|
||||
{registerFlows.status === RegisterFlowStatus.InvalidRequest && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
Invalid Request! Failed to get any registration options.
|
||||
{t('Auth.register_invalid_request')}
|
||||
</Text>
|
||||
)}
|
||||
{registerFlows.status === RegisterFlowStatus.FlowRequired && (
|
||||
<>
|
||||
<SupportedUIAFlowsLoader
|
||||
flows={registerFlows.data.flows ?? []}
|
||||
supportedStages={SUPPORTED_REGISTER_STAGES}
|
||||
>
|
||||
{(supportedFlows) =>
|
||||
supportedFlows.length === 0 ? (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
This application does not support registration on this homeserver.
|
||||
</Text>
|
||||
) : (
|
||||
<PasswordRegisterForm
|
||||
authData={registerFlows.data}
|
||||
uiaFlows={supportedFlows}
|
||||
defaultUsername={registerSearchParams.username}
|
||||
defaultEmail={registerSearchParams.email}
|
||||
defaultRegisterToken={registerSearchParams.token}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</SupportedUIAFlowsLoader>
|
||||
<span data-spacing-node />
|
||||
{sso && <OrDivider />}
|
||||
</>
|
||||
<SupportedUIAFlowsLoader
|
||||
flows={registerFlows.data.flows ?? []}
|
||||
supportedStages={SUPPORTED_REGISTER_STAGES}
|
||||
>
|
||||
{(supportedFlows) =>
|
||||
supportedFlows.length === 0 ? (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
{t('Auth.register_unsupported')}
|
||||
</Text>
|
||||
) : (
|
||||
<PasswordRegisterForm
|
||||
authData={registerFlows.data}
|
||||
uiaFlows={supportedFlows}
|
||||
defaultUsername={registerSearchParams.username}
|
||||
defaultEmail={registerSearchParams.email}
|
||||
defaultRegisterToken={registerSearchParams.token}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</SupportedUIAFlowsLoader>
|
||||
)}
|
||||
{sso && (
|
||||
<>
|
||||
<SSOLogin
|
||||
providers={sso.identity_providers}
|
||||
redirectUrl={ssoRedirectUrl}
|
||||
action={SSOAction.REGISTER}
|
||||
saveScreenSpace={registerFlows.status === RegisterFlowStatus.FlowRequired}
|
||||
/>
|
||||
<span data-spacing-node />
|
||||
</>
|
||||
)}
|
||||
<Text align="Center">
|
||||
Already have an account? <Link to={getLoginPath(server)}>Login</Link>
|
||||
<Text align="Center" size="T300" style={{ color: 'rgba(232, 228, 223, 0.55)' }}>
|
||||
{t('Auth.already_have_account')}{' '}
|
||||
<Link to={getLoginPath(server)} style={{ fontWeight: 600 }}>
|
||||
{t('Auth.title_login')}
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
config,
|
||||
} from 'folds';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { AuthDict, AuthType, MatrixError, createClient } from 'matrix-js-sdk';
|
||||
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||
|
|
@ -36,6 +37,7 @@ type FormData = {
|
|||
};
|
||||
|
||||
function ResetPasswordComplete({ email }: { email?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const server = useAuthServer();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -56,11 +58,11 @@ function ResetPasswordComplete({ email }: { email?: string }) {
|
|||
<Dialog>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Text>
|
||||
Password has been reset successfully. Please login with your new password.
|
||||
{t('Auth.reset_success_message')}
|
||||
</Text>
|
||||
<Button variant="Primary" onClick={handleClick}>
|
||||
<Text size="B400" as="span">
|
||||
Login
|
||||
{t('Auth.reset_success_login')}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
|
|
@ -75,6 +77,7 @@ type PasswordResetFormProps = {
|
|||
defaultEmail?: string;
|
||||
};
|
||||
export function PasswordResetForm({ defaultEmail }: PasswordResetFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const server = useAuthServer();
|
||||
|
||||
const serverDiscovery = useAutoDiscoveryInfo();
|
||||
|
|
@ -167,11 +170,11 @@ export function PasswordResetForm({ defaultEmail }: PasswordResetFormProps) {
|
|||
return (
|
||||
<Box as="form" onSubmit={handleSubmit} direction="Inherit" gap="400">
|
||||
<Text size="T300" priority="400">
|
||||
Homeserver <strong>{server}</strong> will send you an email to let you reset your password.
|
||||
<Trans i18nKey="Auth.reset_description" values={{ server }} components={{ strong: <strong /> }} />
|
||||
</Text>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text as="label" size="L400" priority="300">
|
||||
Email
|
||||
{t('Auth.reset_email_label')}
|
||||
</Text>
|
||||
<Input
|
||||
defaultValue={defaultEmail}
|
||||
|
|
@ -193,7 +196,7 @@ export function PasswordResetForm({ defaultEmail }: PasswordResetFormProps) {
|
|||
<>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text as="label" size="L400" priority="300">
|
||||
New Password
|
||||
{t('Auth.reset_new_password')}
|
||||
</Text>
|
||||
<PasswordInput
|
||||
ref={passRef}
|
||||
|
|
@ -207,7 +210,7 @@ export function PasswordResetForm({ defaultEmail }: PasswordResetFormProps) {
|
|||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text as="label" size="L400" priority="300">
|
||||
Confirm Password
|
||||
{t('Auth.reset_confirm_password')}
|
||||
</Text>
|
||||
<PasswordInput
|
||||
ref={confPassRef}
|
||||
|
|
@ -226,14 +229,14 @@ export function PasswordResetForm({ defaultEmail }: PasswordResetFormProps) {
|
|||
{resetPasswordError && (
|
||||
<FieldError
|
||||
message={`${resetPasswordError.errcode}: ${
|
||||
resetPasswordError.data?.error ?? 'Failed to reset password.'
|
||||
resetPasswordError.data?.error ?? t('Auth.reset_error_fallback')
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<span data-spacing-node />
|
||||
<Button type="submit" variant="Primary" size="500">
|
||||
<Text as="span" size="B500">
|
||||
Reset Password
|
||||
{t('Auth.reset_button')}
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Box, Text } from 'folds';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLoginPath } from '../../pathUtils';
|
||||
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||
import { PasswordResetForm } from './PasswordResetForm';
|
||||
|
|
@ -17,20 +18,21 @@ const useResetPasswordSearchParams = (
|
|||
);
|
||||
|
||||
export function ResetPassword() {
|
||||
const { t } = useTranslation();
|
||||
const server = useAuthServer();
|
||||
const [searchParams] = useSearchParams();
|
||||
const resetPasswordSearchParams = useResetPasswordSearchParams(searchParams);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="500">
|
||||
<Text size="H2" priority="400">
|
||||
Reset Password
|
||||
</Text>
|
||||
<PasswordResetForm defaultEmail={resetPasswordSearchParams.email} />
|
||||
<span data-spacing-node />
|
||||
|
||||
<Text align="Center">
|
||||
Remember your password? <Link to={getLoginPath(server)}>Login</Link>
|
||||
<Text align="Center" size="T300" style={{ color: 'rgba(232, 228, 223, 0.55)' }}>
|
||||
{t('Auth.remember_password')}{' '}
|
||||
<Link to={getLoginPath(server)} style={{ fontWeight: 600 }}>
|
||||
{t('Auth.title_login')}
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,53 +1,267 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
import { style, globalStyle } from '@vanilla-extract/css';
|
||||
import { DefaultReset, toRem, color } from 'folds';
|
||||
|
||||
/*
|
||||
* CSS custom properties --vojo-* are managed manually (not via createVar)
|
||||
* because they're read/written from JS layout logic in AuthLayout.tsx.
|
||||
*/
|
||||
|
||||
/* ── Root page ── */
|
||||
export const AuthLayout = style({
|
||||
minHeight: '100%',
|
||||
backgroundColor: color.Background.Container,
|
||||
color: color.Background.OnContainer,
|
||||
padding: config.space.S400,
|
||||
paddingRight: config.space.S200,
|
||||
paddingBottom: 0,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const AuthCard = style({
|
||||
marginTop: '1vh',
|
||||
maxWidth: toRem(460),
|
||||
width: '100%',
|
||||
backgroundColor: color.Surface.Container,
|
||||
color: color.Surface.OnContainer,
|
||||
borderRadius: config.radii.R400,
|
||||
boxShadow: config.shadow.E100,
|
||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
height: '100dvh',
|
||||
minHeight: '100dvh',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
background:
|
||||
'radial-gradient(ellipse 80% 60% at 50% 25%, rgba(92, 70, 170, 0.18) 0%, rgba(92, 70, 170, 0) 70%), radial-gradient(ellipse at center, #171a33 0%, #10132a 45%, #080a1a 100%)',
|
||||
color: '#e8e4df',
|
||||
|
||||
'@media': {
|
||||
'only screen and (max-width: 768px)': {
|
||||
vars: {
|
||||
'--vojo-mascot-size': 'min(23rem, 43dvh, 88vw)',
|
||||
'--vojo-mascot-top': 'clamp(0.75rem, 2.5dvh, 1.5rem)',
|
||||
'--vojo-stack-pad': '0.75rem',
|
||||
'--vojo-anchor-ratio': '0.66',
|
||||
'--vojo-modal-min-top': 'clamp(10.5rem, 24dvh, 16rem)',
|
||||
'--vojo-modal-gap': '0.75rem',
|
||||
'--vojo-footer-space': '4.4rem',
|
||||
},
|
||||
},
|
||||
'only screen and (max-height: 760px)': {
|
||||
vars: {
|
||||
'--vojo-mascot-size': 'min(21rem, 34dvh, 80vw)',
|
||||
'--vojo-mascot-top': '0.75rem',
|
||||
'--vojo-anchor-ratio': '0.6',
|
||||
'--vojo-modal-min-top': '9rem',
|
||||
'--vojo-modal-gap': '0.4rem',
|
||||
'--vojo-footer-space': '4rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/* ── Stack (mascot + modal zone) ── */
|
||||
export const AuthStack = style({
|
||||
flex: '1 1 auto',
|
||||
minHeight: 0,
|
||||
position: 'relative',
|
||||
padding: '0 var(--vojo-stack-pad) var(--vojo-footer-space)',
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
/* ── Mascot ── */
|
||||
export const AuthMascot = style({
|
||||
position: 'absolute',
|
||||
insetInlineStart: '50%',
|
||||
top: 'var(--vojo-mascot-top)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 0,
|
||||
transform: 'translateX(-50%)',
|
||||
});
|
||||
|
||||
export const AuthMascotVideo = style({
|
||||
display: 'block',
|
||||
width: 'var(--vojo-mascot-size)',
|
||||
height: 'auto',
|
||||
aspectRatio: '1',
|
||||
maxWidth: 'calc(100vw - (2 * var(--vojo-stack-pad)))',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
filter: 'drop-shadow(0 30px 70px rgba(91, 70, 170, 0.45))',
|
||||
});
|
||||
|
||||
/* ── Modal zone (position driven by JS) ── */
|
||||
export const AuthModalZone = style({
|
||||
position: 'absolute',
|
||||
insetInline: 'var(--vojo-stack-pad)',
|
||||
top: 'var(--vojo-modal-top)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
/* ── Auth card (glassmorphism) ── */
|
||||
export const AuthCard = style({
|
||||
display: 'flex',
|
||||
borderRadius: toRem(24),
|
||||
backgroundColor: 'transparent',
|
||||
boxShadow: '0 24px 70px rgba(0, 0, 0, 0.5)',
|
||||
maxWidth: '100%',
|
||||
});
|
||||
|
||||
export const AuthCardContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 'inherit',
|
||||
backgroundColor: 'rgba(16, 18, 40, 0.48)',
|
||||
backdropFilter: 'blur(22px) saturate(150%)',
|
||||
WebkitBackdropFilter: 'blur(22px) saturate(150%)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
width: `min(${toRem(492)}, calc(100vw - 3rem))`,
|
||||
padding: `${toRem(42)} ${toRem(50)}`,
|
||||
boxSizing: 'border-box',
|
||||
gap: toRem(24),
|
||||
});
|
||||
|
||||
export const AuthCardContentConstrained = style({
|
||||
maxHeight: 'var(--vojo-modal-max-h)',
|
||||
overflowY: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
});
|
||||
|
||||
/* ── Server info row ── */
|
||||
export const ServerRow = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'auto min-content',
|
||||
gridTemplateRows: 'auto auto',
|
||||
gap: '4px',
|
||||
paddingBottom: toRem(16),
|
||||
marginBottom: toRem(4),
|
||||
borderBottom: '1px solid rgba(141, 151, 165, 0.2)',
|
||||
});
|
||||
|
||||
export const ServerLabel = style({
|
||||
gridColumn: '1 / -1',
|
||||
gridRow: '1',
|
||||
fontSize: toRem(13),
|
||||
color: 'rgba(232, 228, 223, 0.55)',
|
||||
});
|
||||
|
||||
export const ServerName = style({
|
||||
gridColumn: '1',
|
||||
gridRow: '2',
|
||||
fontSize: toRem(15),
|
||||
color: '#e8e4df',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
alignSelf: 'center',
|
||||
});
|
||||
|
||||
export const ServerEdit = style({
|
||||
gridColumn: '2',
|
||||
gridRow: '2',
|
||||
alignSelf: 'center',
|
||||
fontSize: toRem(13),
|
||||
color: '#e8e4df',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '2px',
|
||||
whiteSpace: 'nowrap',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
color: '#ffffff',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/* ── Server edit dialog ── */
|
||||
export const ServerDialog = style({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export const ServerDialogBackdrop = style({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
});
|
||||
|
||||
export const ServerDialogCard = style({
|
||||
position: 'relative',
|
||||
backgroundColor: '#1a1d36',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
borderRadius: toRem(16),
|
||||
padding: toRem(32),
|
||||
width: `min(${toRem(420)}, calc(100vw - 3rem))`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: toRem(20),
|
||||
color: '#e8e4df',
|
||||
boxShadow: '0 16px 48px rgba(0, 0, 0, 0.5)',
|
||||
});
|
||||
|
||||
/* ── Logo ── */
|
||||
export const AuthLogo = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: toRem(26),
|
||||
height: toRem(26),
|
||||
|
||||
borderRadius: '50%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const AuthHeader = style({
|
||||
padding: `0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
});
|
||||
|
||||
export const AuthCardContent = style({
|
||||
maxWidth: toRem(402),
|
||||
width: '100%',
|
||||
margin: 'auto',
|
||||
padding: config.space.S400,
|
||||
paddingTop: config.space.S700,
|
||||
paddingBottom: toRem(44),
|
||||
gap: toRem(44),
|
||||
});
|
||||
|
||||
/* ── Footer ── */
|
||||
export const AuthFooter = style({
|
||||
padding: config.space.S200,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
width: '100%',
|
||||
padding: '20px 24px 24px',
|
||||
fontSize: '13px',
|
||||
color: 'rgba(232, 228, 223, 0.55)',
|
||||
letterSpacing: '0.02em',
|
||||
});
|
||||
|
||||
export const AuthFooterSeparator = style({
|
||||
color: 'rgba(232, 228, 223, 0.3)',
|
||||
});
|
||||
|
||||
/* ── Theme overrides for folds components inside auth card ── */
|
||||
/* Extract CSS custom property name from folds color token reference */
|
||||
/* e.g. "var(--oq6d070)" → "--oq6d070" — tied to folds API, not hashed names */
|
||||
const v = (token: string) => token.replace(/^var\((.+)\)$/, '$1');
|
||||
|
||||
globalStyle(`${AuthCardContent}`, {
|
||||
vars: {
|
||||
/* Background variant: make inputs transparent dark */
|
||||
[v(color.Background.Container)]: 'rgba(255, 255, 255, 0.06)',
|
||||
[v(color.Background.ContainerHover)]: 'rgba(255, 255, 255, 0.10)',
|
||||
[v(color.Background.ContainerActive)]: 'rgba(255, 255, 255, 0.14)',
|
||||
[v(color.Background.ContainerLine)]: 'rgba(255, 255, 255, 0.18)',
|
||||
[v(color.Background.OnContainer)]: '#e8e4df',
|
||||
|
||||
/* Surface variant */
|
||||
[v(color.Surface.Container)]: 'rgba(255, 255, 255, 0.04)',
|
||||
[v(color.Surface.ContainerHover)]: 'rgba(255, 255, 255, 0.08)',
|
||||
[v(color.Surface.ContainerActive)]: 'rgba(255, 255, 255, 0.12)',
|
||||
[v(color.Surface.ContainerLine)]: 'rgba(255, 255, 255, 0.18)',
|
||||
[v(color.Surface.OnContainer)]: '#e8e4df',
|
||||
|
||||
/* Primary: white button with dark text */
|
||||
[v(color.Primary.Main)]: '#e8e4df',
|
||||
[v(color.Primary.MainHover)]: '#ffffff',
|
||||
[v(color.Primary.MainActive)]: '#d4d0cc',
|
||||
[v(color.Primary.MainLine)]: 'rgba(255, 255, 255, 0.18)',
|
||||
[v(color.Primary.OnMain)]: '#10132a',
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${AuthCardContent} input::placeholder`, {
|
||||
color: 'rgba(232, 228, 223, 0.35)',
|
||||
});
|
||||
|
||||
globalStyle(`${AuthCardContent} a`, {
|
||||
color: '#e8e4df',
|
||||
textDecoration: 'underline',
|
||||
textUnderlineOffset: '2px',
|
||||
});
|
||||
|
||||
globalStyle(`${AuthCardContent} a:hover`, {
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ export const ROOT_PATH = '/';
|
|||
export type LoginPathSearchParams = {
|
||||
username?: string;
|
||||
email?: string;
|
||||
loginToken?: string;
|
||||
};
|
||||
export const LOGIN_PATH = '/login/:server?/';
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue