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

View File

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

View File

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

View File

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

View File

@@ -78,6 +78,21 @@
min-width: 0; 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) { @media (max-width: 700px) {
.member-row { .member-row {
flex-direction: column; flex-direction: column;

View File

@@ -9,16 +9,24 @@ const EMPTY_HOUSEHOLD_FORM = {
description: '', description: '',
} }
function formatMemberName(member) { const EMPTY_USER_FORM = {
const fullName = [member.firstName, member.lastName] firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
}
function formatPersonName(person) {
const fullName = [person.firstName, person.lastName]
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
return fullName || member.email || 'Unnamed member' return fullName || person.email || 'Unnamed person'
} }
function AdminPage() { function AdminPage() {
const { isAuthenticated, isSiteAdmin } = useAuth() const { isAuthenticated, isSiteAdmin, register } = useAuth()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState('') const [errorMessage, setErrorMessage] = useState('')
const [statusMessage, setStatusMessage] = useState('') const [statusMessage, setStatusMessage] = useState('')
@@ -27,6 +35,8 @@ function AdminPage() {
const [editingHouseholdId, setEditingHouseholdId] = useState('') const [editingHouseholdId, setEditingHouseholdId] = useState('')
const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM) const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM)
const [inviteEmail, setInviteEmail] = useState('') const [inviteEmail, setInviteEmail] = useState('')
const [userForm, setUserForm] = useState(EMPTY_USER_FORM)
const [createdUser, setCreatedUser] = useState(null)
async function loadHouseholds(preferredSelectionId = '') { async function loadHouseholds(preferredSelectionId = '') {
const response = await householdsApi.getHouseholds() const response = await householdsApi.getHouseholds()
@@ -59,6 +69,8 @@ function AdminPage() {
setEditingHouseholdId('') setEditingHouseholdId('')
setHouseholdForm(EMPTY_HOUSEHOLD_FORM) setHouseholdForm(EMPTY_HOUSEHOLD_FORM)
setInviteEmail('') setInviteEmail('')
setUserForm(EMPTY_USER_FORM)
setCreatedUser(null)
setErrorMessage('') setErrorMessage('')
setStatusMessage('') setStatusMessage('')
return return
@@ -99,12 +111,17 @@ function AdminPage() {
const canSubmitHouseholdForm = editingHouseholdId const canSubmitHouseholdForm = editingHouseholdId
? Boolean(editingHousehold && (isSiteAdmin || editingHousehold.isCurrentUserHouseholdAdmin)) ? Boolean(editingHousehold && (isSiteAdmin || editingHousehold.isCurrentUserHouseholdAdmin))
: isSiteAdmin : isSiteAdmin
const createdUserRoles = Array.isArray(createdUser?.roles) ? createdUser.roles : []
function resetHouseholdEditor() { function resetHouseholdEditor() {
setEditingHouseholdId('') setEditingHouseholdId('')
setHouseholdForm(EMPTY_HOUSEHOLD_FORM) setHouseholdForm(EMPTY_HOUSEHOLD_FORM)
} }
function resetUserForm() {
setUserForm(EMPTY_USER_FORM)
}
async function handleHouseholdSubmit(event) { async function handleHouseholdSubmit(event) {
event.preventDefault() 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) { async function handleLeaveHousehold(householdId) {
const confirmed = window.confirm('Leave this household?') const confirmed = window.confirm('Leave this household?')
@@ -221,7 +286,7 @@ function AdminPage() {
<> <>
{errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>} {errorMessage && <div className="status-banner status-banner--error">{errorMessage}</div>}
{statusMessage && <div className="status-banner status-banner--success">{statusMessage}</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"> <section className="panel admin-summary-panel">
<h3>Household Summary</h3> <h3>Household Summary</h3>
@@ -335,6 +400,117 @@ function AdminPage() {
</> </>
)} )}
</section> </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> </div>
<section className="panel admin-list-panel"> <section className="panel admin-list-panel">
@@ -371,7 +547,7 @@ function AdminPage() {
{(household.members ?? []).map(member => ( {(household.members ?? []).map(member => (
<div className="member-row" key={member.userId}> <div className="member-row" key={member.userId}>
<div className="member-copy"> <div className="member-copy">
<strong>{formatMemberName(member)}</strong> <strong>{formatPersonName(member)}</strong>
<span className="entity-meta">{member.email}</span> <span className="entity-meta">{member.email}</span>
</div> </div>
{member.isHouseholdAdmin && <span className="member-badge">Admin</span>} {member.isHouseholdAdmin && <span className="member-badge">Admin</span>}