Files
ihasvpn/web/src/pages/Login.tsx
T
jcoffey-dev 02e7993c87 Rename the project to ihasvpn
WGX shares its name with several other WireGuard tools, so the project
becomes ihasvpn, alongside ihasmail.

- Module github.com/Coffey-Labs/ihasvpn, command cmd/ihasvpn, image
  ghcr.io/coffey-labs/ihasvpn.
- Environment variables move from WGX_* to IHASVPN_*. The default database
  is ihasvpn.db, the nftables table is `ihasvpn`, metrics are ihasvpn_*, and
  the session cookie and theme key are renamed, so existing sessions end.
- The mark is the ihasmail cat peeking over the edge of a shield, drawn as
  a vector. docs/brand/generate.py builds the mark, mono mark, wordmarks,
  social card, favicons and app icons from that one drawing.
- The console takes ihasmail's palette: the ihasmail.org teal-navy for dark,
  its contrast-checked light tiers with the site's light accent, received
  traffic in the cat's orange and sent in teal. The wordmark weight and
  font stack follow ihasmail.org.
- Detail values wrap at spaces before breaking inside an address, so an
  IPv6 tunnel address no longer splits mid-number.
- The README history note about the earlier WGX installer is gone with the
  name it explained. Screenshots retaken.
2026-09-12 23:48:36 -07:00

74 lines
2.6 KiB
TypeScript

import { useState, type FormEvent } from "react";
import { api } from "../api";
import { errorMessage, useAuth } from "../state";
import { Field } from "../components/ui";
import { Mark } from "../components/Mark";
import { Legal } from "../components/Legal";
export function Login() {
const { refresh } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [stage, setStage] = useState<"password" | "totp">("password");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setError("");
setBusy(true);
try {
if (stage === "password") {
const r = await api.login(username, password);
if (r.totpRequired) {
setStage("totp");
return;
}
} else {
await api.loginTotp(code);
}
await refresh();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="auth">
<form className="card" onSubmit={submit}>
<div className="card-body">
<div className="brand">
<div className="brand-mark">
<Mark size={44} />
</div>
<div className="brand-name">ihasvpn</div>
</div>
<h1>{stage === "password" ? "Sign in" : "Second factor"}</h1>
{error && <div className="error">{error}</div>}
{stage === "password" ? (
<>
<Field label="Username">
<input className="input" autoFocus autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} required />
</Field>
<Field label="Password">
<input className="input" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</Field>
</>
) : (
<Field label="Authenticator code" hint="Or one of your recovery codes.">
<input className="input" autoFocus autoComplete="one-time-code" inputMode="numeric" value={code} onChange={(e) => setCode(e.target.value)} required />
</Field>
)}
<button className="btn primary" type="submit" disabled={busy} style={{ width: "100%", justifyContent: "center" }}>
{busy ? "…" : stage === "password" ? "Sign in" : "Verify"}
</button>
<Legal center />
</div>
</form>
</div>
);
}