Admin page changes to allow creation of a user and hidden behind the admin permission

This commit is contained in:
2026-04-17 08:45:38 +01:00
parent feb44060c4
commit 86082c50d1
6 changed files with 216 additions and 20 deletions

View File

@@ -10,6 +10,7 @@ This app is no longer a static demo. It now uses the backend API for authenticat
- Added a shared API client with JWT handling, refresh-token retry, and normalized error messages.
- Added shared auth/session state with `localStorage` persistence.
- Updated the dashboard to support login and live inventory summary data.
- Added a site-admin user creation form on the Admin page backed by `POST /api/auth/register`.
- Added a dedicated inventory management page for CRUD operations.
- Updated search to call the backend search endpoints.
- Updated barcode scanning so scans look up matching inventory items through the API.
@@ -20,7 +21,7 @@ This app is no longer a static demo. It now uses the backend API for authenticat
| Route | Purpose |
| --- | --- |
| `/` | Dashboard. Shows login when signed out, or inventory summary, chart, and nearest expiry data when signed in. |
| `/admin` | Household administration page for listing, creating, editing, inviting, and leaving households. |
| `/admin` | Site-admin page for household administration and user creation. |
| `/inventory` | Manage locations and inventory items. |
| `/search` | Search items and locations, then filter results further in the UI. |
| `/barcode` | Scan a barcode and search the inventory API for matches. |
@@ -68,10 +69,15 @@ Households:
- `POST /api/households/{id}/invite`
- `DELETE /api/households/{id}/leave`
Users:
- `POST /api/auth/register`
Notes:
- Household creation is limited to users with the backend `Admin` role.
- Household editing and member invites are available when the current user is a household admin or site admin.
- The `/admin` route is only visible and accessible for users with the backend `Admin` role.
- User creation on this page uses the register contract: `email`, `password`, `confirmPassword`, `firstName`, and `lastName`.
- The register endpoint does not assign roles, so new users are created without admin access by default.
### Search (`src/pages/SearchPage/SearchPage.jsx`)
@@ -114,8 +120,7 @@ The shared API client also supports:
Notes:
- `register` is still implemented in the client helper, but there is currently no registration form in the UI.
- If you need to create a user, use the backend Swagger UI or another client to call `POST /api/auth/register`.
- `register` is exposed in the Admin page for site admins and does not replace the current signed-in session.
## API Configuration
@@ -207,7 +212,7 @@ npm run preview
2. Enter an existing user email and password
3. Sign in to unlock the protected routes and API-backed data
There is no registration form in the UI at the moment.
There is no public registration form in the signed-out UI. Site admins can create accounts from `/admin`.
### Inventory Page
@@ -226,11 +231,11 @@ Important update behavior:
Use `/admin` to:
- Review all households returned for the current user
- Create a new household when the signed-in user has the site admin role
- Edit households the signed-in user administers
- Review household data as a site admin
- Create a new household
- Invite members by email to the selected household
- Leave a household from the same page
- Create a new user account through the register endpoint
### Search Page

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Webpage Playground</title>
<title>Pantry Manager</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Routes, Route } from 'react-router-dom'
import { Navigate, Routes, Route } from 'react-router-dom'
import Navbar from './components/Navbar/Navbar.jsx'
import AdminPage from './pages/AdminPage/AdminPage.jsx'
import HomePage from './pages/HomePage/HomePage.jsx'
@@ -36,7 +36,7 @@ if (typeof document !== 'undefined') {
}
function App() {
const { isAuthenticated } = useAuth()
const { isAuthenticated, isSiteAdmin } = useAuth()
const [theme, setTheme] = useState(initialTheme)
useEffect(() => {
@@ -61,7 +61,7 @@ function App() {
{isAuthenticated ? (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/admin" element={isSiteAdmin ? <AdminPage /> : <Navigate to="/" replace />} />
<Route path="/inventory" element={<InventoryPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/barcode" element={<BarcodePage />} />

View File

@@ -83,7 +83,7 @@ function Navbar({ theme, onToggleTheme }) {
{isAuthenticated ? (
<>
<li><NavLink to="/" onClick={closeMenu}>Homepage</NavLink></li>
<li><NavLink to="/admin" onClick={closeMenu}>Admin</NavLink></li>
{isSiteAdmin && <li><NavLink to="/admin" onClick={closeMenu}>Admin</NavLink></li>}
<li><NavLink to="/inventory" onClick={closeMenu}>Inventory</NavLink></li>
<li><NavLink to="/search" onClick={closeMenu}>Search</NavLink></li>
<li><NavLink to="/barcode" onClick={closeMenu}>Barcode Scanner</NavLink></li>

View File

@@ -78,6 +78,21 @@
min-width: 0;
}
.admin-created-user-card {
margin-top: 20px;
padding: 16px;
border-radius: 12px;
background: var(--color-surface-muted);
border: 1px solid var(--color-border-muted);
display: grid;
gap: 12px;
}
.admin-created-user-copy {
display: grid;
gap: 6px;
}
@media (max-width: 700px) {
.member-row {
flex-direction: column;

View File

@@ -9,16 +9,24 @@ const EMPTY_HOUSEHOLD_FORM = {
description: '',
}
function formatMemberName(member) {
const fullName = [member.firstName, member.lastName]
const EMPTY_USER_FORM = {
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
}
function formatPersonName(person) {
const fullName = [person.firstName, person.lastName]
.filter(Boolean)
.join(' ')
return fullName || member.email || 'Unnamed member'
return fullName || person.email || 'Unnamed person'
}
function AdminPage() {
const { isAuthenticated, isSiteAdmin } = useAuth()
const { isAuthenticated, isSiteAdmin, register } = useAuth()
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('')
@@ -27,6 +35,8 @@ function AdminPage() {
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()
@@ -59,6 +69,8 @@ function AdminPage() {
setEditingHouseholdId('')
setHouseholdForm(EMPTY_HOUSEHOLD_FORM)
setInviteEmail('')
setUserForm(EMPTY_USER_FORM)
setCreatedUser(null)
setErrorMessage('')
setStatusMessage('')
return
@@ -99,12 +111,17 @@ function AdminPage() {
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()
@@ -182,6 +199,54 @@ function AdminPage() {
}
}
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?')
@@ -221,7 +286,7 @@ function AdminPage() {
<>
{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">Syncing household data...</div>}
{loading && <div className="status-banner status-banner--info">Processing admin request...</div>}
<section className="panel admin-summary-panel">
<h3>Household Summary</h3>
@@ -335,6 +400,117 @@ function AdminPage() {
</>
)}
</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">
@@ -371,7 +547,7 @@ function AdminPage() {
{(household.members ?? []).map(member => (
<div className="member-row" key={member.userId}>
<div className="member-copy">
<strong>{formatMemberName(member)}</strong>
<strong>{formatPersonName(member)}</strong>
<span className="entity-meta">{member.email}</span>
</div>
{member.isHouseholdAdmin && <span className="member-badge">Admin</span>}