224 lines
6.9 KiB
TypeScript
224 lines
6.9 KiB
TypeScript
'use client'
|
|
|
|
import { Suspense, useEffect, useMemo, useState } from 'react'
|
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
import BrandingLogo from '../ui/BrandingLogo'
|
|
import { clearToken, getApiBase, setToken } from '../lib/auth'
|
|
|
|
type InviteInfo = {
|
|
code: string
|
|
label?: string | null
|
|
description?: string | null
|
|
enabled: boolean
|
|
is_expired?: boolean
|
|
is_usable?: boolean
|
|
expires_at?: string | null
|
|
max_uses?: number | null
|
|
use_count?: number | null
|
|
remaining_uses?: number | null
|
|
profile?: {
|
|
id: number
|
|
name: string
|
|
description?: string | null
|
|
} | null
|
|
}
|
|
|
|
const formatDate = (value?: string | null) => {
|
|
if (!value) return 'Never'
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.valueOf())) return value
|
|
return date.toLocaleString()
|
|
}
|
|
|
|
function SignupPageContent() {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const [inviteCode, setInviteCode] = useState(searchParams.get('code') ?? '')
|
|
const [invite, setInvite] = useState<InviteInfo | null>(null)
|
|
const [inviteLoading, setInviteLoading] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [username, setUsername] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [confirmPassword, setConfirmPassword] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [status, setStatus] = useState<string | null>(null)
|
|
|
|
const canSubmit = useMemo(() => {
|
|
return Boolean(invite?.is_usable && username.trim() && password && !loading)
|
|
}, [invite, username, password, loading])
|
|
|
|
const lookupInvite = async (code: string) => {
|
|
const trimmed = code.trim()
|
|
if (!trimmed) {
|
|
setInvite(null)
|
|
return
|
|
}
|
|
setInviteLoading(true)
|
|
setError(null)
|
|
setStatus(null)
|
|
try {
|
|
const baseUrl = getApiBase()
|
|
const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`)
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
throw new Error(text || 'Invite not found')
|
|
}
|
|
const data = await response.json()
|
|
setInvite(data?.invite ?? null)
|
|
setStatus('Invite loaded.')
|
|
} catch (err) {
|
|
console.error(err)
|
|
setInvite(null)
|
|
setError('Invite code not found or unavailable.')
|
|
} finally {
|
|
setInviteLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
const initialCode = searchParams.get('code') ?? ''
|
|
if (initialCode) {
|
|
setInviteCode(initialCode)
|
|
void lookupInvite(initialCode)
|
|
}
|
|
}, [searchParams])
|
|
|
|
const submit = async (event: React.FormEvent) => {
|
|
event.preventDefault()
|
|
if (password !== confirmPassword) {
|
|
setError('Passwords do not match.')
|
|
return
|
|
}
|
|
if (!inviteCode.trim()) {
|
|
setError('Invite code is required.')
|
|
return
|
|
}
|
|
if (!invite?.is_usable) {
|
|
setError('Invite is not usable. Refresh invite details or ask an admin for a new code.')
|
|
return
|
|
}
|
|
setLoading(true)
|
|
setError(null)
|
|
setStatus(null)
|
|
try {
|
|
clearToken()
|
|
const baseUrl = getApiBase()
|
|
const response = await fetch(`${baseUrl}/auth/signup`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
invite_code: inviteCode,
|
|
username: username.trim(),
|
|
password,
|
|
}),
|
|
})
|
|
if (!response.ok) {
|
|
const text = await response.text()
|
|
throw new Error(text || 'Sign-up failed')
|
|
}
|
|
const data = await response.json()
|
|
if (data?.access_token) {
|
|
setToken(data.access_token)
|
|
window.location.href = '/'
|
|
return
|
|
}
|
|
throw new Error('Sign-up did not return a token')
|
|
} catch (err) {
|
|
console.error(err)
|
|
setError(err instanceof Error ? err.message : 'Unable to create account.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className="card auth-card">
|
|
<BrandingLogo className="brand-logo brand-logo--login" />
|
|
<h1>Create account</h1>
|
|
<p className="lede">Use an invite code from your admin to create your Jellyfin-backed Magent account.</p>
|
|
<form onSubmit={submit} className="auth-form">
|
|
<label>
|
|
Invite code
|
|
<div className="invite-lookup-row">
|
|
<input
|
|
value={inviteCode}
|
|
onChange={(e) => setInviteCode(e.target.value)}
|
|
placeholder="Paste your invite code"
|
|
autoCapitalize="characters"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
disabled={inviteLoading}
|
|
onClick={() => void lookupInvite(inviteCode)}
|
|
>
|
|
{inviteLoading ? 'Checking…' : 'Check invite'}
|
|
</button>
|
|
</div>
|
|
</label>
|
|
{invite && (
|
|
<div className={`invite-summary ${invite.is_usable ? '' : 'is-disabled'}`}>
|
|
<div className="invite-summary-row">
|
|
<strong>{invite.label || invite.code}</strong>
|
|
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
|
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
|
</span>
|
|
</div>
|
|
{invite.description && <p>{invite.description}</p>}
|
|
<div className="admin-meta-row">
|
|
<span>Code: {invite.code}</span>
|
|
<span>Expires: {formatDate(invite.expires_at)}</span>
|
|
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span>
|
|
<span>Profile: {invite.profile?.name || 'None'}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<label>
|
|
Username
|
|
<input
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
autoComplete="username"
|
|
/>
|
|
</label>
|
|
<label>
|
|
Password
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
autoComplete="new-password"
|
|
/>
|
|
</label>
|
|
<label>
|
|
Confirm password
|
|
<input
|
|
type="password"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
autoComplete="new-password"
|
|
/>
|
|
</label>
|
|
{error && <div className="error-banner">{error}</div>}
|
|
{status && <div className="status-banner">{status}</div>}
|
|
<div className="auth-actions">
|
|
<button type="submit" disabled={!canSubmit}>
|
|
{loading ? 'Creating account…' : 'Create account (Jellyfin + Magent)'}
|
|
</button>
|
|
</div>
|
|
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
|
|
Back to sign in
|
|
</button>
|
|
</form>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
export default function SignupPage() {
|
|
return (
|
|
<Suspense fallback={<main className="card auth-card">Loading sign-up…</main>}>
|
|
<SignupPageContent />
|
|
</Suspense>
|
|
)
|
|
}
|