chore: standardize security and quality foundations
This commit is contained in:
+201
-81
@@ -1,108 +1,228 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { getApiBase, setToken } from '../lib/auth'
|
||||
import AuthLayout from '../ui/AuthLayout'
|
||||
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 }
|
||||
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'
|
||||
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 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
|
||||
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() : '')
|
||||
});
|
||||
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)
|
||||
if (!controller.signal.aborted) setOptionsReady(true);
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
};
|
||||
void load();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (loading || !canSignIn || !optionsReady) return
|
||||
setError('')
|
||||
setLoading(true)
|
||||
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',
|
||||
})
|
||||
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
|
||||
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')
|
||||
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) }
|
||||
}
|
||||
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
|
||||
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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user