625 lines
23 KiB
TypeScript
625 lines
23 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 ProfileResponse = {
|
|
user: ProfileInfo
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
export default function ProfileInvitesPage() {
|
|
const router = useRouter()
|
|
const [profile, setProfile] = useState<ProfileInfo | 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 [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`
|
|
}, [])
|
|
|
|
const loadPage = async () => {
|
|
const baseUrl = getApiBase()
|
|
const [profileResponse, invitesResponse] = await Promise.all([
|
|
authFetch(`${baseUrl}/auth/profile`),
|
|
authFetch(`${baseUrl}/auth/profile/invites`),
|
|
])
|
|
if (!profileResponse.ok || !invitesResponse.ok) {
|
|
if (profileResponse.status === 401 || invitesResponse.status === 401) {
|
|
clearToken()
|
|
router.push('/login')
|
|
return
|
|
}
|
|
throw new Error('Could not load invite tools.')
|
|
}
|
|
const [profileData, inviteData] = (await Promise.all([
|
|
profileResponse.json(),
|
|
invitesResponse.json(),
|
|
])) as [ProfileResponse, OwnedInvitesResponse]
|
|
const user = profileData?.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),
|
|
})
|
|
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)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!getToken()) {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
const load = async () => {
|
|
try {
|
|
await loadPage()
|
|
} catch (err) {
|
|
console.error(err)
|
|
setInviteError(err instanceof Error ? err.message : 'Could not load invite tools.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
void load()
|
|
}, [router])
|
|
|
|
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 canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
|
|
|
if (loading) {
|
|
return <main className="card">Loading invite tools...</main>
|
|
}
|
|
|
|
return (
|
|
<main className="card">
|
|
<div className="user-directory-panel-header profile-page-header">
|
|
<div>
|
|
<h1>My invites</h1>
|
|
<p className="lede">Create invite links, email them directly, and track who you have invited.</p>
|
|
</div>
|
|
<div className="admin-inline-actions">
|
|
<button type="button" className="ghost-button" onClick={() => router.push('/profile')}>
|
|
Back to profile
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{profile ? (
|
|
<div className="status-banner">
|
|
Signed in as <strong>{profile.username}</strong> ({profile.role}).
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="profile-tabbar">
|
|
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
|
<button type="button" role="tab" aria-selected={false} onClick={() => router.push('/profile')}>
|
|
Overview
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={false}
|
|
onClick={() => router.push('/profile?tab=activity')}
|
|
>
|
|
Activity
|
|
</button>
|
|
<button type="button" role="tab" aria-selected className="is-active">
|
|
My invites
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={false}
|
|
onClick={() => router.push('/profile?tab=security')}
|
|
>
|
|
Security
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{inviteError && <div className="error-banner">{inviteError}</div>}
|
|
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
|
|
|
{!canManageInvites ? (
|
|
<section className="profile-section profile-tab-panel">
|
|
<h2>Invite access is disabled</h2>
|
|
<p className="lede">
|
|
Your account is not currently allowed to create self-service invites. Ask an administrator to enable invite access for your profile.
|
|
</p>
|
|
<div className="admin-inline-actions">
|
|
<button type="button" onClick={() => router.push('/profile')}>
|
|
Return to profile
|
|
</button>
|
|
</div>
|
|
</section>
|
|
) : (
|
|
<section className="profile-section profile-invites-section profile-tab-panel">
|
|
<div className="user-directory-panel-header">
|
|
<div>
|
|
<h2>Invite workspace</h2>
|
|
<p className="lede">
|
|
{inviteManagedByMaster
|
|
? 'Create and manage invite links you have issued. New invites use the admin master invite rule.'
|
|
: 'Create and manage invite links you have issued. New invites use your account defaults.'}
|
|
</p>
|
|
</div>
|
|
</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">
|
|
Save a recipient email, send the invite immediately, and keep the generated link ready to copy.
|
|
</p>
|
|
{inviteManagedByMaster && masterInviteTemplate ? (
|
|
<div className="status-banner profile-invite-master-banner">
|
|
Using master invite rule <code>{masterInviteTemplate.code}</code>
|
|
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits and status are managed by admin.
|
|
</div>
|
|
) : null}
|
|
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout profile-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 have not 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>
|
|
)}
|
|
</main>
|
|
)
|
|
}
|