Files
ihasmail/web/src/views/Login.tsx
T
jcoffey-dev 4618f7656e Show the version on the sign-in page too
It was only on the About page, which is behind a sign-in -- so the one
place a version is most often wanted, when something is wrong and nobody
can get in, was the one place it could not be read.

It sits next to the AGPL source link deliberately. Section 13's offer is
for the source of *this* build, and naming the build is what turns that
into something a person can act on rather than a link to whatever main
happens to be. A bug report can name the build without signing in, too.

Worth being deliberate about: this is pre-authentication, so anyone who
can reach the instance can read it. That is a real disclosure -- it tells
an unauthenticated visitor exactly which build to look up. It is being
accepted rather than overlooked: ihasmail is AGPL with its source already
linked from that same line, so the version narrows nothing that reading
the source would not, and the sign-in page already names the software.
2026-08-26 10:16:59 -07:00

112 lines
5.4 KiB
TypeScript

import { useEffect, useState, type FormEvent } from "react";
import { Eye, EyeOff, LogIn, ShieldCheck } from "lucide-react";
import { useSession } from "@/store/session";
import { ApiError } from "@/jmap/client";
import { DEFAULT_SOURCE_URL } from "@/lib/source";
import { APP_VERSION } from "@/lib/version";
export function LoginPage() {
const login = useSession((s) => s.login);
// The AGPL's offer has to reach everyone who interacts with the app over the
// network, and that includes whoever is looking at this form. The server says
// where its own source lives, so a modified deployment points at its own.
const [sourceUrl, setSourceUrl] = useState(DEFAULT_SOURCE_URL);
useEffect(() => {
let live = true;
fetch("/api/config")
.then((r) => (r.ok ? r.json() : null))
.then((c) => { if (live && c?.sourceUrl) setSourceUrl(c.sourceUrl as string); })
.catch(() => { /* the default stands */ });
return () => { live = false; };
}, []);
const [username, setUsername] = useState(() => localStorage.getItem("ihasmail:lastUser") ?? "");
const [password, setPassword] = useState("");
const [totp, setTotp] = useState("");
const [showTotp, setShowTotp] = useState(false);
const [showPw, setShowPw] = useState(false);
const [remember, setRemember] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!username || !password) return;
setBusy(true);
setError(null);
try {
await login(username.trim(), password, totp.trim(), remember);
localStorage.setItem("ihasmail:lastUser", username.trim());
} catch (err) {
if (err instanceof ApiError) {
if (err.code === "invalid_credentials") {
setError(showTotp ? "Invalid credentials or verification code." : "Invalid username or password.");
if (!showTotp && password) setShowTotp(true);
} else if (err.code === "rate_limited") setError("Too many attempts. Please wait a few minutes and try again.");
else setError(err.message || "Could not sign in.");
} else setError("Network error. Please check your connection.");
} finally {
setBusy(false);
}
};
return (
<div className="login-page">
<form className="login-card" onSubmit={submit}>
<div className="logo">
<img src="/img/logo.png" alt="" width={120} height={143} />
<h1>ihasmail</h1>
<p className="tagline">Fast, friendly webmail. Your mailbox, your way.</p>
</div>
{error && (
<div className="error-box mb-16" role="alert">
{error}
</div>
)}
<div className="field">
<label htmlFor="u">Email or username</label>
<input id="u" className="input" type="text" autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus={!username} required />
</div>
<div className="field">
<label htmlFor="p">Password</label>
<div className="pw-wrap">
<input id="p" className="input" type={showPw ? "text" : "password"} autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus={Boolean(username)} required style={{ paddingRight: 40 }} />
<button type="button" className="icon-btn" onClick={() => setShowPw((v) => !v)} aria-label={showPw ? "Hide password" : "Show password"} tabIndex={-1}>
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
{showTotp ? (
<div className="field">
<label htmlFor="t">Two-factor code</label>
<input id="t" className="input" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" value={totp} onChange={(e) => setTotp(e.target.value)} autoFocus />
<span className="hint">Enter the code from your authenticator app if your account uses 2FA.</span>
</div>
) : (
<button type="button" className="btn btn-ghost btn-sm" style={{ marginBottom: 12, color: "var(--fg-muted)" }} onClick={() => setShowTotp(true)}>
<ShieldCheck size={16} /> I have a two-factor code
</button>
)}
<label className="check" style={{ marginBottom: 12 }}>
<input type="checkbox" checked={remember} onChange={(e) => setRemember(e.target.checked)} />
<span>Keep me signed in on this device</span>
</label>
<button className="btn btn-primary btn-lg btn-block" type="submit" disabled={busy}>
{busy ? <span className="spinner" style={{ borderTopColor: "#fff" }} /> : <LogIn size={18} />}
{busy ? "Signing in…" : "Sign in"}
</button>
<p className="foot">
{/*
The version sits next to the source link on purpose: the AGPL's
offer is for the source of *this* build, and a version makes that
offer something a person can actually act on. It also means a bug
report names the build without anyone having to sign in to find it.
*/}
ihasmail v{APP_VERSION} by <a href="https://linuxexpert.org" target="_blank" rel="noopener noreferrer">linuxexpert.org</a>
{" · "}
<a href={sourceUrl} target="_blank" rel="noopener noreferrer">AGPL-3.0 source</a>
</p>
</form>
</div>
);
}