glorifying login page
This commit is contained in:
parent
e240bfbbfa
commit
2584380a80
5 changed files with 133 additions and 35 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,8 +23,8 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/index-BdqgFh6n.js"></script>
|
<script type="module" crossorigin src="/assets/index-BDeuKnDM.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-pvHlBxfG.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CpFuZW_m.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
|
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { AppBackground } from "../components/AppBackground";
|
import { AppBackground } from "../components/AppBackground";
|
||||||
import { Alert } from "../components/ui";
|
import { Alert } from "../components/ui";
|
||||||
|
|
@ -7,13 +8,17 @@ import { ThemeSwitch } from "../theme";
|
||||||
import type { AdminSession, PublicBranding } from "../types";
|
import type { AdminSession, PublicBranding } from "../types";
|
||||||
|
|
||||||
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
|
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
|
||||||
// Deliberately not pre-filled: the built-in operator is a default name, not a
|
|
||||||
// default identity, and typing it is the difference between choosing to use
|
|
||||||
// it and drifting into it. The server rejects a blank username either way.
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [secret, setSecret] = useState("");
|
const [secret, setSecret] = useState("");
|
||||||
|
// Which field is showing. The password field mounts only on step 1, so
|
||||||
|
// there is never a submit handler wired to a "confirm" or "log in" button
|
||||||
|
// that could fire with a still-empty password field -- credentials only
|
||||||
|
// ever reach api.login once both are on screen and this has advanced.
|
||||||
|
const [step, setStep] = useState<0 | 1>(0);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const usernameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const passwordRef = useRef<HTMLInputElement>(null);
|
||||||
// Whose server this is. Fetched without a session -- the name and icon are
|
// Whose server this is. Fetched without a session -- the name and icon are
|
||||||
// already public from owpengram-server's client endpoints -- and left null on
|
// already public from owpengram-server's client endpoints -- and left null on
|
||||||
// failure so the panel simply keeps its own branding.
|
// failure so the panel simply keeps its own branding.
|
||||||
|
|
@ -24,11 +29,42 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
|
||||||
api.publicBranding().then(setBranding).catch(() => undefined);
|
api.publicBranding().then(setBranding).catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// preventScroll matters here: .login-wizard clips with overflow:hidden and
|
||||||
|
// is itself a scroll container, so a plain .focus() makes the browser
|
||||||
|
// scroll it to reveal the field -- fighting the translateX slide and
|
||||||
|
// leaving a stray scrollLeft behind that then desyncs every later step
|
||||||
|
// change from what the transform shows.
|
||||||
|
if (step === 1) {
|
||||||
|
passwordRef.current?.focus({ preventScroll: true });
|
||||||
|
} else {
|
||||||
|
usernameRef.current?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
const serverName = branding?.name?.trim() || "OwpenGram";
|
const serverName = branding?.name?.trim() || "OwpenGram";
|
||||||
const iconSrc = branding?.has_icon && !iconFailed ? api.publicIconURL() : "/logo.png";
|
const iconSrc = branding?.has_icon && !iconFailed ? api.publicIconURL() : "/logo.png";
|
||||||
|
|
||||||
|
function goToPassword() {
|
||||||
|
if (!username.trim()) return;
|
||||||
|
setError("");
|
||||||
|
setStep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToUsername() {
|
||||||
|
setError("");
|
||||||
|
setStep(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The form has one submit handler regardless of step, because Enter inside
|
||||||
|
// any of its text inputs fires it -- routing that here means the username
|
||||||
|
// field's Enter key advances instead of submitting a login with no password.
|
||||||
async function submit(event: FormEvent) {
|
async function submit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (step === 0) {
|
||||||
|
goToPassword();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
|
@ -79,33 +115,56 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
|
||||||
</div>
|
</div>
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
<form className="form-stack" onSubmit={submit}>
|
<form className="form-stack" onSubmit={submit}>
|
||||||
<label>
|
<div className="login-wizard">
|
||||||
<span>{"Username"}</span>
|
<div className="login-wizard-track" style={{ transform: `translateX(-${step * 100}%)` }}>
|
||||||
<input
|
<div className="login-wizard-step" aria-hidden={step !== 0}>
|
||||||
autoFocus
|
<label>
|
||||||
type="text"
|
<span>{"Username"}</span>
|
||||||
value={username}
|
<input
|
||||||
autoComplete="username"
|
ref={usernameRef}
|
||||||
spellCheck={false}
|
type="text"
|
||||||
autoCapitalize="none"
|
value={username}
|
||||||
// A hint, not a value: it names the built-in operator without
|
autoComplete="username"
|
||||||
// filling the field in, so signing in as it stays a deliberate act.
|
spellCheck={false}
|
||||||
placeholder={"owpengram"}
|
autoCapitalize="none"
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
placeholder={"login"}
|
||||||
/>
|
tabIndex={step === 0 ? undefined : -1}
|
||||||
</label>
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
<label>
|
/>
|
||||||
<span>{"Password"}</span>
|
</label>
|
||||||
<input
|
</div>
|
||||||
type="password"
|
<div className="login-wizard-step" aria-hidden={step !== 1}>
|
||||||
value={secret}
|
<label>
|
||||||
autoComplete="current-password"
|
<span>{"Password"}</span>
|
||||||
onChange={(event) => setSecret(event.target.value)}
|
<input
|
||||||
/>
|
ref={passwordRef}
|
||||||
</label>
|
type="password"
|
||||||
<button className="btn primary full" type="submit" disabled={busy}>
|
value={secret}
|
||||||
{busy ? "Logging in" : "Log in"}
|
autoComplete="current-password"
|
||||||
</button>
|
placeholder={"password"}
|
||||||
|
tabIndex={step === 1 ? undefined : -1}
|
||||||
|
onChange={(event) => setSecret(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{step === 0 ? (
|
||||||
|
<button className="btn primary full" type="submit" disabled={!username.trim()}>
|
||||||
|
{"Next"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="login-wizard-actions">
|
||||||
|
<button className="btn icon-text" type="button" onClick={goToUsername}>
|
||||||
|
<ArrowLeft size={15} />
|
||||||
|
{"Back"}
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" type="submit" disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="spin" size={15} /> : null}
|
||||||
|
{busy ? "Logging in" : "Log in"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -413,6 +413,45 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Login form: username and password are two panels of equal width, slid
|
||||||
|
horizontally by translateX on the track rather than shown/hidden, so
|
||||||
|
moving between them reads as one continuous field instead of a page
|
||||||
|
swap. The clipping .login-wizard is what makes the off-screen step
|
||||||
|
invisible; aria-hidden + tabIndex={-1} in the component keep it out of
|
||||||
|
the tab order and screen readers while it's off-screen. */
|
||||||
|
.login-wizard {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wizard-track {
|
||||||
|
display: flex;
|
||||||
|
transition: transform 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wizard-step {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Back sits beside Log in rather than squeezed against the "Password"
|
||||||
|
label -- as a normal-height text+icon button it has a real click target
|
||||||
|
and room to breathe, instead of a 22px icon crowding the label above a
|
||||||
|
400px-wide field. */
|
||||||
|
.login-wizard-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-wizard-actions .btn.primary {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.login-wizard-track {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.boot-screen {
|
.boot-screen {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue