603 lines
21 KiB
JavaScript
603 lines
21 KiB
JavaScript
import { useEffect, useState } from 'react'
|
|
import { householdsApi } from '../../api/client.js'
|
|
import { useAuth } from '../../context/AuthContext.jsx'
|
|
import { formatDate } from '../../utils/searchUtils.js'
|
|
import './AdminPage.css'
|
|
|
|
const EMPTY_HOUSEHOLD_FORM = {
|
|
name: '',
|
|
description: '',
|
|
}
|
|
|
|
const EMPTY_USER_FORM = {
|
|
firstName: '',
|
|
lastName: '',
|
|
email: '',
|
|
password: '',
|
|
confirmPassword: '',
|
|
}
|
|
|
|
function formatPersonName(person) {
|
|
const fullName = [person.firstName, person.lastName]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
|
|
return fullName || person.email || 'Unnamed person'
|
|
}
|
|
|
|
function AdminPage() {
|
|
const { isAuthenticated, isSiteAdmin, register } = useAuth()
|
|
const [loading, setLoading] = useState(false)
|
|
const [errorMessage, setErrorMessage] = useState('')
|
|
const [statusMessage, setStatusMessage] = useState('')
|
|
const [households, setHouseholds] = useState([])
|
|
const [selectedHouseholdId, setSelectedHouseholdId] = useState('')
|
|
const [editingHouseholdId, setEditingHouseholdId] = useState('')
|
|
const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM)
|
|
const [inviteEmail, setInviteEmail] = useState('')
|
|
const [userForm, setUserForm] = useState(EMPTY_USER_FORM)
|
|
const [createdUser, setCreatedUser] = useState(null)
|
|
|
|
async function loadHouseholds(preferredSelectionId = '') {
|
|
const response = await householdsApi.getHouseholds()
|
|
const nextHouseholds = Array.isArray(response) ? response : []
|
|
|
|
setHouseholds(nextHouseholds)
|
|
setSelectedHouseholdId(currentSelectionId => {
|
|
const targetSelectionId = preferredSelectionId || currentSelectionId
|
|
|
|
if (nextHouseholds.some(household => household.id === targetSelectionId)) {
|
|
return targetSelectionId
|
|
}
|
|
|
|
return nextHouseholds[0]?.id ?? ''
|
|
})
|
|
setEditingHouseholdId(currentEditingId => (
|
|
nextHouseholds.some(household => household.id === currentEditingId)
|
|
? currentEditingId
|
|
: ''
|
|
))
|
|
}
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
|
|
async function loadInitialHouseholds() {
|
|
if (!isAuthenticated) {
|
|
setHouseholds([])
|
|
setSelectedHouseholdId('')
|
|
setEditingHouseholdId('')
|
|
setHouseholdForm(EMPTY_HOUSEHOLD_FORM)
|
|
setInviteEmail('')
|
|
setUserForm(EMPTY_USER_FORM)
|
|
setCreatedUser(null)
|
|
setErrorMessage('')
|
|
setStatusMessage('')
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setErrorMessage('')
|
|
|
|
try {
|
|
const response = await householdsApi.getHouseholds()
|
|
|
|
if (cancelled) return
|
|
|
|
const nextHouseholds = Array.isArray(response) ? response : []
|
|
setHouseholds(nextHouseholds)
|
|
setSelectedHouseholdId(nextHouseholds[0]?.id ?? '')
|
|
} catch (error) {
|
|
if (!cancelled) {
|
|
setErrorMessage(error.message)
|
|
}
|
|
} finally {
|
|
if (!cancelled) {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
loadInitialHouseholds()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [isAuthenticated])
|
|
|
|
const selectedHousehold = households.find(household => household.id === selectedHouseholdId) ?? null
|
|
const editingHousehold = households.find(household => household.id === editingHouseholdId) ?? null
|
|
const canManageSelectedHousehold = Boolean(selectedHousehold && (isSiteAdmin || selectedHousehold.isCurrentUserHouseholdAdmin))
|
|
const canSubmitHouseholdForm = editingHouseholdId
|
|
? Boolean(editingHousehold && (isSiteAdmin || editingHousehold.isCurrentUserHouseholdAdmin))
|
|
: isSiteAdmin
|
|
const createdUserRoles = Array.isArray(createdUser?.roles) ? createdUser.roles : []
|
|
|
|
function resetHouseholdEditor() {
|
|
setEditingHouseholdId('')
|
|
setHouseholdForm(EMPTY_HOUSEHOLD_FORM)
|
|
}
|
|
|
|
function resetUserForm() {
|
|
setUserForm(EMPTY_USER_FORM)
|
|
}
|
|
|
|
async function handleHouseholdSubmit(event) {
|
|
event.preventDefault()
|
|
|
|
const name = householdForm.name.trim()
|
|
|
|
if (!name) {
|
|
setErrorMessage('Household name is required.')
|
|
return
|
|
}
|
|
|
|
if (!canSubmitHouseholdForm) {
|
|
setErrorMessage(editingHouseholdId
|
|
? 'You can only edit households you administer.'
|
|
: 'Only site admins can create households.')
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setErrorMessage('')
|
|
setStatusMessage('')
|
|
|
|
try {
|
|
const payload = {
|
|
name,
|
|
description: householdForm.description.trim() || null,
|
|
}
|
|
|
|
const result = editingHouseholdId
|
|
? await householdsApi.updateHousehold(editingHouseholdId, payload)
|
|
: await householdsApi.createHousehold(payload)
|
|
|
|
await loadHouseholds(result?.id ?? editingHouseholdId)
|
|
resetHouseholdEditor()
|
|
setStatusMessage(editingHouseholdId ? 'Household updated.' : 'Household created.')
|
|
} catch (error) {
|
|
setErrorMessage(error.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function handleInviteSubmit(event) {
|
|
event.preventDefault()
|
|
|
|
const email = inviteEmail.trim()
|
|
|
|
if (!selectedHouseholdId) {
|
|
setErrorMessage('Select a household before sending an invite.')
|
|
return
|
|
}
|
|
|
|
if (!email) {
|
|
setErrorMessage('Invite email is required.')
|
|
return
|
|
}
|
|
|
|
if (!canManageSelectedHousehold) {
|
|
setErrorMessage('You can only invite users to households you administer.')
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setErrorMessage('')
|
|
setStatusMessage('')
|
|
|
|
try {
|
|
await householdsApi.inviteHouseholdMember(selectedHouseholdId, { email })
|
|
await loadHouseholds(selectedHouseholdId)
|
|
setInviteEmail('')
|
|
setStatusMessage('Invitation sent.')
|
|
} catch (error) {
|
|
setErrorMessage(error.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function handleUserSubmit(event) {
|
|
event.preventDefault()
|
|
|
|
const email = userForm.email.trim()
|
|
|
|
if (!isSiteAdmin) {
|
|
setErrorMessage('Only site admins can create users from the admin page.')
|
|
return
|
|
}
|
|
|
|
if (!email) {
|
|
setErrorMessage('User email is required.')
|
|
return
|
|
}
|
|
|
|
if (!userForm.password) {
|
|
setErrorMessage('A password is required to create a user.')
|
|
return
|
|
}
|
|
|
|
if (userForm.password !== userForm.confirmPassword) {
|
|
setErrorMessage('Passwords do not match.')
|
|
return
|
|
}
|
|
|
|
setLoading(true)
|
|
setErrorMessage('')
|
|
setStatusMessage('')
|
|
|
|
try {
|
|
const result = await register({
|
|
email,
|
|
password: userForm.password,
|
|
confirmPassword: userForm.confirmPassword,
|
|
firstName: userForm.firstName.trim() || null,
|
|
lastName: userForm.lastName.trim() || null,
|
|
})
|
|
|
|
setCreatedUser(result?.user ?? null)
|
|
resetUserForm()
|
|
setStatusMessage(result?.message || 'User created.')
|
|
} catch (error) {
|
|
setErrorMessage(error.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function handleLeaveHousehold(householdId) {
|
|
const confirmed = window.confirm('Leave this household?')
|
|
|
|
if (!confirmed) return
|
|
|
|
setLoading(true)
|
|
setErrorMessage('')
|
|
setStatusMessage('')
|
|
|
|
try {
|
|
await householdsApi.leaveHousehold(householdId)
|
|
await loadHouseholds(selectedHouseholdId === householdId ? '' : selectedHouseholdId)
|
|
|
|
if (selectedHouseholdId === householdId) {
|
|
setInviteEmail('')
|
|
resetHouseholdEditor()
|
|
}
|
|
|
|
setStatusMessage('Household left.')
|
|
} catch (error) {
|
|
setErrorMessage(error.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<h2>Admin</h2>
|
|
<hr />
|
|
|
|
{!isAuthenticated ? (
|
|
<div className="auth-required">
|
|
Sign in on the dashboard before using the household administration endpoints.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
|
|
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</div>}
|
|
{loading && <div className="status-banner status-banner--info">Processing admin request...</div>}
|
|
|
|
<section className="panel admin-summary-panel">
|
|
<h3>Household Summary</h3>
|
|
<div className="stats-grid admin-stats-grid">
|
|
<div className="stat-card">
|
|
<span className="stat-label">Households</span>
|
|
<strong>{households.length}</strong>
|
|
</div>
|
|
<div className="stat-card">
|
|
<span className="stat-label">Managed Households</span>
|
|
<strong>{households.filter(household => household.isCurrentUserHouseholdAdmin || isSiteAdmin).length}</strong>
|
|
</div>
|
|
<div className="stat-card">
|
|
<span className="stat-label">Members</span>
|
|
<strong>{households.reduce((count, household) => count + (household.members?.length ?? 0), 0)}</strong>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="management-grid admin-grid">
|
|
<section className="panel">
|
|
<div className="section-heading">
|
|
<h3>{editingHouseholdId ? 'Edit Household' : 'Create Household'}</h3>
|
|
{editingHouseholdId && (
|
|
<button type="button" className="btn btn-secondary" onClick={resetHouseholdEditor}>
|
|
New household
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{!isSiteAdmin && !editingHouseholdId && (
|
|
<p className="form-note">
|
|
Only site admins can create households. Household admins can still edit an existing household from the list.
|
|
</p>
|
|
)}
|
|
|
|
<form className="editor-form" onSubmit={handleHouseholdSubmit}>
|
|
<div className="field-group">
|
|
<label htmlFor="household-name">Name</label>
|
|
<input
|
|
id="household-name"
|
|
type="text"
|
|
value={householdForm.name}
|
|
onChange={event => setHouseholdForm(current => ({ ...current, name: event.target.value }))}
|
|
placeholder="Main Household"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="field-group">
|
|
<label htmlFor="household-description">Description</label>
|
|
<textarea
|
|
id="household-description"
|
|
rows="3"
|
|
value={householdForm.description}
|
|
onChange={event => setHouseholdForm(current => ({ ...current, description: event.target.value }))}
|
|
placeholder="Shared pantry and fridge access"
|
|
/>
|
|
</div>
|
|
|
|
<div className="button-row">
|
|
<button type="submit" className="btn btn-primary" disabled={!canSubmitHouseholdForm}>
|
|
{editingHouseholdId ? 'Update household' : 'Create household'}
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" onClick={resetHouseholdEditor}>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<div className="section-heading">
|
|
<h3>Invite Member</h3>
|
|
<span className="subtle-text">{selectedHousehold?.name || 'No household selected'}</span>
|
|
</div>
|
|
|
|
{!selectedHousehold ? (
|
|
<div className="empty-state compact-empty-state">Select a household to invite a member.</div>
|
|
) : (
|
|
<>
|
|
{!canManageSelectedHousehold && (
|
|
<p className="form-note">
|
|
You can only invite users to households you administer.
|
|
</p>
|
|
)}
|
|
|
|
<form className="editor-form" onSubmit={handleInviteSubmit}>
|
|
<div className="field-group">
|
|
<label htmlFor="invite-email">Email</label>
|
|
<input
|
|
id="invite-email"
|
|
type="email"
|
|
value={inviteEmail}
|
|
onChange={event => setInviteEmail(event.target.value)}
|
|
placeholder="member@example.com"
|
|
disabled={!canManageSelectedHousehold}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="button-row">
|
|
<button type="submit" className="btn btn-primary" disabled={!canManageSelectedHousehold}>
|
|
Send invite
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" onClick={() => setInviteEmail('')}>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<div className="section-heading">
|
|
<h3>Create User</h3>
|
|
<span className="subtle-text">{createdUser?.email || 'Register endpoint'}</span>
|
|
</div>
|
|
|
|
<p className="form-note">
|
|
{isSiteAdmin
|
|
? 'This creates a new account with the register endpoint. Role assignment still happens separately in the backend.'
|
|
: 'Only site admins can create users from this page.'}
|
|
</p>
|
|
|
|
<form className="editor-form" onSubmit={handleUserSubmit}>
|
|
<div className="form-grid">
|
|
<div className="field-group">
|
|
<label htmlFor="register-first-name">First Name</label>
|
|
<input
|
|
id="register-first-name"
|
|
type="text"
|
|
value={userForm.firstName}
|
|
onChange={event => setUserForm(current => ({ ...current, firstName: event.target.value }))}
|
|
placeholder="Jordan"
|
|
disabled={!isSiteAdmin}
|
|
/>
|
|
</div>
|
|
|
|
<div className="field-group">
|
|
<label htmlFor="register-last-name">Last Name</label>
|
|
<input
|
|
id="register-last-name"
|
|
type="text"
|
|
value={userForm.lastName}
|
|
onChange={event => setUserForm(current => ({ ...current, lastName: event.target.value }))}
|
|
placeholder="Lee"
|
|
disabled={!isSiteAdmin}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="field-group">
|
|
<label htmlFor="register-email">Email</label>
|
|
<input
|
|
id="register-email"
|
|
type="email"
|
|
value={userForm.email}
|
|
onChange={event => setUserForm(current => ({ ...current, email: event.target.value }))}
|
|
placeholder="new-user@example.com"
|
|
disabled={!isSiteAdmin}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-grid">
|
|
<div className="field-group">
|
|
<label htmlFor="register-password">Password</label>
|
|
<input
|
|
id="register-password"
|
|
type="password"
|
|
value={userForm.password}
|
|
onChange={event => setUserForm(current => ({ ...current, password: event.target.value }))}
|
|
placeholder="Create a password"
|
|
disabled={!isSiteAdmin}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="field-group">
|
|
<label htmlFor="register-confirm-password">Confirm Password</label>
|
|
<input
|
|
id="register-confirm-password"
|
|
type="password"
|
|
value={userForm.confirmPassword}
|
|
onChange={event => setUserForm(current => ({ ...current, confirmPassword: event.target.value }))}
|
|
placeholder="Repeat the password"
|
|
disabled={!isSiteAdmin}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="button-row">
|
|
<button type="submit" className="btn btn-primary" disabled={!isSiteAdmin}>
|
|
Create user
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" onClick={resetUserForm} disabled={!isSiteAdmin}>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
{createdUser && (
|
|
<div className="admin-created-user-card">
|
|
<div className="admin-created-user-copy">
|
|
<strong>{formatPersonName(createdUser)}</strong>
|
|
<div className="entity-meta">{createdUser.email || 'No email provided.'}</div>
|
|
<div className="entity-meta">ID: {createdUser.id || 'Not available'}</div>
|
|
</div>
|
|
|
|
<div className="admin-badge-row">
|
|
{createdUserRoles.length === 0 ? (
|
|
<span className="member-badge">No roles assigned</span>
|
|
) : (
|
|
createdUserRoles.map(role => (
|
|
<span className="member-badge" key={role}>{role}</span>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
<section className="panel admin-list-panel">
|
|
<div className="section-heading">
|
|
<h3>Households</h3>
|
|
<span className="subtle-text">{households.length} total</span>
|
|
</div>
|
|
|
|
{households.length === 0 ? (
|
|
<div className="empty-state compact-empty-state">No households were returned for this account yet.</div>
|
|
) : (
|
|
<div className="entity-list">
|
|
{households.map(household => {
|
|
const canManageHousehold = isSiteAdmin || household.isCurrentUserHouseholdAdmin
|
|
|
|
return (
|
|
<article
|
|
className={`entity-row admin-household-row ${selectedHouseholdId === household.id ? 'is-selected' : ''}`}
|
|
key={household.id}
|
|
>
|
|
<div className="entity-copy admin-household-copy">
|
|
<div className="admin-household-header">
|
|
<strong>{household.name}</strong>
|
|
<div className="admin-badge-row">
|
|
{household.isCurrentUserHouseholdAdmin && <span className="admin-badge">Household admin</span>}
|
|
{household.adminEmail && <span className="member-badge">Owner: {household.adminEmail}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="entity-meta">{household.description || 'No description provided.'}</div>
|
|
<div className="entity-meta">Created: {formatDate(household.createdAt) || 'Not available'}</div>
|
|
|
|
<div className="member-list">
|
|
{(household.members ?? []).map(member => (
|
|
<div className="member-row" key={member.userId}>
|
|
<div className="member-copy">
|
|
<strong>{formatPersonName(member)}</strong>
|
|
<span className="entity-meta">{member.email}</span>
|
|
</div>
|
|
{member.isHouseholdAdmin && <span className="member-badge">Admin</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="entity-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => setSelectedHouseholdId(household.id)}
|
|
>
|
|
Select
|
|
</button>
|
|
{canManageHousehold && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => {
|
|
setSelectedHouseholdId(household.id)
|
|
setEditingHouseholdId(household.id)
|
|
setHouseholdForm({
|
|
name: household.name ?? '',
|
|
description: household.description ?? '',
|
|
})
|
|
}}
|
|
>
|
|
Edit
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn btn-danger"
|
|
onClick={() => handleLeaveHousehold(household.id)}
|
|
>
|
|
Leave
|
|
</button>
|
|
</div>
|
|
</article>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default AdminPage |