855 lines
31 KiB
TypeScript
855 lines
31 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||
|
||
type ProfileInfo = {
|
||
username: string
|
||
role: string
|
||
auth_provider: string
|
||
invite_management_enabled?: boolean
|
||
}
|
||
|
||
type ProfileStats = {
|
||
total: number
|
||
ready: number
|
||
pending: number
|
||
in_progress: number
|
||
declined: number
|
||
working: number
|
||
partial: number
|
||
approved: number
|
||
last_request_at?: string | null
|
||
share: number
|
||
global_total: number
|
||
most_active_user?: { username: string; total: number } | null
|
||
}
|
||
|
||
type ActivityEntry = {
|
||
ip: string
|
||
user_agent: string
|
||
first_seen_at: string
|
||
last_seen_at: string
|
||
hit_count: number
|
||
}
|
||
|
||
type ProfileActivity = {
|
||
last_ip?: string | null
|
||
last_user_agent?: string | null
|
||
last_seen_at?: string | null
|
||
device_count: number
|
||
recent: ActivityEntry[]
|
||
}
|
||
|
||
type ProfileResponse = {
|
||
user: ProfileInfo
|
||
stats: ProfileStats
|
||
activity: ProfileActivity
|
||
}
|
||
|
||
type OwnedInvite = {
|
||
id: number
|
||
code: string
|
||
label?: string | null
|
||
description?: string | null
|
||
recipient_email?: string | null
|
||
max_uses?: number | null
|
||
use_count: number
|
||
remaining_uses?: number | null
|
||
enabled: boolean
|
||
expires_at?: string | null
|
||
is_expired?: boolean
|
||
is_usable?: boolean
|
||
created_at?: string | null
|
||
updated_at?: string | null
|
||
}
|
||
|
||
type OwnedInvitesResponse = {
|
||
invites?: OwnedInvite[]
|
||
count?: number
|
||
invite_access?: {
|
||
enabled?: boolean
|
||
managed_by_master?: boolean
|
||
}
|
||
master_invite?: {
|
||
id: number
|
||
code: string
|
||
label?: string | null
|
||
description?: string | null
|
||
max_uses?: number | null
|
||
enabled?: boolean
|
||
expires_at?: string | null
|
||
is_usable?: boolean
|
||
} | null
|
||
}
|
||
|
||
type OwnedInviteForm = {
|
||
code: string
|
||
label: string
|
||
description: string
|
||
recipient_email: string
|
||
max_uses: string
|
||
expires_at: string
|
||
enabled: boolean
|
||
send_email: boolean
|
||
message: string
|
||
}
|
||
|
||
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
|
||
|
||
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
||
code: '',
|
||
label: '',
|
||
description: '',
|
||
recipient_email: '',
|
||
max_uses: '',
|
||
expires_at: '',
|
||
enabled: true,
|
||
send_email: false,
|
||
message: '',
|
||
})
|
||
|
||
const formatDate = (value?: string | null) => {
|
||
if (!value) return 'Never'
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.valueOf())) return value
|
||
return date.toLocaleString()
|
||
}
|
||
|
||
const parseBrowser = (agent?: string | null) => {
|
||
if (!agent) return 'Unknown'
|
||
const value = agent.toLowerCase()
|
||
if (value.includes('edg/')) return 'Edge'
|
||
if (value.includes('chrome/') && !value.includes('edg/')) return 'Chrome'
|
||
if (value.includes('firefox/')) return 'Firefox'
|
||
if (value.includes('safari/') && !value.includes('chrome/')) return 'Safari'
|
||
return 'Unknown'
|
||
}
|
||
|
||
export default function ProfilePage() {
|
||
const router = useRouter()
|
||
const [profile, setProfile] = useState<ProfileInfo | null>(null)
|
||
const [stats, setStats] = useState<ProfileStats | null>(null)
|
||
const [activity, setActivity] = useState<ProfileActivity | null>(null)
|
||
const [currentPassword, setCurrentPassword] = useState('')
|
||
const [newPassword, setNewPassword] = useState('')
|
||
const [status, setStatus] = useState<string | null>(null)
|
||
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
|
||
const [inviteError, setInviteError] = useState<string | null>(null)
|
||
const [invites, setInvites] = useState<OwnedInvite[]>([])
|
||
const [inviteSaving, setInviteSaving] = useState(false)
|
||
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
||
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
|
||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
||
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
||
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
||
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
const signupBaseUrl = useMemo(() => {
|
||
if (typeof window === 'undefined') return '/signup'
|
||
return `${window.location.origin}/signup`
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!getToken()) {
|
||
router.push('/login')
|
||
return
|
||
}
|
||
const load = async () => {
|
||
try {
|
||
const baseUrl = getApiBase()
|
||
const [profileResponse, invitesResponse] = await Promise.all([
|
||
authFetch(`${baseUrl}/auth/profile`),
|
||
authFetch(`${baseUrl}/auth/profile/invites`),
|
||
])
|
||
if (!profileResponse.ok || !invitesResponse.ok) {
|
||
clearToken()
|
||
router.push('/login')
|
||
return
|
||
}
|
||
const [data, inviteData] = (await Promise.all([
|
||
profileResponse.json(),
|
||
invitesResponse.json(),
|
||
])) as [ProfileResponse, OwnedInvitesResponse]
|
||
const user = data?.user ?? {}
|
||
setProfile({
|
||
username: user?.username ?? 'Unknown',
|
||
role: user?.role ?? 'user',
|
||
auth_provider: user?.auth_provider ?? 'local',
|
||
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||
})
|
||
setStats(data?.stats ?? null)
|
||
setActivity(data?.activity ?? null)
|
||
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
|
||
setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false))
|
||
setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false))
|
||
setMasterInviteTemplate(inviteData?.master_invite ?? null)
|
||
} catch (err) {
|
||
console.error(err)
|
||
setStatus('Could not load your profile.')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
void load()
|
||
}, [router])
|
||
|
||
const submit = async (event: React.FormEvent) => {
|
||
event.preventDefault()
|
||
setStatus(null)
|
||
if (!currentPassword || !newPassword) {
|
||
setStatus('Enter your current password and a new password.')
|
||
return
|
||
}
|
||
try {
|
||
const baseUrl = getApiBase()
|
||
const response = await authFetch(`${baseUrl}/auth/password`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
current_password: currentPassword,
|
||
new_password: newPassword,
|
||
}),
|
||
})
|
||
if (!response.ok) {
|
||
let detail = 'Update failed'
|
||
try {
|
||
const payload = await response.json()
|
||
if (typeof payload?.detail === 'string' && payload.detail.trim()) {
|
||
detail = payload.detail
|
||
}
|
||
} catch {
|
||
const text = await response.text().catch(() => '')
|
||
if (text?.trim()) detail = text
|
||
}
|
||
throw new Error(detail)
|
||
}
|
||
const data = await response.json().catch(() => ({}))
|
||
setCurrentPassword('')
|
||
setNewPassword('')
|
||
setStatus(
|
||
data?.provider === 'jellyfin'
|
||
? 'Password updated in Jellyfin (and Magent cache).'
|
||
: 'Password updated.'
|
||
)
|
||
} catch (err) {
|
||
console.error(err)
|
||
if (err instanceof Error && err.message) {
|
||
setStatus(`Could not update password. ${err.message}`)
|
||
} else {
|
||
setStatus('Could not update password. Check your current password.')
|
||
}
|
||
}
|
||
}
|
||
|
||
const resetInviteEditor = () => {
|
||
setInviteEditingId(null)
|
||
setInviteForm(defaultOwnedInviteForm())
|
||
}
|
||
|
||
const editInvite = (invite: OwnedInvite) => {
|
||
setInviteEditingId(invite.id)
|
||
setInviteError(null)
|
||
setInviteStatus(null)
|
||
setInviteForm({
|
||
code: invite.code ?? '',
|
||
label: invite.label ?? '',
|
||
description: invite.description ?? '',
|
||
recipient_email: invite.recipient_email ?? '',
|
||
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
|
||
expires_at: invite.expires_at ?? '',
|
||
enabled: invite.enabled !== false,
|
||
send_email: false,
|
||
message: '',
|
||
})
|
||
}
|
||
|
||
const reloadInvites = async () => {
|
||
const baseUrl = getApiBase()
|
||
const response = await authFetch(`${baseUrl}/auth/profile/invites`)
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
clearToken()
|
||
router.push('/login')
|
||
return
|
||
}
|
||
throw new Error(`Invite refresh failed: ${response.status}`)
|
||
}
|
||
const data = (await response.json()) as OwnedInvitesResponse
|
||
setInvites(Array.isArray(data?.invites) ? data.invites : [])
|
||
setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false))
|
||
setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false))
|
||
setMasterInviteTemplate(data?.master_invite ?? null)
|
||
}
|
||
|
||
const saveInvite = async (event: React.FormEvent) => {
|
||
event.preventDefault()
|
||
setInviteSaving(true)
|
||
setInviteError(null)
|
||
setInviteStatus(null)
|
||
try {
|
||
const baseUrl = getApiBase()
|
||
const response = await authFetch(
|
||
inviteEditingId == null
|
||
? `${baseUrl}/auth/profile/invites`
|
||
: `${baseUrl}/auth/profile/invites/${inviteEditingId}`,
|
||
{
|
||
method: inviteEditingId == null ? 'POST' : 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
code: inviteForm.code || null,
|
||
label: inviteForm.label || null,
|
||
description: inviteForm.description || null,
|
||
recipient_email: inviteForm.recipient_email || null,
|
||
max_uses: inviteForm.max_uses || null,
|
||
expires_at: inviteForm.expires_at || null,
|
||
enabled: inviteForm.enabled,
|
||
send_email: inviteForm.send_email,
|
||
message: inviteForm.message || null,
|
||
}),
|
||
}
|
||
)
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
clearToken()
|
||
router.push('/login')
|
||
return
|
||
}
|
||
const text = await response.text()
|
||
throw new Error(text || 'Invite save failed')
|
||
}
|
||
const data = await response.json().catch(() => ({}))
|
||
if (data?.email?.status === 'ok') {
|
||
setInviteStatus(
|
||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
|
||
)
|
||
} else if (data?.email?.status === 'error') {
|
||
setInviteStatus(
|
||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
|
||
)
|
||
} else {
|
||
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
|
||
}
|
||
resetInviteEditor()
|
||
await reloadInvites()
|
||
} catch (err) {
|
||
console.error(err)
|
||
setInviteError(err instanceof Error ? err.message : 'Could not save invite.')
|
||
} finally {
|
||
setInviteSaving(false)
|
||
}
|
||
}
|
||
|
||
const deleteInvite = async (invite: OwnedInvite) => {
|
||
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
|
||
setInviteError(null)
|
||
setInviteStatus(null)
|
||
try {
|
||
const baseUrl = getApiBase()
|
||
const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, {
|
||
method: 'DELETE',
|
||
})
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
clearToken()
|
||
router.push('/login')
|
||
return
|
||
}
|
||
const text = await response.text()
|
||
throw new Error(text || 'Invite delete failed')
|
||
}
|
||
if (inviteEditingId === invite.id) {
|
||
resetInviteEditor()
|
||
}
|
||
setInviteStatus(`Deleted invite ${invite.code}.`)
|
||
await reloadInvites()
|
||
} catch (err) {
|
||
console.error(err)
|
||
setInviteError(err instanceof Error ? err.message : 'Could not delete invite.')
|
||
}
|
||
}
|
||
|
||
const copyInviteLink = async (invite: OwnedInvite) => {
|
||
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
|
||
try {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(url)
|
||
setInviteStatus(`Copied invite link for ${invite.code}.`)
|
||
} else {
|
||
window.prompt('Copy invite link', url)
|
||
}
|
||
} catch (err) {
|
||
console.error(err)
|
||
window.prompt('Copy invite link', url)
|
||
}
|
||
}
|
||
|
||
const authProvider = profile?.auth_provider ?? 'local'
|
||
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
||
const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin'
|
||
const securityHelpText =
|
||
authProvider === 'jellyfin'
|
||
? 'Changing your password here updates your Jellyfin account and refreshes Magent’s cached sign-in.'
|
||
: authProvider === 'local'
|
||
? 'Change your Magent account password.'
|
||
: 'Password changes are not available for this sign-in provider.'
|
||
|
||
useEffect(() => {
|
||
if (activeTab === 'invites' && !canManageInvites) {
|
||
setActiveTab('overview')
|
||
}
|
||
}, [activeTab, canManageInvites])
|
||
|
||
if (loading) {
|
||
return <main className="card">Loading profile...</main>
|
||
}
|
||
|
||
return (
|
||
<main className="card">
|
||
<h1>My profile</h1>
|
||
{profile && (
|
||
<div className="status-banner">
|
||
Signed in as <strong>{profile.username}</strong> ({profile.role}). Login type:{' '}
|
||
{profile.auth_provider}.
|
||
</div>
|
||
)}
|
||
<div className="profile-tabbar">
|
||
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={activeTab === 'overview'}
|
||
className={activeTab === 'overview' ? 'is-active' : ''}
|
||
onClick={() => setActiveTab('overview')}
|
||
>
|
||
Overview
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={activeTab === 'activity'}
|
||
className={activeTab === 'activity' ? 'is-active' : ''}
|
||
onClick={() => setActiveTab('activity')}
|
||
>
|
||
Activity
|
||
</button>
|
||
{canManageInvites ? (
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={activeTab === 'invites'}
|
||
className={activeTab === 'invites' ? 'is-active' : ''}
|
||
onClick={() => setActiveTab('invites')}
|
||
>
|
||
My invites
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={activeTab === 'security'}
|
||
className={activeTab === 'security' ? 'is-active' : ''}
|
||
onClick={() => setActiveTab('security')}
|
||
>
|
||
Security
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{activeTab === 'overview' && (
|
||
<section className="profile-section profile-tab-panel">
|
||
<h2>Account stats</h2>
|
||
<div className="stat-grid">
|
||
<div className="stat-card">
|
||
<div className="stat-label">Requests submitted</div>
|
||
<div className="stat-value">{stats?.total ?? 0}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Ready to watch</div>
|
||
<div className="stat-value">{stats?.ready ?? 0}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">In progress</div>
|
||
<div className="stat-value">{stats?.in_progress ?? 0}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Pending approval</div>
|
||
<div className="stat-value">{stats?.pending ?? 0}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Declined</div>
|
||
<div className="stat-value">{stats?.declined ?? 0}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Working</div>
|
||
<div className="stat-value">{stats?.working ?? 0}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Partial</div>
|
||
<div className="stat-value">{stats?.partial ?? 0}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Approved</div>
|
||
<div className="stat-value">{stats?.approved ?? 0}</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Last request</div>
|
||
<div className="stat-value stat-value--small">
|
||
{formatDate(stats?.last_request_at)}
|
||
</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Share of all requests</div>
|
||
<div className="stat-value">
|
||
{stats?.global_total
|
||
? `${Math.round((stats.share || 0) * 1000) / 10}%`
|
||
: '0%'}
|
||
</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-label">Total requests (global)</div>
|
||
<div className="stat-value">{stats?.global_total ?? 0}</div>
|
||
</div>
|
||
{profile?.role === 'admin' ? (
|
||
<div className="stat-card">
|
||
<div className="stat-label">Most active user</div>
|
||
<div className="stat-value stat-value--small">
|
||
{stats?.most_active_user
|
||
? `${stats.most_active_user.username} (${stats.most_active_user.total})`
|
||
: 'N/A'}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === 'activity' && (
|
||
<section className="profile-section profile-tab-panel">
|
||
<h2>Connection history</h2>
|
||
<div className="status-banner">
|
||
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
|
||
</div>
|
||
<div className="connection-list">
|
||
{(activity?.recent ?? []).map((entry, index) => (
|
||
<div key={`${entry.ip}-${entry.last_seen_at}-${index}`} className="connection-item">
|
||
<div>
|
||
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
|
||
<div className="meta">IP: {entry.ip}</div>
|
||
<div className="meta">First seen: {formatDate(entry.first_seen_at)}</div>
|
||
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
|
||
</div>
|
||
<div className="connection-count">{entry.hit_count} visits</div>
|
||
</div>
|
||
))}
|
||
{activity && activity.recent.length === 0 ? (
|
||
<div className="status-banner">No connection history yet.</div>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === 'invites' && (
|
||
<section className="profile-section profile-invites-section profile-tab-panel">
|
||
<div className="user-directory-panel-header">
|
||
<div>
|
||
<h2>My invites</h2>
|
||
<p className="lede">
|
||
{inviteManagedByMaster
|
||
? 'Create and manage invite links you’ve issued. New invites use the admin master invite rule.'
|
||
: 'Create and manage invite links you’ve issued. New invites use your account defaults.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{inviteError && <div className="error-banner">{inviteError}</div>}
|
||
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
||
<div className="profile-invites-layout">
|
||
<div className="profile-invite-form-card">
|
||
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
|
||
<p className="meta profile-invite-form-lede">
|
||
Share the generated signup link with the person you want to invite.
|
||
</p>
|
||
{inviteManagedByMaster && masterInviteTemplate ? (
|
||
<div className="status-banner profile-invite-master-banner">
|
||
Using master invite rule <code>{masterInviteTemplate.code}</code>
|
||
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits/status are managed by admin.
|
||
</div>
|
||
) : null}
|
||
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
||
<div className="invite-form-row">
|
||
<div className="invite-form-row-label">
|
||
<span>Identity</span>
|
||
<small>Optional code and label for easier tracking.</small>
|
||
</div>
|
||
<div className="invite-form-row-control invite-form-row-grid">
|
||
<label>
|
||
<span>Code (optional)</span>
|
||
<input
|
||
value={inviteForm.code}
|
||
onChange={(event) =>
|
||
setInviteForm((current) => ({ ...current, code: event.target.value }))
|
||
}
|
||
placeholder="Leave blank to auto-generate"
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>Label</span>
|
||
<input
|
||
value={inviteForm.label}
|
||
onChange={(event) =>
|
||
setInviteForm((current) => ({ ...current, label: event.target.value }))
|
||
}
|
||
placeholder="Family invite"
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="invite-form-row">
|
||
<div className="invite-form-row-label">
|
||
<span>Description</span>
|
||
<small>Optional note shown on the signup page.</small>
|
||
</div>
|
||
<div className="invite-form-row-control">
|
||
<textarea
|
||
rows={3}
|
||
value={inviteForm.description}
|
||
onChange={(event) =>
|
||
setInviteForm((current) => ({
|
||
...current,
|
||
description: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="Optional note shown on the signup page"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="invite-form-row">
|
||
<div className="invite-form-row-label">
|
||
<span>Delivery</span>
|
||
<small>Save a recipient email and optionally send the invite immediately.</small>
|
||
</div>
|
||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||
<label>
|
||
<span>Recipient email</span>
|
||
<input
|
||
type="email"
|
||
value={inviteForm.recipient_email}
|
||
onChange={(event) =>
|
||
setInviteForm((current) => ({
|
||
...current,
|
||
recipient_email: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="friend@example.com"
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>Delivery note</span>
|
||
<textarea
|
||
rows={3}
|
||
value={inviteForm.message}
|
||
onChange={(event) =>
|
||
setInviteForm((current) => ({
|
||
...current,
|
||
message: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="Optional note to include in the email"
|
||
/>
|
||
</label>
|
||
<label className="inline-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={inviteForm.send_email}
|
||
onChange={(event) =>
|
||
setInviteForm((current) => ({
|
||
...current,
|
||
send_email: event.target.checked,
|
||
}))
|
||
}
|
||
/>
|
||
Send "You have been invited" email after saving
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="invite-form-row">
|
||
<div className="invite-form-row-label">
|
||
<span>Limits</span>
|
||
<small>Usage cap and optional expiry date/time.</small>
|
||
</div>
|
||
<div className="invite-form-row-control invite-form-row-grid">
|
||
<label>
|
||
<span>Max uses</span>
|
||
<input
|
||
value={inviteForm.max_uses}
|
||
onChange={(event) =>
|
||
setInviteForm((current) => ({ ...current, max_uses: event.target.value }))
|
||
}
|
||
inputMode="numeric"
|
||
placeholder="Blank = unlimited"
|
||
disabled={inviteManagedByMaster}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>Invite expiry (ISO datetime)</span>
|
||
<input
|
||
value={inviteForm.expires_at}
|
||
onChange={(event) =>
|
||
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
|
||
}
|
||
placeholder="2026-03-01T12:00:00+00:00"
|
||
disabled={inviteManagedByMaster}
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="invite-form-row">
|
||
<div className="invite-form-row-label">
|
||
<span>Status</span>
|
||
<small>Enable or disable this invite before sharing.</small>
|
||
</div>
|
||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||
<label className="inline-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={inviteForm.enabled}
|
||
onChange={(event) =>
|
||
setInviteForm((current) => ({
|
||
...current,
|
||
enabled: event.target.checked,
|
||
}))
|
||
}
|
||
disabled={inviteManagedByMaster}
|
||
/>
|
||
Invite is enabled
|
||
</label>
|
||
<div className="admin-inline-actions">
|
||
<button type="submit" disabled={inviteSaving}>
|
||
{inviteSaving
|
||
? 'Saving…'
|
||
: inviteEditingId == null
|
||
? 'Create invite'
|
||
: 'Save invite'}
|
||
</button>
|
||
{inviteEditingId != null && (
|
||
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
||
Cancel edit
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
<div className="meta profile-invite-hint">
|
||
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
|
||
</div>
|
||
</div>
|
||
<div className="profile-invites-list">
|
||
{invites.length === 0 ? (
|
||
<div className="status-banner">You haven’t created any invites yet.</div>
|
||
) : (
|
||
<div className="admin-list">
|
||
{invites.map((invite) => (
|
||
<div key={invite.id} className="admin-list-item">
|
||
<div className="admin-list-item-main">
|
||
<div className="admin-list-item-title-row">
|
||
<code className="invite-code">{invite.code}</code>
|
||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
||
</span>
|
||
<span className="small-pill is-muted">
|
||
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
||
</span>
|
||
</div>
|
||
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
||
{invite.description && (
|
||
<p className="admin-list-item-text admin-list-item-text--muted">
|
||
{invite.description}
|
||
</p>
|
||
)}
|
||
<div className="admin-meta-row">
|
||
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
|
||
<span>
|
||
Uses: {invite.use_count}
|
||
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
||
</span>
|
||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||
<span>Created: {formatDate(invite.created_at)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="admin-inline-actions">
|
||
<button
|
||
type="button"
|
||
className="ghost-button"
|
||
onClick={() => copyInviteLink(invite)}
|
||
>
|
||
Copy link
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ghost-button"
|
||
onClick={() => editInvite(invite)}
|
||
>
|
||
Edit
|
||
</button>
|
||
<button type="button" onClick={() => deleteInvite(invite)}>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeTab === 'security' && (
|
||
<section className="profile-section profile-tab-panel">
|
||
<h2>Security</h2>
|
||
<div className="status-banner">{securityHelpText}</div>
|
||
{canChangePassword ? (
|
||
<form onSubmit={submit} className="auth-form profile-security-form">
|
||
<label>
|
||
Current password
|
||
<input
|
||
type="password"
|
||
value={currentPassword}
|
||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||
autoComplete="current-password"
|
||
/>
|
||
</label>
|
||
<label>
|
||
New password
|
||
<input
|
||
type="password"
|
||
value={newPassword}
|
||
onChange={(event) => setNewPassword(event.target.value)}
|
||
autoComplete="new-password"
|
||
/>
|
||
</label>
|
||
{status && <div className="status-banner">{status}</div>}
|
||
<div className="auth-actions">
|
||
<button type="submit">
|
||
{authProvider === 'jellyfin' ? 'Update Jellyfin password' : 'Update password'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
) : (
|
||
<div className="status-banner">
|
||
Password changes are not available for {authProvider} sign-in accounts from Magent.
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</main>
|
||
)
|
||
}
|