Files
Magent/frontend/app/login/page.tsx
T
Assclaw f852e7c941
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped
chore: standardize security and quality foundations
2026-09-17 20:03:47 +12:00

229 lines
7.9 KiB
TypeScript

"use client";
import { useEffect, useState, type FormEvent } from "react";
import { getApiBase, setToken } from "../lib/auth";
import AuthLayout from "../ui/AuthLayout";
type LoginMode = "jellyfin" | "local";
type LoginOptions = {
showJellyfinLogin: boolean;
showLocalLogin: boolean;
showForgotPassword: boolean;
showSignupLink: boolean;
};
const DEFAULT_OPTIONS: LoginOptions = {
showJellyfinLogin: true,
showLocalLogin: true,
showForgotPassword: true,
showSignupLink: true,
};
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [mode, setMode] = useState<LoginMode>("jellyfin");
const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS);
const [optionsReady, setOptionsReady] = useState(false);
const [loginMessage, setLoginMessage] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const canSignIn = options.showJellyfinLogin || options.showLocalLogin;
const selectedMode: LoginMode =
mode === "jellyfin" && options.showJellyfinLogin ? "jellyfin" : options.showLocalLogin ? "local" : "jellyfin";
useEffect(() => {
const controller = new AbortController();
const load = async () => {
try {
const response = await fetch(`${getApiBase()}/site/public`, { signal: controller.signal });
if (!response.ok) throw new Error("Options unavailable");
const data = await response.json();
if (controller.signal.aborted) return;
setOptions({
showJellyfinLogin: data?.login?.showJellyfinLogin !== false,
showLocalLogin: data?.login?.showLocalLogin !== false,
showForgotPassword: data?.login?.showForgotPassword !== false,
showSignupLink: data?.login?.showSignupLink !== false,
});
setLoginMessage(typeof data?.login?.message === "string" ? data.login.message.trim() : "");
} catch {
// Keep the normal sign-in methods available during a settings outage.
} finally {
if (!controller.signal.aborted) setOptionsReady(true);
}
};
void load();
return () => controller.abort();
}, []);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (loading || !canSignIn || !optionsReady) return;
setError("");
setLoading(true);
try {
const response = await fetch(
`${getApiBase()}${selectedMode === "jellyfin" ? "/auth/jellyfin/login" : "/auth/login"}`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username: username.trim(), password }),
credentials: "include",
},
);
if (!response.ok) {
setError(
response.status === 429
? "Too many attempts. Please wait a moment and try again."
: response.status >= 500
? "Sign-in is temporarily unavailable. Please try again shortly."
: response.status === 403
? "This account cannot sign in. Please contact an administrator."
: "Check your username and password, then try again.",
);
return;
}
const data = await response.json();
if (!data?.authenticated) {
setError("Could not sign in. Please try again.");
return;
}
setToken("cookie");
const next = new URLSearchParams(window.location.search).get("next") || "";
const allowedNext =
[
"/insights",
"/insights/reports",
"/profile",
"/profile#monthly-recaps",
"/profile#newsletters",
"/admin/recaps",
"/admin/newsletters",
].includes(next) ||
/^\/insights\/reports\?month=[0-9]{4}-(?:0[1-9]|1[0-2])$/.test(next) ||
/^\/issues\/confirm\/\d+$/.test(next);
window.location.assign(allowedNext ? next : "/welcome");
} catch {
setError("Could not reach Magent. Check your connection and try again.");
} finally {
setLoading(false);
}
};
return (
<AuthLayout
title="Welcome back."
description="Sign in to your media workspace."
footer={
optionsReady &&
options.showSignupLink && (
<>
Have an invite?{" "}
<a href="/signup">
Create an account <span aria-hidden="true"></span>
</a>
</>
)
}
>
{loginMessage && (
<p className="account-notice account-login-message" role="status">
{loginMessage}
</p>
)}
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && (
<fieldset className="login-methods" aria-label="Sign-in account">
<button
type="button"
aria-pressed={selectedMode === "jellyfin"}
disabled={loading}
onClick={() => {
setMode("jellyfin");
setError("");
}}
>
Grizzlyflix
</button>
<button
type="button"
aria-pressed={selectedMode === "local"}
disabled={loading}
onClick={() => {
setMode("local");
setError("");
}}
>
Magent
</button>
</fieldset>
)}
{!optionsReady ? (
<p className="account-hint" role="status">
Loading sign-in
</p>
) : !canSignIn ? (
<p className="account-notice is-error" role="alert">
Sign-in is currently unavailable. Please contact an administrator.
</p>
) : (
<form className="account-form login-form" onSubmit={submit}>
<p className="login-method-help">
{selectedMode === "jellyfin" ? "Use your Grizzlyflix / Jellyfin account." : "Use your Magent account."}
</p>
<label htmlFor="login-username">Username</label>
<input
id="login-username"
name="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
required
disabled={loading}
/>
<div className="login-password-label">
<label htmlFor="login-password">Password</label>
{options.showForgotPassword && <a href="/forgot-password">Forgot password?</a>}
</div>
<div className="login-password-field">
<input
id="login-password"
name="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
disabled={loading}
/>
<button
type="button"
className="password-visibility"
aria-label={showPassword ? "Hide password" : "Show password"}
aria-pressed={showPassword}
onClick={() => setShowPassword(!showPassword)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
{showPassword && <path d="m3 3 18 18" />}
</svg>
</button>
</div>
{error && (
<p className="account-notice is-error" role="alert">
{error}
</p>
)}
<button type="submit" className="account-primary login-submit" disabled={loading}>
{loading ? "Signing in…" : "Sign in"}
<span aria-hidden="true"></span>
</button>
</form>
)}
</AuthLayout>
);
}