111 lines
6.8 KiB
TypeScript
111 lines
6.8 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState, type FormEvent } from 'react'
|
|
import { getApiBase, setToken } from '../lib/auth'
|
|
import MagentMark from '../ui/MagentMark'
|
|
|
|
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 [banner, setBanner] = useState<{ message: string; tone: string } | null>(null)
|
|
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,
|
|
})
|
|
if (data?.banner?.enabled && typeof data.banner.message === 'string' && data.banner.message.trim().toLowerCase() !== 'beta environment') {
|
|
setBanner({ message: data.banner.message, tone: data.banner.tone || 'info' })
|
|
}
|
|
} 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')
|
|
window.location.assign('/')
|
|
} catch {
|
|
setError('Could not reach Magent. Check your connection and try again.')
|
|
} finally { setLoading(false) }
|
|
}
|
|
|
|
return (
|
|
<main className="login-page">
|
|
<section className="login-card" aria-labelledby="login-title">
|
|
<div className="login-brand"><a href="/login" aria-label="Magent sign in"><MagentMark /><span>Magent</span></a><span className="login-beta">Beta</span></div>
|
|
<header><h1 id="login-title">Welcome back.</h1><p>Sign in to your media workspace.</p></header>
|
|
{banner && <p className={`account-notice ${['error', 'maintenance'].includes(banner.tone) ? 'is-error' : 'is-status'}`} role="status">{banner.message}</p>}
|
|
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && <div className="login-methods" role="group" 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>
|
|
</div>}
|
|
{!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>
|
|
)}
|
|
{optionsReady && options.showSignupLink && <footer>Have an invite? <a href="/signup">Create an account <span aria-hidden="true">↗</span></a></footer>}
|
|
</section>
|
|
<p className="login-credit">Grizzlyflix · Request. Watch. Enjoy.</p>
|
|
</main>
|
|
)
|
|
}
|