app / frontend /src /components /views /AuthScreen.jsx
Aissugur Kurganbekov
Claude Opus 4.8
Tamga — evidence-backed customs document audit
373030b
Raw History Blame Contribute Delete
9.02 kB
import { useEffect, useRef, useState } from "react";
import { IconEye, IconEyeOff } from "../../lib/icons";
import Button from "../ui/Button";
import { ErrorState } from "../ui/States";
import Logo from "../Logo";
import AuthArt from "../AuthArt";
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// Renders the official Google Identity Services button when a client id is set;
// on success it hands the Google ID token to onCredential. Hidden otherwise.
function GoogleButton({ clientId, onCredential }) {
const ref = useRef(null);
const cbRef = useRef(onCredential);
cbRef.current = onCredential;
useEffect(() => {
if (!clientId) return undefined;
let cancelled = false;
function render() {
const gid = window.google?.accounts?.id;
if (cancelled || !gid || !ref.current) return;
gid.initialize({
client_id: clientId,
callback: (resp) => cbRef.current?.(resp.credential),
});
ref.current.innerHTML = "";
gid.renderButton(ref.current, {
type: "standard",
theme: "outline",
size: "large",
text: "continue_with",
width: 320,
});
}
if (window.google?.accounts?.id) {
render();
return () => {
cancelled = true;
};
}
let script = document.getElementById("gis-script");
if (!script) {
script = document.createElement("script");
script.id = "gis-script";
script.src = "https://accounts.google.com/gsi/client";
script.async = true;
document.body.appendChild(script);
}
script.addEventListener("load", render);
return () => {
cancelled = true;
script.removeEventListener("load", render);
};
}, [clientId]);
if (!clientId) return null;
return <div className="auth-google" ref={ref} />;
}
// Minimal, elegant auth: a quiet form panel (serif wordmark, email + password,
// Google sign-in, and a demo path) beside a soft art panel. Login methods are
// unchanged from before: email/password and "Continue with Google".
export default function AuthScreen({
onSignIn,
onSignUp,
onGoogle,
onDemo,
googleClientId,
loading = false,
serverError = "",
}) {
const [mode, setMode] = useState("signin"); // 'signin' | 'signup'
const [show, setShow] = useState(false);
const [values, setValues] = useState({ email: "", password: "", confirm: "" });
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const isSignup = mode === "signup";
function validate(v = values) {
const e = {};
if (!v.email) e.email = "Email is required.";
else if (!emailRe.test(v.email)) e.email = "Enter a valid email address.";
if (!v.password) e.password = "Password is required.";
else if (isSignup && v.password.length < 8) e.password = "Use at least 8 characters.";
if (isSignup && v.confirm !== v.password) e.confirm = "Passwords don’t match.";
return e;
}
function setField(name, val) {
const next = { ...values, [name]: val };
setValues(next);
if (touched[name]) setErrors(validate(next));
}
function switchMode(next) {
setMode(next);
setErrors({});
setTouched({});
}
async function onSubmit(ev) {
ev.preventDefault();
const e = validate();
setErrors(e);
setTouched({ email: true, password: true, confirm: true });
if (Object.keys(e).length) return;
const payload = { email: values.email.trim(), password: values.password };
if (isSignup) await onSignUp?.(payload);
else await onSignIn?.(payload);
}
const eid = (n) => `auth-${n}`;
const errId = (n) => (errors[n] ? `${eid(n)}-err` : undefined);
return (
<div className="authpage">
{/* ---- Form ---- */}
<main className="authpage-form">
<div className="authpage-inner">
<div className="authpage-brand">
<Logo size={30} />
<span className="authpage-mark">Tamga</span>
</div>
<p className="authpage-sub">
{isSignup
? "Create your account to start auditing packets."
: "Sign in to continue to your workspace."}
</p>
{serverError && (
<div style={{ marginBottom: 16 }}>
<ErrorState>{serverError}</ErrorState>
</div>
)}
<form className="authpage-fields" onSubmit={onSubmit} noValidate>
<label className="field" htmlFor={eid("email")}>
<span className="field-label">Email</span>
<input
id={eid("email")}
className="input"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@company.com"
value={values.email}
aria-invalid={errors.email ? true : undefined}
aria-describedby={errId("email")}
onBlur={() => {
setTouched((t) => ({ ...t, email: true }));
setErrors(validate());
}}
onChange={(e) => setField("email", e.target.value)}
/>
{errors.email && (
<span className="field-error" id={errId("email")} role="alert">
{errors.email}
</span>
)}
</label>
<label className="field" htmlFor={eid("password")}>
<span className="field-label">Password</span>
<span className="auth-input-wrap">
<input
id={eid("password")}
className="input"
type={show ? "text" : "password"}
autoComplete={isSignup ? "new-password" : "current-password"}
placeholder="••••••••"
value={values.password}
aria-invalid={errors.password ? true : undefined}
aria-describedby={errId("password")}
onBlur={() => {
setTouched((t) => ({ ...t, password: true }));
setErrors(validate());
}}
onChange={(e) => setField("password", e.target.value)}
/>
<button
type="button"
className="auth-eye"
aria-pressed={show}
aria-label={show ? "Hide password" : "Show password"}
onClick={() => setShow((s) => !s)}
>
{show ? <IconEyeOff size={18} /> : <IconEye size={18} />}
</button>
</span>
{errors.password && (
<span className="field-error" id={errId("password")} role="alert">
{errors.password}
</span>
)}
</label>
{isSignup && (
<label className="field" htmlFor={eid("confirm")}>
<span className="field-label">Confirm password</span>
<input
id={eid("confirm")}
className="input"
type={show ? "text" : "password"}
autoComplete="new-password"
placeholder="••••••••"
value={values.confirm}
aria-invalid={errors.confirm ? true : undefined}
aria-describedby={errId("confirm")}
onBlur={() => {
setTouched((t) => ({ ...t, confirm: true }));
setErrors(validate());
}}
onChange={(e) => setField("confirm", e.target.value)}
/>
{errors.confirm && (
<span className="field-error" id={errId("confirm")} role="alert">
{errors.confirm}
</span>
)}
</label>
)}
<Button type="submit" variant="primary" size="lg" block loading={loading}>
{isSignup ? "Create account" : "Sign in"}
</Button>
</form>
<GoogleButton clientId={googleClientId} onCredential={onGoogle} />
<div className="auth-divider">
<span>or</span>
</div>
<button
type="button"
className="auth-demo-link"
onClick={onDemo}
disabled={loading}
aria-busy={loading}
>
{loading ? "Starting demo…" : "Continue with the demo →"}
</button>
<p className="auth-switch">
{isSignup ? "Already have an account? " : "New to Tamga? "}
<button type="button" onClick={() => switchMode(isSignup ? "signin" : "signup")}>
{isSignup ? "Sign in" : "Create one"}
</button>
</p>
</div>
</main>
{/* ---- Art panel ---- */}
<aside className="authpage-art" aria-hidden="true">
<AuthArt />
</aside>
</div>
);
}