Admin page changes to allow creation of a user and hidden behind the admin permission
This commit is contained in:
23
README.md
23
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
Reference in New Issue
Block a user