fix teacher, parent and admin pages
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 @ - = + * # $ % & !
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user