229 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
}
|