fix teacher, parent and admin pages

This commit is contained in:
root
2026-04-25 00:00:10 -04:00
parent 7fe34dde0d
commit 3e77fc92c7
275 changed files with 46412 additions and 3325 deletions
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { confirmUserEmailChange } from '../../api/session'
/**
* Landing page for the link sent to the new address after requesting an email change
* from account settings (`requestUserEmailChange`).
*/
export function ConfirmEmailChangePage() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token') ?? ''
const [phase, setPhase] = useState<'loading' | 'ok' | 'err'>(token ? 'loading' : 'err')
const [message, setMessage] = useState(token ? '' : 'Missing confirmation token. Open the link from your email.')
useEffect(() => {
if (!token) return
let cancelled = false
void confirmUserEmailChange(token)
.then((res) => {
if (cancelled) return
setPhase('ok')
const m = typeof res?.message === 'string' && res.message.trim() ? res.message.trim() : ''
setMessage(m || 'Your email address has been updated. You can close this tab or sign in again.')
})
.catch((e) => {
if (cancelled) return
setPhase('err')
setMessage(e instanceof Error ? e.message : 'Confirmation failed.')
})
return () => {
cancelled = true
}
}, [token])
return (
<div className="registration-form container mt-5 mb-5 text-center">
<div className="text-center mb-4">
<Link to="/">
<img
src="/alrahma_logo.png"
alt=""
style={{ width: 180, height: 120, objectFit: 'contain' }}
onError={(ev) => {
;(ev.target as HTMLImageElement).style.display = 'none'
}}
/>
</Link>
</div>
{phase === 'loading' ? (
<>
<h3 className="h5 text-muted">Confirming your new email</h3>
<p className="text-muted small">Please wait.</p>
</>
) : null}
{phase === 'ok' ? (
<>
<h3 className="text-success h4">Email confirmed</h3>
<p className="text-muted">{message}</p>
<Link className="btn btn-success mt-2" to="/login">
Sign in
</Link>
</>
) : null}
{phase === 'err' ? (
<>
<h3 className="text-danger h4">Could not confirm email</h3>
<p className="text-muted">{message}</p>
<Link className="btn btn-outline-secondary mt-2" to="/login">
Sign in
</Link>
</>
) : null}
</div>
)
}
+149
View File
@@ -0,0 +1,149 @@
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { CiAcademicFilter } from '../../components/CiPartials'
import { fetchLoginActivity, type LoginActivityRow } from '../../api/session'
function formatDt(value?: string | null): string {
if (!value) return '—'
const date = new Date(String(value).replace(' ', 'T'))
if (Number.isNaN(date.valueOf())) return value
const pad = (n: number) => String(n).padStart(2, '0')
const datePart = `${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${date.getFullYear()}`
const hasTime = /[T ]\d{2}:\d{2}/.test(String(value))
if (!hasTime) return datePart
return `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
/** CI `user/login_activity.php` — paginated JSON from administrator API. */
export function LoginActivityPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [rows, setRows] = useState<LoginActivityRow[]>([])
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageCount, setPageCount] = useState(1)
const [perPage, setPerPage] = useState(25)
const load = useCallback(async () => {
try {
const data = await fetchLoginActivity({
page,
per_page: perPage,
school_year: searchParams.get('school_year') ?? undefined,
semester: searchParams.get('semester') ?? undefined,
})
setRows(data.activities ?? [])
const p = data.pagination
if (p?.currentPage) setPage(p.currentPage)
if (p?.pageCount) setPageCount(p.pageCount)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load login activity.')
}
}, [page, perPage, searchParams])
useEffect(() => {
void load()
}, [load])
return (
<div className="container-fluid py-3">
<h2 className="text-center mt-4 mb-3">Login Activity</h2>
<CiAcademicFilter
onApply={({ schoolYear, semester }) => {
const next = new URLSearchParams()
if (schoolYear) next.set('school_year', schoolYear)
if (semester) next.set('semester', semester)
setSearchParams(next)
setPage(1)
}}
/>
{error ? (
<div id="loginActivityAlert" className="alert alert-danger" role="alert">
{error}
</div>
) : null}
<div className="d-flex justify-content-between align-items-center flex-wrap gap-3 mb-3">
<div>
<label htmlFor="perPageSelect" className="form-label mb-0 me-2">
Rows per page:
</label>
<select
id="perPageSelect"
className="form-select d-inline-block w-auto"
value={perPage}
onChange={(e) => {
setPerPage(Number(e.target.value))
setPage(1)
}}
>
{[10, 25, 50, 100].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
<div className="d-flex align-items-center gap-2">
<button
type="button"
className="btn btn-outline-secondary"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</button>
<span className="text-muted small">
Page {page} of {pageCount}
</span>
<button
type="button"
className="btn btn-outline-secondary"
disabled={page >= pageCount}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
<div className="table-responsive">
<table className="table table-striped table-bordered align-middle mb-0">
<thead className="table-dark">
<tr>
<th>User ID</th>
<th>Email</th>
<th>Login Time</th>
<th>Logout Time</th>
<th>IP Address</th>
<th>User Agent</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-muted">
No login activities found.
</td>
</tr>
) : (
rows.map((activity, i) => (
<tr key={`${activity.user_id}-${activity.login_time}-${i}`}>
<td>{activity.user_id ?? '—'}</td>
<td>{activity.email ?? '—'}</td>
<td>{formatDt(activity.login_time)}</td>
<td>{formatDt(activity.logout_time)}</td>
<td>{activity.ip_address ?? '—'}</td>
<td style={{ maxWidth: 280, whiteSpace: 'normal', wordBreak: 'break-word' }}>
{activity.user_agent ?? '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
/** CI `user/password_set_success.php` — success modal then redirect to login. */
export function PasswordSetSuccessPage() {
const navigate = useNavigate()
const modalRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const el = modalRef.current
const B = (
window as unknown as {
bootstrap?: { Modal: { getOrCreateInstance: (n: HTMLElement) => { show: () => void } } }
}
).bootstrap
if (el && B?.Modal) {
B.Modal.getOrCreateInstance(el).show()
}
const t = window.setTimeout(() => {
navigate('/login', { replace: true })
}, 5000)
return () => window.clearTimeout(t)
}, [navigate])
return (
<div className="registration-form container mt-5 mb-5 text-center">
<div
className="modal fade"
id="successModal"
tabIndex={-1}
ref={modalRef}
aria-labelledby="successModalLabel"
aria-hidden="true"
>
<div className="text-center mb-4">
<Link to="/">
<img
src="/alrahma_logo.png"
alt=""
style={{ width: 180, height: 120, objectFit: 'contain' }}
onError={(ev) => {
;(ev.target as HTMLImageElement).style.display = 'none'
}}
/>
</Link>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content border-success shadow">
<div className="modal-header bg-success text-white">
<h5 className="modal-title w-100 text-center" id="successModalLabel">
Success
</h5>
</div>
<div className="modal-body">
<h4 className="mb-3">Password Set Successfully!</h4>
<p className="mb-0">You will be redirected to the login page shortly.</p>
</div>
</div>
</div>
</div>
</div>
<p className="text-muted small">
<Link to="/login">Go to login now</Link>
</p>
</div>
)
}
+145
View File
@@ -0,0 +1,145 @@
import { type FormEvent, useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { submitPasswordReset } from '../../api/passwordFlow'
import { STRONG_PASSWORD_REGEX } from './passwordValidation'
function togglePw(id: string, iconEl: HTMLElement) {
const input = document.getElementById(id) as HTMLInputElement | null
const icon = iconEl.querySelector('i')
if (!input || !icon) return
if (input.type === 'password') {
input.type = 'text'
icon.classList.remove('fa-eye')
icon.classList.add('fa-eye-slash')
} else {
input.type = 'password'
icon.classList.remove('fa-eye-slash')
icon.classList.add('fa-eye')
}
}
/** CI `user/reset_password.php` — token from email link. */
export function ResetPasswordPage() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token') ?? ''
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [pwErr, setPwErr] = useState(false)
const [matchErr, setMatchErr] = useState(false)
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState(false)
async function onSubmit(e: FormEvent) {
e.preventDefault()
const okPw = STRONG_PASSWORD_REGEX.test(password)
const okMatch = password === confirm
setPwErr(!okPw)
setMatchErr(!okMatch)
if (!okPw || !okMatch || !token) return
try {
await submitPasswordReset({ token, password, pass_confirm: confirm })
setDone(true)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Reset failed.')
}
}
if (!token) {
return (
<div className="registration-form container mt-5 mb-5 text-center">
<div className="alert alert-warning">Missing reset token. Open the link from your email.</div>
<Link to="/forgot-password">Request a new link</Link>
</div>
)
}
if (done) {
return (
<div className="registration-form container mt-5 mb-5 text-center">
<h3 className="text-success">Password updated</h3>
<p>You can sign in with your new password.</p>
<Link className="btn btn-success" to="/login">
Login
</Link>
</div>
)
}
return (
<div className="registration-form container mt-5 mb-5 text-center">
<form onSubmit={(e) => void onSubmit(e)} className="text-start" style={{ maxWidth: 480, margin: '0 auto' }}>
<div className="text-center mb-4">
<Link to="/">
<img
src="/alrahma_logo.png"
alt=""
style={{ width: 180, height: 120, objectFit: 'contain' }}
onError={(ev) => {
;(ev.target as HTMLImageElement).style.display = 'none'
}}
/>
</Link>
</div>
<h3 className="text-center text-success mb-4" style={{ fontFamily: 'Arial, sans-serif' }}>
Reset Your Password
</h3>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="form-group position-relative mb-4">
<input
type="password"
className="form-control pe-5"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter new password"
maxLength={30}
required
autoComplete="new-password"
/>
<span
className="position-absolute top-50 end-0 translate-middle-y me-3"
style={{ cursor: 'pointer' }}
onClick={(e) => togglePw('password', e.currentTarget)}
aria-hidden
>
<i className="fa-solid fa-eye" />
</span>
<small className={`text-danger ${pwErr ? '' : 'd-none'}`}>
Password must be at least 8 characters, include uppercase, lowercase, number, and special character.
</small>
</div>
<div className="form-group position-relative mb-4">
<input
type="password"
className="form-control pe-5"
id="password_confirm"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="Confirm new password"
maxLength={30}
required
autoComplete="new-password"
/>
<span
className="position-absolute top-50 end-0 translate-middle-y me-3"
style={{ cursor: 'pointer' }}
onClick={(e) => togglePw('password_confirm', e.currentTarget)}
aria-hidden
>
<i className="fa-solid fa-eye" />
</span>
<small className={`text-danger ${matchErr ? '' : 'd-none'}`}>Passwords do not match.</small>
</div>
<div className="d-grid mb-3">
<button type="submit" className="btn btn-success">
Reset Password
</button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,141 @@
import { type FormEvent, useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { submitAuthorizedUserPassword } from '../../api/passwordFlow'
import { STRONG_PASSWORD_REGEX } from './passwordValidation'
function togglePw(id: string, wrap: HTMLElement) {
const input = document.getElementById(id) as HTMLInputElement | null
const icon = wrap.querySelector('i')
if (!input || !icon) return
input.type = input.type === 'password' ? 'text' : 'password'
icon.classList.toggle('fa-eye')
icon.classList.toggle('fa-eye-slash')
}
/** CI `user/set_authorized_user_password.php` — path includes user id; token in query or hidden. */
export function SetAuthorizedUserPasswordPage() {
const { userId: userIdParam } = useParams()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const token = searchParams.get('token') ?? ''
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [pwErr, setPwErr] = useState(false)
const [matchErr, setMatchErr] = useState(false)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent) {
e.preventDefault()
const okPw = STRONG_PASSWORD_REGEX.test(password)
const okMatch = password === confirm
setPwErr(!okPw)
setMatchErr(!okMatch)
if (!okPw || !okMatch || !userIdParam || !token) return
try {
await submitAuthorizedUserPassword(userIdParam, {
token,
password,
password_confirm: confirm,
})
navigate('/user/password-set-success', { replace: true })
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save password.')
}
}
if (!userIdParam || !token) {
return (
<div className="container py-5 text-center">
<div className="alert alert-warning">Invalid or expired link.</div>
<Link to="/login">Login</Link>
</div>
)
}
return (
<div className="registration-form container mt-5 mb-5">
<form onSubmit={(e) => void onSubmit(e)} autoComplete="off">
<div className="text-center mb-4">
<Link to="/">
<img
src="/alrahma_logo.png"
alt=""
style={{ width: 180, height: 120, objectFit: 'contain' }}
onError={(ev) => {
;(ev.target as HTMLImageElement).style.display = 'none'
}}
/>
</Link>
</div>
<h3 className="text-center text-success mb-4" style={{ fontFamily: 'Arial, sans-serif' }}>
Create Your Password
</h3>
{error ? <div className="alert alert-danger">{error}</div> : null}
<input type="hidden" name="user_id" value={userIdParam} readOnly />
<input type="hidden" name="token" value={token} readOnly />
<div className="mb-3 position-relative">
<input
type="password"
className="form-control pe-5"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter new password"
maxLength={40}
required
autoComplete="new-password"
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
onPaste={(e) => e.preventDefault()}
/>
<span
className="position-absolute top-50 end-0 translate-middle-y me-3"
style={{ cursor: 'pointer' }}
onClick={(e) => togglePw('password', e.currentTarget)}
aria-hidden
>
<i className="fa-solid fa-eye" />
</span>
<small className={`text-danger ${pwErr ? '' : 'd-none'}`}>
Password must meet complexity requirements (see CI rules).
</small>
</div>
<div className="mb-3 position-relative">
<input
type="password"
className="form-control pe-5"
id="password_confirm"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="Confirm new password"
maxLength={40}
required
autoComplete="new-password"
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
onPaste={(e) => e.preventDefault()}
/>
<span
className="position-absolute top-50 end-0 translate-middle-y me-3"
style={{ cursor: 'pointer' }}
onClick={(e) => togglePw('password_confirm', e.currentTarget)}
aria-hidden
>
<i className="fa-solid fa-eye" />
</span>
<small className={`text-danger ${matchErr ? '' : 'd-none'}`}>Passwords do not match.</small>
</div>
<div className="d-grid">
<button type="submit" className="btn btn-success">
Save Password
</button>
</div>
</form>
</div>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { type FormEvent, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { submitSetPassword } from '../../api/passwordFlow'
import { STRONG_PASSWORD_REGEX } from './passwordValidation'
function togglePw(id: string, wrap: HTMLElement) {
const input = document.getElementById(id) as HTMLInputElement | null
const icon = wrap.querySelector('i')
if (!input || !icon) return
input.type = input.type === 'password' ? 'text' : 'password'
icon.classList.toggle('fa-eye')
icon.classList.toggle('fa-eye-slash')
}
/** CI `user/set_password.php` — `?user_id=&token=` from registration / invite email. */
export function SetPasswordPage() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const userId = searchParams.get('user_id') ?? ''
const token = searchParams.get('token') ?? ''
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [pwErr, setPwErr] = useState(false)
const [matchErr, setMatchErr] = useState(false)
const [error, setError] = useState<string | null>(null)
async function onSubmit(e: FormEvent) {
e.preventDefault()
const okPw = STRONG_PASSWORD_REGEX.test(password)
const okMatch = password === confirm
setPwErr(!okPw)
setMatchErr(!okMatch)
if (!okPw || !okMatch || !userId || !token) return
try {
await submitSetPassword({
user_id: userId,
token,
password,
password_confirm: confirm,
})
navigate('/user/password-set-success', { replace: true })
} catch (err) {
setError(err instanceof Error ? err.message : 'Unable to save password.')
}
}
if (!userId || !token) {
return (
<div className="container py-5 text-center">
<div className="alert alert-warning">Invalid or expired link.</div>
<Link to="/login">Login</Link>
</div>
)
}
return (
<div className="registration-form container mt-5 mb-5">
<form onSubmit={(e) => void onSubmit(e)} autoComplete="off">
<div className="text-center mb-4">
<Link to="/">
<img
src="/alrahma_logo.png"
alt=""
style={{ width: 180, height: 120, objectFit: 'contain' }}
onError={(ev) => {
;(ev.target as HTMLImageElement).style.display = 'none'
}}
/>
</Link>
</div>
<h3 className="text-center text-success mb-4" style={{ fontFamily: 'Arial, sans-serif' }}>
Create Your Password
</h3>
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="mb-3 position-relative">
<input
type="password"
className="form-control pe-5"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter new password"
maxLength={40}
required
autoComplete="new-password"
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
onPaste={(e) => e.preventDefault()}
/>
<span
className="position-absolute top-50 end-0 translate-middle-y me-3"
style={{ cursor: 'pointer' }}
onClick={(e) => togglePw('password', e.currentTarget)}
aria-hidden
>
<i className="fa-solid fa-eye" />
</span>
<small className={`text-danger ${pwErr ? '' : 'd-none'}`}>
Use 8+ chars with upper, lower, number, and a special character @ - = + * # $ % &amp; !
</small>
</div>
<div className="mb-3 position-relative">
<input
type="password"
className="form-control pe-5"
id="password_confirm"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="Confirm new password"
maxLength={40}
required
autoComplete="new-password"
onCopy={(e) => e.preventDefault()}
onCut={(e) => e.preventDefault()}
onPaste={(e) => e.preventDefault()}
/>
<span
className="position-absolute top-50 end-0 translate-middle-y me-3"
style={{ cursor: 'pointer' }}
onClick={(e) => togglePw('password_confirm', e.currentTarget)}
aria-hidden
>
<i className="fa-solid fa-eye" />
</span>
<small className={`text-danger ${matchErr ? '' : 'd-none'}`}>Passwords do not match.</small>
</div>
<div className="d-grid">
<button type="submit" className="btn btn-success">
Save Password
</button>
</div>
</form>
</div>
)
}
+156
View File
@@ -0,0 +1,156 @@
import { type FormEvent, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { createUserRecord } from '../../api/session'
/** CI `user/create.php` — admin create user (expects Laravel `POST /api/v1/users`). */
export function UserCreatePage() {
const navigate = useNavigate()
const [error, setError] = useState<string | null>(null)
const [form, setForm] = useState({
password: '',
firstname: '',
lastname: '',
cellphone: '',
email: '',
address_street: '',
city: '',
state: '',
zip: '',
role: '',
accept_school_policy: false,
})
async function onSubmit(e: FormEvent) {
e.preventDefault()
try {
await createUserRecord({
...form,
accept_school_policy: form.accept_school_policy ? 1 : 0,
})
navigate('/app/user/user-list')
} catch (err) {
setError(err instanceof Error ? err.message : 'Create failed.')
}
}
return (
<div className="container mt-4">
<h1 className="h3 mb-4">Create User</h1>
{error ? <div className="alert alert-danger">{error}</div> : null}
<form onSubmit={(e) => void onSubmit(e)} className="row g-3" style={{ maxWidth: 560 }}>
<div className="col-12">
<label className="form-label">Password</label>
<input
type="password"
className="form-control"
required
value={form.password}
onChange={(e) => setForm((c) => ({ ...c, password: e.target.value }))}
/>
</div>
<div className="col-md-6">
<label className="form-label">First Name</label>
<input
className="form-control"
required
value={form.firstname}
onChange={(e) => setForm((c) => ({ ...c, firstname: e.target.value }))}
/>
</div>
<div className="col-md-6">
<label className="form-label">Last Name</label>
<input
className="form-control"
required
value={form.lastname}
onChange={(e) => setForm((c) => ({ ...c, lastname: e.target.value }))}
/>
</div>
<div className="col-12">
<label className="form-label">Cell Phone</label>
<input
className="form-control"
required
value={form.cellphone}
onChange={(e) => setForm((c) => ({ ...c, cellphone: e.target.value }))}
/>
</div>
<div className="col-12">
<label className="form-label">Email</label>
<input
type="email"
className="form-control"
required
value={form.email}
onChange={(e) => setForm((c) => ({ ...c, email: e.target.value }))}
/>
</div>
<div className="col-12">
<label className="form-label">Address Street</label>
<input
className="form-control"
required
value={form.address_street}
onChange={(e) => setForm((c) => ({ ...c, address_street: e.target.value }))}
/>
</div>
<div className="col-md-6">
<label className="form-label">City</label>
<input
className="form-control"
required
value={form.city}
onChange={(e) => setForm((c) => ({ ...c, city: e.target.value }))}
/>
</div>
<div className="col-md-3">
<label className="form-label">State</label>
<input
className="form-control"
required
value={form.state}
onChange={(e) => setForm((c) => ({ ...c, state: e.target.value }))}
/>
</div>
<div className="col-md-3">
<label className="form-label">Zip</label>
<input
className="form-control"
required
value={form.zip}
onChange={(e) => setForm((c) => ({ ...c, zip: e.target.value }))}
/>
</div>
<div className="col-12">
<label className="form-label">Role</label>
<input
className="form-control"
required
value={form.role}
onChange={(e) => setForm((c) => ({ ...c, role: e.target.value }))}
/>
</div>
<div className="col-12 form-check">
<input
type="checkbox"
className="form-check-input"
id="pol"
checked={form.accept_school_policy}
onChange={(e) => setForm((c) => ({ ...c, accept_school_policy: e.target.checked }))}
/>
<label className="form-check-label" htmlFor="pol">
Accept School Policy
</label>
</div>
<div className="col-12">
<button type="submit" className="btn btn-primary me-2">
Create
</button>
<Link className="btn btn-outline-secondary" to="/app/user/user-list">
Cancel
</Link>
</div>
</form>
</div>
)
}
+359
View File
@@ -0,0 +1,359 @@
import { type FormEvent, useEffect, useState } from 'react'
import { Link, useMatch, useNavigate, useParams } from 'react-router-dom'
import { checkUserEmailAvailable, fetchUser, requestUserEmailChange, saveUser } from '../../api/session'
import { ApiHttpError } from '../../api/http'
import type { UserListRow } from '../../api/types'
import { useAuth } from '../../auth/AuthProvider'
import { isParentPortalRole, isTeacherPortalRole } from '../../lib/portalRoles'
function portalDashboardPath(role: string) {
if (isParentPortalRole(role)) return '/app/parent/home'
if (isTeacherPortalRole(role)) return '/app/teacher_dashboard'
return '/app/home'
}
function sameEmail(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase()
}
const EMAIL_IN_USE_FALLBACK = 'This email is already in use. Please use a different email address.'
function errorMessageFromApi(err: unknown, fallback: string): string {
if (err instanceof ApiHttpError) {
const b = err.body
if (b && typeof b === 'object' && b !== null) {
const o = b as Record<string, unknown>
if (typeof o.message === 'string' && o.message.trim()) return o.message.trim()
const errors = o.errors
if (errors && typeof errors === 'object' && errors !== null) {
const em = (errors as Record<string, unknown>).email
if (Array.isArray(em) && em[0] != null) return String(em[0])
if (typeof em === 'string' && em.trim()) return em.trim()
}
}
if (err.message.trim()) return err.message
}
if (err instanceof Error && err.message.trim()) return err.message
return fallback
}
function rowToForm(row: UserListRow) {
const u = row.user ?? {}
return {
password: '',
firstname: String(u.firstname ?? ''),
lastname: String(u.lastname ?? ''),
cellphone: String(u.cellphone ?? ''),
email: String(u.email ?? ''),
address_street: String(u.address_street ?? ''),
city: String(u.city ?? ''),
state: String(u.state ?? ''),
zip: String(u.zip ?? ''),
}
}
/** CI `user/edit.php` — simplified standalone edit (`POST` parity via API). */
export function UserEditPage() {
const portalSelf = useMatch({ path: '/app/account', end: true })
const { userId } = useParams()
const { user, selectedRole, roles } = useAuth()
const role = selectedRole ?? roles[0] ?? ''
const id = portalSelf ? Number(user?.id ?? 0) : Number(userId ?? 0)
const navigate = useNavigate()
const [error, setError] = useState<string | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [loading, setLoading] = useState(() => id > 0)
const [showPassword, setShowPassword] = useState(false)
/** Email currently stored on the account (from API). Used for portal email-change flow. */
const [verifiedEmail, setVerifiedEmail] = useState('')
const [emailChangeNotice, setEmailChangeNotice] = useState<string | null>(null)
const [form, setForm] = useState({
password: '',
firstname: '',
lastname: '',
cellphone: '',
email: '',
address_street: '',
city: '',
state: '',
zip: '',
})
useEffect(() => {
if (!id) {
setLoading(false)
setLoadError(null)
return
}
let cancelled = false
setLoading(true)
setLoadError(null)
void fetchUser(id)
.then((row) => {
if (!cancelled) {
setShowPassword(false)
const next = rowToForm(row)
setForm(next)
setVerifiedEmail(String(row.user?.email ?? next.email ?? ''))
setEmailChangeNotice(null)
}
})
.catch((err) => {
if (!cancelled) {
setLoadError(err instanceof Error ? err.message : 'Unable to load user.')
}
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [id, reloadKey])
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!id) return
setError(null)
try {
setEmailChangeNotice(null)
const emailChanged = !sameEmail(form.email, verifiedEmail)
const newEmail = form.email.trim()
if (emailChanged) {
try {
const avail = await checkUserEmailAvailable(id, newEmail)
if (avail.available === false) {
setError(
(typeof avail.message === 'string' && avail.message.trim()) || EMAIL_IN_USE_FALLBACK,
)
return
}
} catch (err) {
if (err instanceof ApiHttpError) {
if (err.status === 404) {
/* availability route optional — continue; duplicate may still be rejected below */
} else if (err.status === 422 || err.status === 409) {
setError(errorMessageFromApi(err, EMAIL_IN_USE_FALLBACK))
return
} else {
setError(err.message || 'Could not verify email. Try again.')
return
}
} else {
setError('Could not verify email. Try again.')
return
}
}
}
const payload: Record<string, unknown> = {
firstname: form.firstname,
lastname: form.lastname,
cellphone: form.cellphone,
address_street: form.address_street,
city: form.city,
state: form.state,
zip: form.zip,
}
if (emailChanged) {
if (!portalSelf) payload.email = form.email
} else {
payload.email = form.email
}
if (form.password.trim()) payload.password = form.password
if (portalSelf && emailChanged) {
await requestUserEmailChange(id, newEmail)
await saveUser(id, payload)
setEmailChangeNotice(
`We sent a confirmation link to ${newEmail}. Your sign-in email stays ${verifiedEmail.trim() || 'unchanged'} until you open that link.`,
)
setForm((c) => ({ ...c, email: verifiedEmail, password: '' }))
setShowPassword(false)
return
}
await saveUser(id, payload)
if (portalSelf) {
navigate(portalDashboardPath(role), { replace: true })
} else {
navigate('/app/user/user-list')
}
} catch (err) {
if (err instanceof ApiHttpError && (err.status === 422 || err.status === 409)) {
setError(errorMessageFromApi(err, EMAIL_IN_USE_FALLBACK))
return
}
setError(errorMessageFromApi(err, 'Update failed.'))
}
}
if (portalSelf && !user?.id) {
return (
<div className="container mt-4">
<div className="alert alert-warning">You need to be signed in to open account settings.</div>
<Link to="/login">Log in</Link>
</div>
)
}
return (
<div className="container mt-4">
<h1 className="h3 mb-4">{portalSelf ? 'Account settings' : 'Edit User'}</h1>
<p className="text-muted small">
{portalSelf
? 'Update your profile and password. If you change your email, we send a confirmation link to the new address; it only updates after you click that link.'
: 'Profile fields are loaded from the server. Save to apply changes.'}
</p>
{emailChangeNotice ? (
<div className="alert alert-success" role="status">
{emailChangeNotice}
</div>
) : null}
{loadError ? (
<div
className="alert alert-danger d-flex flex-wrap align-items-center justify-content-between gap-2"
role="alert"
>
<span>{loadError}</span>
<button
type="button"
className="btn btn-outline-danger btn-sm"
onClick={() => setReloadKey((k) => k + 1)}
>
Try again
</button>
</div>
) : null}
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading ? (
<p className="text-muted" aria-busy="true">
Loading profile
</p>
) : null}
{!loading && !loadError ? (
<form onSubmit={(e) => void onSubmit(e)} className="row g-3" style={{ maxWidth: 560 }}>
<div className="col-12">
<label className="form-label" htmlFor="user-edit-password">
New Password (leave blank to keep)
</label>
<div className="input-group">
<input
id="user-edit-password"
type={showPassword ? 'text' : 'password'}
className="form-control"
autoComplete="new-password"
value={form.password}
onChange={(e) => setForm((c) => ({ ...c, password: e.target.value }))}
/>
<button
type="button"
className="btn btn-outline-secondary"
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-pressed={showPassword}
onClick={() => setShowPassword((v) => !v)}
>
<i className={showPassword ? 'bi bi-eye-slash' : 'bi bi-eye'} aria-hidden />
</button>
</div>
</div>
<div className="col-md-6">
<label className="form-label">First Name</label>
<input
className="form-control"
required
value={form.firstname}
onChange={(e) => setForm((c) => ({ ...c, firstname: e.target.value }))}
/>
</div>
<div className="col-md-6">
<label className="form-label">Last Name</label>
<input
className="form-control"
required
value={form.lastname}
onChange={(e) => setForm((c) => ({ ...c, lastname: e.target.value }))}
/>
</div>
<div className="col-12">
<label className="form-label">Cell Phone</label>
<input
className="form-control"
required
value={form.cellphone}
onChange={(e) => setForm((c) => ({ ...c, cellphone: e.target.value }))}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="user-edit-email">
Email
</label>
<input
id="user-edit-email"
type="email"
className="form-control"
required
autoComplete="email"
value={form.email}
onChange={(e) => setForm((c) => ({ ...c, email: e.target.value }))}
/>
{portalSelf ? (
<p className="form-text small mb-0">
Changing this address sends a confirmation link to the new email. It is not saved until you confirm.
</p>
) : null}
</div>
<div className="col-12">
<label className="form-label">Address Street</label>
<input
className="form-control"
required
value={form.address_street}
onChange={(e) => setForm((c) => ({ ...c, address_street: e.target.value }))}
/>
</div>
<div className="col-md-6">
<label className="form-label">City</label>
<input
className="form-control"
required
value={form.city}
onChange={(e) => setForm((c) => ({ ...c, city: e.target.value }))}
/>
</div>
<div className="col-md-3">
<label className="form-label">State</label>
<input
className="form-control"
required
value={form.state}
onChange={(e) => setForm((c) => ({ ...c, state: e.target.value }))}
/>
</div>
<div className="col-md-3">
<label className="form-label">Zip</label>
<input
className="form-control"
required
value={form.zip}
onChange={(e) => setForm((c) => ({ ...c, zip: e.target.value }))}
/>
</div>
<div className="col-12">
<button type="submit" className="btn btn-primary me-2">
Update
</button>
<Link
className="btn btn-outline-secondary"
to={portalSelf ? portalDashboardPath(role) : '/app/user/user-list'}
>
Cancel
</Link>
</div>
</form>
) : null}
</div>
)
}
+719
View File
@@ -0,0 +1,719 @@
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { fetchUsers, saveUser } from '../../api/session'
import type { UserListRow } from '../../api/types'
function dash(v: unknown): string {
const s = (v ?? '').toString().trim()
return s === '' ? '—' : s
}
function formatDateTimeLocal(value: unknown): string {
if (value == null || value === '') return ''
const normalized = String(value).trim().replace(' ', 'T')
const date = new Date(normalized)
if (Number.isNaN(date.valueOf())) {
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})/)
return match ? `${match[1]}T${match[2]}` : ''
}
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function roleLabels(row: UserListRow): string {
const roles = row.roles
if (!roles?.length) return 'No Role Assigned'
const parts = roles
.map((r) => {
if (typeof r === 'string') return r.trim()
return String(r.name ?? r.slug ?? '').trim()
})
.filter(Boolean)
return parts.length ? parts.join(', ') : 'No Role Assigned'
}
function isParentRole(row: UserListRow): boolean {
return (row.roles ?? []).some((r) => {
const s =
typeof r === 'string' ? r.toLowerCase() : `${r.name ?? ''} ${r.slug ?? ''}`.toLowerCase()
return s.includes('parent')
})
}
type EditFormState = {
id: number
account_id: string
firstname: string
lastname: string
gender: string
cellphone: string
email: string
address_street: string
apt: string
city: string
state: string
zip: string
accept_school_policy: boolean
user_type: string
rfid_tag: string
school_id: string
failed_attempts: string
last_failed_at: string
semester: string
school_year: string
status: string
is_suspended: boolean
is_verified: boolean
token: string
updated_at: string
created_at: string
}
function userToForm(user: Record<string, unknown>, id: number): EditFormState {
const g = (user.gender ?? '').toString().toLowerCase()
return {
id,
account_id: String(user.account_id ?? ''),
firstname: String(user.firstname ?? ''),
lastname: String(user.lastname ?? ''),
gender: g === 'male' || g === 'female' ? g : '',
cellphone: String(user.cellphone ?? ''),
email: String(user.email ?? ''),
address_street: String(user.address_street ?? ''),
apt: String(user.apt ?? ''),
city: String(user.city ?? ''),
state: String(user.state ?? ''),
zip: String(user.zip ?? ''),
accept_school_policy: Boolean(
user.accept_school_policy === true ||
user.accept_school_policy === 1 ||
user.accept_school_policy === '1',
),
user_type: String(user.user_type ?? ''),
rfid_tag: String(user.rfid_tag ?? ''),
school_id: String(user.school_id ?? ''),
failed_attempts: String(user.failed_attempts ?? ''),
last_failed_at: formatDateTimeLocal(user.last_failed_at),
semester: String(user.semester ?? ''),
school_year: String(user.school_year ?? ''),
status: String(user.status ?? ''),
is_suspended: Boolean(
user.is_suspended === true || user.is_suspended === 1 || user.is_suspended === '1',
),
is_verified: Boolean(
user.is_verified === true || user.is_verified === 1 || user.is_verified === '1',
),
token: String(user.token ?? ''),
updated_at: formatDateTimeLocal(user.updated_at),
created_at: formatDateTimeLocal(user.created_at),
}
}
function formToPayload(f: EditFormState): Record<string, unknown> {
const payload: Record<string, unknown> = {
firstname: f.firstname.trim(),
lastname: f.lastname.trim(),
accept_school_policy: f.accept_school_policy ? 1 : 0,
is_suspended: f.is_suspended ? 1 : 0,
is_verified: f.is_verified ? 1 : 0,
}
const normalizedGender = f.gender.trim().toLowerCase()
const normalizedUserType = f.user_type.trim().toLowerCase()
const normalizedStatus = f.status.trim().toLowerCase()
const assignIfPresent = (key: string, value: string, opts?: { nullable?: boolean }) => {
const trimmed = value.trim()
if (trimmed !== '') {
payload[key] = trimmed
return
}
if (opts?.nullable) {
payload[key] = null
}
}
assignIfPresent('account_id', f.account_id, { nullable: true })
assignIfPresent('cellphone', f.cellphone)
assignIfPresent('email', f.email)
assignIfPresent('address_street', f.address_street)
assignIfPresent('apt', f.apt, { nullable: true })
assignIfPresent('city', f.city)
assignIfPresent('state', f.state)
assignIfPresent('zip', f.zip)
assignIfPresent('rfid_tag', f.rfid_tag, { nullable: true })
assignIfPresent('last_failed_at', f.last_failed_at, { nullable: true })
assignIfPresent('semester', f.semester)
assignIfPresent('school_year', f.school_year, { nullable: true })
assignIfPresent('token', f.token, { nullable: true })
if (normalizedGender === 'male' || normalizedGender === 'female') {
payload.gender = normalizedGender
} else if (normalizedGender === '') {
payload.gender = null
}
if (normalizedUserType === 'primary' || normalizedUserType === 'secondary' || normalizedUserType === 'tertiary') {
payload.user_type = normalizedUserType
}
if (normalizedStatus === 'active' || normalizedStatus === 'inactive') {
payload.status = normalizedStatus === 'active' ? 'Active' : 'Inactive'
}
const schoolId = f.school_id.trim()
if (schoolId !== '') {
if (/^\d+$/.test(schoolId)) {
payload.school_id = Number(schoolId)
}
} else {
payload.school_id = null
}
const failedAttempts = f.failed_attempts.trim()
payload.failed_attempts = failedAttempts === '' ? null : Number(failedAttempts)
return payload
}
function buildChangedPayload(
current: EditFormState,
original: EditFormState | null,
): Record<string, unknown> {
const currentPayload = formToPayload(current)
if (!original) return currentPayload
const originalPayload = formToPayload(original)
const changed: Record<string, unknown> = {}
for (const [key, value] of Object.entries(currentPayload)) {
if (!Object.is(value, originalPayload[key])) {
changed[key] = value
}
}
return changed
}
/** CI `user/user_list.php` — DataTable-style list + full edit modal (`saveUser`). */
export function UserListPage() {
const [rows, setRows] = useState<UserListRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [saveError, setSaveError] = useState<string | null>(null)
const [form, setForm] = useState<EditFormState | null>(null)
const [originalForm, setOriginalForm] = useState<EditFormState | null>(null)
const [saving, setSaving] = useState(false)
const modalRef = useRef<HTMLDivElement | null>(null)
async function load() {
setLoading(true)
try {
const data = await fetchUsers({ sort: 'lastname', order: 'asc' })
setRows(data.users ?? [])
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load users.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
const modalTitle = useMemo(() => {
if (!form) return 'Edit User'
const name = [form.firstname, form.lastname].filter(Boolean).join(' ').trim()
return name ? `Edit User — ${name}` : 'Edit User'
}, [form])
useEffect(() => {
if (!form) return
const el = modalRef.current
const B = (
window as unknown as {
bootstrap?: { Modal: { getOrCreateInstance: (n: HTMLElement) => { show: () => void } } }
}
).bootstrap
if (el && B?.Modal?.getOrCreateInstance) {
B.Modal.getOrCreateInstance(el).show()
}
}, [form])
function openEdit(entry: UserListRow) {
const user = entry.user ?? {}
const id = Number(user.id ?? 0)
if (!id) return
const nextForm = userToForm(user as Record<string, unknown>, id)
setSaveError(null)
setOriginalForm(nextForm)
setForm(nextForm)
}
function closeModal() {
const el = modalRef.current
const Inst = (
window as unknown as {
bootstrap?: { Modal: { getInstance: (n: HTMLElement) => { hide: () => void } | null } }
}
).bootstrap?.Modal
if (el && Inst?.getInstance) {
Inst.getInstance(el)?.hide()
}
setForm(null)
setOriginalForm(null)
}
async function onSubmit(e: FormEvent) {
e.preventDefault()
if (!form) return
const payload = buildChangedPayload(form, originalForm)
if (Object.keys(payload).length === 0) {
setSaveError(null)
closeModal()
return
}
setSaving(true)
setSaveError(null)
try {
await saveUser(form.id, payload)
await load()
closeModal()
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Save failed.')
} finally {
setSaving(false)
}
}
return (
<div className="container-fluid py-3">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<h2 className="text-center mt-2 mb-0 flex-grow-1">User List</h2>
<Link className="btn btn-success btn-sm" to="/app/user/create">
Create user
</Link>
</div>
{error ? (
<div id="userListAlert" className="alert alert-danger" role="alert">
{error}
</div>
) : null}
<div className="table-responsive">
<table className="display table table-striped table-bordered align-middle" style={{ width: '100%' }}>
<thead className="table-dark">
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
<th>Phone</th>
<th>User Type</th>
<th>Roles</th>
<th style={{ width: 140 }}>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={8} className="text-center text-muted">
Loading users
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={8} className="text-center text-muted">
No users found.
</td>
</tr>
) : (
rows.map((entry) => {
const user = entry.user ?? {}
const uid = Number(user.id ?? 0)
const idDisplay =
user.school_id != null && String(user.school_id).trim() !== ''
? String(user.school_id)
: String(user.id ?? '')
const parent = isParentRole(entry) && uid > 0
const familyHref = `/app/administrator/family?guardian_id=${encodeURIComponent(String(uid))}`
return (
<tr key={uid || idDisplay}>
<td>{dash(idDisplay)}</td>
<td>
{parent ? (
<Link className="text-decoration-none" to={familyHref}>
{dash(user.firstname)}
</Link>
) : (
dash(user.firstname)
)}
</td>
<td>
{parent ? (
<Link className="text-decoration-none" to={familyHref}>
{dash(user.lastname)}
</Link>
) : (
dash(user.lastname)
)}
</td>
<td>
{user.email ? (
<a className="text-decoration-none" href={`mailto:${String(user.email)}`}>
{String(user.email)}
</a>
) : (
'—'
)}
</td>
<td>
{user.cellphone ? (
<a className="text-decoration-none" href={`tel:${String(user.cellphone)}`}>
{String(user.cellphone)}
</a>
) : (
'—'
)}
</td>
<td>{dash(user.user_type)}</td>
<td>{roleLabels(entry)}</td>
<td>
<button
type="button"
className="btn btn-sm btn-primary"
disabled={!uid}
onClick={() => openEdit(entry)}
>
<i className="fas fa-edit" aria-hidden /> Edit
</button>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{form ? (
<div
className="modal fade"
id="editUserModal"
tabIndex={-1}
ref={modalRef}
aria-labelledby="editUserModalLabel"
aria-hidden="true"
>
<div className="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<form className="modal-content" onSubmit={(e) => void onSubmit(e)}>
<div className="modal-header bg-primary text-white">
<h5 className="modal-title" id="editUserModalLabel">
{modalTitle}
</h5>
<button
type="button"
className="btn-close btn-close-white"
aria-label="Close"
onClick={() => closeModal()}
/>
</div>
<div className="modal-body">
{saveError ? <div className="alert alert-danger">{saveError}</div> : null}
<div className="row g-3">
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Account ID</label>
<input
type="text"
name="account_id"
className="form-control"
value={form.account_id}
onChange={(e) => setForm((c) => (c ? { ...c, account_id: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">First Name</label>
<input
type="text"
name="firstname"
className="form-control"
required
value={form.firstname}
onChange={(e) => setForm((c) => (c ? { ...c, firstname: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Last Name</label>
<input
type="text"
name="lastname"
className="form-control"
required
value={form.lastname}
onChange={(e) => setForm((c) => (c ? { ...c, lastname: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Gender</label>
<select
name="gender"
className="form-select"
value={form.gender}
onChange={(e) => setForm((c) => (c ? { ...c, gender: e.target.value } : c))}
>
<option value=""></option>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
<div className="mb-3">
<label className="form-label">Phone</label>
<input
type="text"
name="cellphone"
className="form-control"
value={form.cellphone}
onChange={(e) => setForm((c) => (c ? { ...c, cellphone: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Email</label>
<input
type="email"
name="email"
className="form-control"
required
value={form.email}
onChange={(e) => setForm((c) => (c ? { ...c, email: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Address Street</label>
<input
type="text"
name="address_street"
className="form-control"
value={form.address_street}
onChange={(e) => setForm((c) => (c ? { ...c, address_street: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Apt</label>
<input
type="text"
name="apt"
className="form-control"
value={form.apt}
onChange={(e) => setForm((c) => (c ? { ...c, apt: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">City</label>
<input
type="text"
name="city"
className="form-control"
value={form.city}
onChange={(e) => setForm((c) => (c ? { ...c, city: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">State</label>
<input
type="text"
name="state"
maxLength={2}
className="form-control"
value={form.state}
onChange={(e) => setForm((c) => (c ? { ...c, state: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">ZIP</label>
<input
type="text"
name="zip"
className="form-control"
value={form.zip}
onChange={(e) => setForm((c) => (c ? { ...c, zip: e.target.value } : c))}
/>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="form-label d-block">Accept School Policy</label>
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={form.accept_school_policy}
onChange={(e) =>
setForm((c) => (c ? { ...c, accept_school_policy: e.target.checked } : c))
}
/>
<label className="form-check-label">Accepted</label>
</div>
</div>
<div className="mb-3">
<label className="form-label">User Type</label>
<input
type="text"
name="user_type"
className="form-control"
value={form.user_type}
onChange={(e) => setForm((c) => (c ? { ...c, user_type: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">RFID Tag</label>
<input
type="text"
name="rfid_tag"
className="form-control"
value={form.rfid_tag}
onChange={(e) => setForm((c) => (c ? { ...c, rfid_tag: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">School ID</label>
<input
type="text"
name="school_id"
className="form-control"
value={form.school_id}
onChange={(e) => setForm((c) => (c ? { ...c, school_id: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Failed Attempts</label>
<input
type="number"
name="failed_attempts"
className="form-control"
value={form.failed_attempts}
onChange={(e) => setForm((c) => (c ? { ...c, failed_attempts: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Last Failed At</label>
<input
type="datetime-local"
name="last_failed_at"
className="form-control"
value={form.last_failed_at}
onChange={(e) => setForm((c) => (c ? { ...c, last_failed_at: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Semester</label>
<input
type="text"
name="semester"
className="form-control"
value={form.semester}
onChange={(e) => setForm((c) => (c ? { ...c, semester: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">School Year</label>
<input
type="text"
name="school_year"
className="form-control"
value={form.school_year}
onChange={(e) => setForm((c) => (c ? { ...c, school_year: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Status</label>
<input
type="text"
name="status"
className="form-control"
value={form.status}
onChange={(e) => setForm((c) => (c ? { ...c, status: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label d-block">Is Suspended</label>
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={form.is_suspended}
onChange={(e) =>
setForm((c) => (c ? { ...c, is_suspended: e.target.checked } : c))
}
/>
<label className="form-check-label">Suspended</label>
</div>
</div>
<div className="mb-3">
<label className="form-label d-block">Is Verified</label>
<div className="form-check form-switch">
<input
className="form-check-input"
type="checkbox"
checked={form.is_verified}
onChange={(e) =>
setForm((c) => (c ? { ...c, is_verified: e.target.checked } : c))
}
/>
<label className="form-check-label">Verified</label>
</div>
</div>
<div className="mb-3">
<label className="form-label">Token</label>
<input
type="text"
name="token"
className="form-control"
value={form.token}
onChange={(e) => setForm((c) => (c ? { ...c, token: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Updated At</label>
<input
type="datetime-local"
name="updated_at"
className="form-control"
value={form.updated_at}
onChange={(e) => setForm((c) => (c ? { ...c, updated_at: e.target.value } : c))}
/>
</div>
<div className="mb-3">
<label className="form-label">Created At</label>
<input
type="datetime-local"
name="created_at"
className="form-control"
value={form.created_at}
onChange={(e) => setForm((c) => (c ? { ...c, created_at: e.target.value } : c))}
/>
</div>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => closeModal()}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
) : null}
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { Link } from 'react-router-dom'
import { useAuth } from '../../auth/AuthProvider'
import { useContinueWithRole } from '../../hooks/useContinueWithRole'
/** CI `user/welcome_back.php` — multi-role picker (same behavior as `/app/select-role`). */
export function WelcomeBackPage() {
const { user, roles } = useAuth()
const { continueWithRole, busy, busyRole, error } = useContinueWithRole()
return (
<div className="container-fluid py-4 px-3">
<div className="row g-4 justify-content-center align-items-stretch">
<div className="col-12 col-md-5 col-lg-4 d-flex flex-column align-items-center justify-content-center border rounded p-4 bg-light">
<img
src="/images/logo.png"
alt=""
className="img-fluid mb-3"
style={{ maxWidth: 200 }}
onError={(ev) => {
;(ev.target as HTMLImageElement).style.display = 'none'
}}
/>
<div className="fw-semibold text-center">Al Rahma Sunday School</div>
</div>
<div className="col-12 col-md-6 col-lg-5">
<div className="border rounded p-4 shadow-sm h-100">
<h2 className="h4 mb-3">Welcome back!</h2>
<p className="text-muted small mb-4">
As your account has multiple roles in the portal, select how you want to continue:
</p>
<div className="d-flex flex-column gap-2">
{roles.map((role) => {
const label = role.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
return (
<button
key={role}
type="button"
className="btn btn-success"
disabled={busy}
onClick={() => void continueWithRole(role)}
>
{busy && busyRole === role ? 'Opening…' : label}
</button>
)
})}
</div>
{user?.name ? (
<p className="small text-muted mt-3 mb-0">Signed in as {user.name}</p>
) : null}
{error ? <div className="alert alert-danger mt-3 mb-0 py-2 small">{error}</div> : null}
<div className="mt-4 text-center">
<Link className="small" to="/app/select-role">
Alternative layout (same action)
</Link>
</div>
</div>
</div>
</div>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
/** Matches CI `user/reset_password.php` / `set_password.php` client validation. */
export const STRONG_PASSWORD_REGEX =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\-=+*#$%&!?])[A-Za-z\d@\-=+*#$%&!?]{8,}$/
export function passwordsMatch(a: string, b: string): boolean {
return a === b
}