feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { getApiBase, setToken } from "../lib/auth";
|
||||
import { loginErrorMessage } from "../lib/login-errors";
|
||||
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(await loginErrorMessage(response));
|
||||
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",
|
||||
"/setup",
|
||||
"/admin/backups",
|
||||
].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("");
|
||||
}}
|
||||
>
|
||||
Jellyfin
|
||||
</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 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user