Sync dev changes into release-1.0
This commit is contained in:
@@ -11,6 +11,12 @@ const ALLOWED_SECTIONS = new Set([
|
||||
'qbittorrent',
|
||||
'requests',
|
||||
'cache',
|
||||
'invites',
|
||||
'password',
|
||||
'captcha',
|
||||
'smtp',
|
||||
'notifications',
|
||||
'expiry',
|
||||
'logs',
|
||||
'maintenance',
|
||||
])
|
||||
|
||||
@@ -86,6 +86,9 @@ export default function LoginPage() {
|
||||
Sign in with Magent account
|
||||
</button>
|
||||
</form>
|
||||
<div className="status-banner">
|
||||
Have an invite? <a href="/register">Create an account</a>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,12 +10,25 @@ type ProfileInfo = {
|
||||
auth_provider: string
|
||||
}
|
||||
|
||||
type ContactInfo = {
|
||||
email?: string | null
|
||||
discord?: string | null
|
||||
telegram?: string | null
|
||||
matrix?: string | null
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter()
|
||||
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
||||
const [contact, setContact] = useState<ContactInfo>({})
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [contactStatus, setContactStatus] = useState<string | null>(null)
|
||||
const [referrals, setReferrals] = useState<
|
||||
{ code: string; uses_count?: number; max_uses?: number | null }[]
|
||||
>([])
|
||||
const [referralStatus, setReferralStatus] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,6 +51,23 @@ export default function ProfilePage() {
|
||||
role: data?.role ?? 'user',
|
||||
auth_provider: data?.auth_provider ?? 'local',
|
||||
})
|
||||
const contactResponse = await authFetch(`${baseUrl}/auth/contact`)
|
||||
if (contactResponse.ok) {
|
||||
const contactData = await contactResponse.json()
|
||||
setContact({
|
||||
email: contactData?.contact?.email ?? '',
|
||||
discord: contactData?.contact?.discord ?? '',
|
||||
telegram: contactData?.contact?.telegram ?? '',
|
||||
matrix: contactData?.contact?.matrix ?? '',
|
||||
})
|
||||
}
|
||||
const referralResponse = await authFetch(`${baseUrl}/auth/referrals`)
|
||||
if (referralResponse.ok) {
|
||||
const referralData = await referralResponse.json()
|
||||
if (Array.isArray(referralData?.invites)) {
|
||||
setReferrals(referralData.invites)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus('Could not load your profile.')
|
||||
@@ -78,6 +108,55 @@ export default function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const submitContact = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setContactStatus(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/contact`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: contact.email,
|
||||
discord: contact.discord,
|
||||
telegram: contact.telegram,
|
||||
matrix: contact.matrix,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Update failed')
|
||||
}
|
||||
setContactStatus('Contact details saved.')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setContactStatus('Could not update contact details.')
|
||||
}
|
||||
}
|
||||
|
||||
const createReferral = async () => {
|
||||
setReferralStatus(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/referrals`, { method: 'POST' })
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Could not create referral invite')
|
||||
}
|
||||
const data = await response.json()
|
||||
if (data?.code) {
|
||||
setReferrals((current) => [
|
||||
{ code: data.code, uses_count: 0, max_uses: 1 },
|
||||
...current,
|
||||
])
|
||||
}
|
||||
setReferralStatus('Referral invite created.')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setReferralStatus('Could not create a referral invite.')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <main className="card">Loading profile...</main>
|
||||
}
|
||||
@@ -121,6 +200,82 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<form onSubmit={submitContact} className="auth-form">
|
||||
<h2>Contact details</h2>
|
||||
<label>
|
||||
Email address
|
||||
<input
|
||||
type="email"
|
||||
value={contact.email || ''}
|
||||
onChange={(event) => setContact((current) => ({ ...current, email: event.target.value }))}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Discord handle
|
||||
<input
|
||||
type="text"
|
||||
value={contact.discord || ''}
|
||||
onChange={(event) =>
|
||||
setContact((current) => ({ ...current, discord: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Telegram ID
|
||||
<input
|
||||
type="text"
|
||||
value={contact.telegram || ''}
|
||||
onChange={(event) =>
|
||||
setContact((current) => ({ ...current, telegram: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Matrix ID
|
||||
<input
|
||||
type="text"
|
||||
value={contact.matrix || ''}
|
||||
onChange={(event) =>
|
||||
setContact((current) => ({ ...current, matrix: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{contactStatus && <div className="status-banner">{contactStatus}</div>}
|
||||
<div className="auth-actions">
|
||||
<button type="submit">Save contact details</button>
|
||||
</div>
|
||||
</form>
|
||||
<section className="summary-card">
|
||||
<h2>Referral invites</h2>
|
||||
<p className="meta">
|
||||
Share a referral invite with friends or family. Each invite has limited uses.
|
||||
</p>
|
||||
{referralStatus && <div className="status-banner">{referralStatus}</div>}
|
||||
<div className="auth-actions">
|
||||
<button type="button" onClick={createReferral}>
|
||||
Create referral invite
|
||||
</button>
|
||||
</div>
|
||||
{referrals.length === 0 ? (
|
||||
<div className="meta">No referral invites yet.</div>
|
||||
) : (
|
||||
<div className="cache-table">
|
||||
<div className="cache-row cache-head">
|
||||
<span>Code</span>
|
||||
<span>Uses</span>
|
||||
</div>
|
||||
{referrals.map((invite) => (
|
||||
<div key={invite.code} className="cache-row">
|
||||
<span>{invite.code}</span>
|
||||
<span>
|
||||
{invite.uses_count ?? 0}/{invite.max_uses ?? '∞'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
194
frontend/app/register/page.tsx
Normal file
194
frontend/app/register/page.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, getApiBase, setToken } from '../lib/auth'
|
||||
import BrandingLogo from '../ui/BrandingLogo'
|
||||
|
||||
type SignupConfig = {
|
||||
invites_enabled: boolean
|
||||
captcha_provider: string
|
||||
hcaptcha_site_key?: string | null
|
||||
recaptcha_site_key?: string | null
|
||||
turnstile_site_key?: string | null
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter()
|
||||
const [config, setConfig] = useState<SignupConfig | null>(null)
|
||||
const [inviteCode, setInviteCode] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [discord, setDiscord] = useState('')
|
||||
const [telegram, setTelegram] = useState('')
|
||||
const [matrix, setMatrix] = useState('')
|
||||
const [captchaToken, setCaptchaToken] = useState('')
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/signup/config`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Signup unavailable')
|
||||
}
|
||||
const data = await response.json()
|
||||
setConfig(data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus('Sign-up is not available right now.')
|
||||
}
|
||||
}
|
||||
void load()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.captcha_provider || config.captcha_provider === 'none') {
|
||||
return
|
||||
}
|
||||
const provider = config.captcha_provider
|
||||
const script = document.createElement('script')
|
||||
if (provider === 'hcaptcha') {
|
||||
script.src = 'https://js.hcaptcha.com/1/api.js'
|
||||
} else if (provider === 'recaptcha') {
|
||||
script.src = 'https://www.google.com/recaptcha/api.js'
|
||||
} else if (provider === 'turnstile') {
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||||
}
|
||||
script.async = true
|
||||
script.defer = true
|
||||
document.body.appendChild(script)
|
||||
;(window as any).magentCaptchaCallback = (token: string) => {
|
||||
setCaptchaToken(token)
|
||||
}
|
||||
return () => {
|
||||
document.body.removeChild(script)
|
||||
}
|
||||
}, [config?.captcha_provider])
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setStatus(null)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
invite_code: inviteCode,
|
||||
username,
|
||||
password,
|
||||
contact: { email, discord, telegram, matrix },
|
||||
captcha_token: captchaToken,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Registration failed')
|
||||
}
|
||||
const data = await response.json()
|
||||
if (data?.access_token) {
|
||||
setToken(data.access_token)
|
||||
}
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
const message =
|
||||
err instanceof Error && err.message
|
||||
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
|
||||
: 'Could not complete sign-up.'
|
||||
setStatus(message)
|
||||
}
|
||||
}
|
||||
|
||||
const captchaProvider = config?.captcha_provider || 'none'
|
||||
const captchaKey =
|
||||
captchaProvider === 'hcaptcha'
|
||||
? config?.hcaptcha_site_key
|
||||
: captchaProvider === 'recaptcha'
|
||||
? config?.recaptcha_site_key
|
||||
: config?.turnstile_site_key
|
||||
|
||||
return (
|
||||
<main className="card auth-card">
|
||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||
<h1>Create your account</h1>
|
||||
{!config?.invites_enabled ? (
|
||||
<div className="status-banner">Sign-ups are currently closed.</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="auth-form">
|
||||
<label>
|
||||
Invite code
|
||||
<input
|
||||
type="text"
|
||||
value={inviteCode}
|
||||
onChange={(event) => setInviteCode(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Email address (optional)
|
||||
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Discord handle (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={discord}
|
||||
onChange={(event) => setDiscord(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Telegram ID (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={telegram}
|
||||
onChange={(event) => setTelegram(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Matrix ID (optional)
|
||||
<input type="text" value={matrix} onChange={(event) => setMatrix(event.target.value)} />
|
||||
</label>
|
||||
{captchaProvider !== 'none' && captchaKey ? (
|
||||
<div className="captcha-wrap">
|
||||
{captchaProvider === 'hcaptcha' && (
|
||||
<div className="h-captcha" data-sitekey={captchaKey} data-callback="magentCaptchaCallback" />
|
||||
)}
|
||||
{captchaProvider === 'recaptcha' && (
|
||||
<div className="g-recaptcha" data-sitekey={captchaKey} data-callback="magentCaptchaCallback" />
|
||||
)}
|
||||
{captchaProvider === 'turnstile' && (
|
||||
<div className="cf-turnstile" data-sitekey={captchaKey} data-callback="magentCaptchaCallback" />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
<div className="auth-actions">
|
||||
<button type="submit">Create account</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,17 @@ const NAV_GROUPS = [
|
||||
{ href: '/admin/cache', label: 'Cache' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Accounts',
|
||||
items: [
|
||||
{ href: '/admin/invites', label: 'Invites' },
|
||||
{ href: '/admin/password', label: 'Password rules' },
|
||||
{ href: '/admin/captcha', label: 'Captcha' },
|
||||
{ href: '/admin/smtp', label: 'Email (SMTP)' },
|
||||
{ href: '/admin/notifications', label: 'Notifications' },
|
||||
{ href: '/admin/expiry', label: 'Account expiry' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Admin',
|
||||
items: [
|
||||
|
||||
@@ -25,6 +25,9 @@ export default function UsersPage() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [bulkAction, setBulkAction] = useState('block')
|
||||
const [bulkRole, setBulkRole] = useState('user')
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
@@ -103,6 +106,43 @@ export default function UsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (username: string, isChecked: boolean) => {
|
||||
setSelected((current) =>
|
||||
isChecked ? [...new Set([...current, username])] : current.filter((name) => name !== username)
|
||||
)
|
||||
}
|
||||
|
||||
const toggleSelectAll = (isChecked: boolean) => {
|
||||
setSelected(isChecked ? users.map((user) => user.username) : [])
|
||||
}
|
||||
|
||||
const runBulkAction = async () => {
|
||||
if (selected.length === 0) {
|
||||
setError('Select at least one user to run a bulk action.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/admin/users/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: bulkAction,
|
||||
role: bulkRole,
|
||||
usernames: selected,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Bulk update failed')
|
||||
}
|
||||
setSelected([])
|
||||
await loadUsers()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError('Could not run the bulk action.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
@@ -128,6 +168,35 @@ export default function UsersPage() {
|
||||
>
|
||||
<section className="admin-section">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{users.length > 0 && (
|
||||
<div className="summary-card user-bulk-bar">
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.length === users.length}
|
||||
onChange={(event) => toggleSelectAll(event.target.checked)}
|
||||
/>
|
||||
<span>Select all</span>
|
||||
</label>
|
||||
<div className="user-bulk-actions">
|
||||
<select value={bulkAction} onChange={(event) => setBulkAction(event.target.value)}>
|
||||
<option value="block">Block access</option>
|
||||
<option value="unblock">Allow access</option>
|
||||
<option value="role">Set role</option>
|
||||
<option value="delete">Delete users</option>
|
||||
</select>
|
||||
{bulkAction === 'role' && (
|
||||
<select value={bulkRole} onChange={(event) => setBulkRole(event.target.value)}>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
)}
|
||||
<button type="button" onClick={runBulkAction}>
|
||||
Apply to {selected.length} selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{users.length === 0 ? (
|
||||
<div className="status-banner">No users found yet.</div>
|
||||
) : (
|
||||
@@ -135,6 +204,14 @@ export default function UsersPage() {
|
||||
{users.map((user) => (
|
||||
<div key={user.username} className="summary-card user-card">
|
||||
<div>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(user.username)}
|
||||
onChange={(event) => toggleSelect(user.username, event.target.checked)}
|
||||
/>
|
||||
<span>Select</span>
|
||||
</label>
|
||||
<strong>{user.username}</strong>
|
||||
<span className="meta">Role: {user.role}</span>
|
||||
<span className="meta">Login type: {user.authProvider || 'local'}</span>
|
||||
|
||||
Reference in New Issue
Block a user