286 lines
11 KiB
React
286 lines
11 KiB
React
import { useRef, useState } from 'react'
|
||
import CopyableCredential from '../components/CopyableCredential'
|
||
import { Badge, Button, Card, Dialog, EmptyState, Field, PageHeader, Tabs } from '../components/ui'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import { useEmployees } from '../hooks/useEmployees'
|
||
import { useScrollIntoView } from '../hooks/useScrollIntoView'
|
||
import { validateEmployeeForm } from '../lib/employeeForm'
|
||
import './AdminPage.css'
|
||
|
||
const EMPTY_EMPLOYEE = {
|
||
displayName: '',
|
||
email: '',
|
||
password: '',
|
||
shortcode: '',
|
||
isAdmin: false,
|
||
}
|
||
|
||
function Notice({ notice }) {
|
||
if (!notice?.text) return null
|
||
return (
|
||
<div className={`msg ${notice.type === 'error' ? 'err' : 'ok'} admin-notice`} role={notice.type === 'error' ? 'alert' : 'status'}>
|
||
{notice.text}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function AdminPage() {
|
||
const { user, isAdmin } = useAuth()
|
||
const {
|
||
employees,
|
||
loading: employeesLoading,
|
||
error: employeesError,
|
||
createEmployee,
|
||
updateEmployee,
|
||
deleteEmployee,
|
||
} = useEmployees()
|
||
|
||
// Kunden werden unter "Kunden" gepflegt (Anlegen und Loeschen fuer Admins dort)
|
||
const [activeSection, setActiveSection] = useState('employees')
|
||
const [employeeForm, setEmployeeForm] = useState(EMPTY_EMPLOYEE)
|
||
const [editingEmployee, setEditingEmployee] = useState(null)
|
||
const [formOpen, setFormOpen] = useState(false)
|
||
const [formError, setFormError] = useState(null)
|
||
const [employeeBusy, setEmployeeBusy] = useState(false)
|
||
const [createdCredential, setCreatedCredential] = useState(null)
|
||
const [notice, setNotice] = useState(null)
|
||
const errorRef = useRef(null)
|
||
// Hooks stehen vor dem frühen return bei !isAdmin
|
||
useScrollIntoView(errorRef, formError)
|
||
|
||
const showNotice = (type, text) => {
|
||
setNotice({ type, text })
|
||
window.setTimeout(() => setNotice(null), 4500)
|
||
}
|
||
|
||
if (!isAdmin) {
|
||
return (
|
||
<div className="page admin-page">
|
||
<section className="admin-access-denied">
|
||
<span>403</span>
|
||
<h1>Zugriff verweigert</h1>
|
||
<p>Für diesen Bereich ist ein Admin-Account erforderlich.</p>
|
||
</section>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Der Admin-Haken des eigenen Kontos ist gesperrt: man nimmt sich nicht selbst die Rechte
|
||
const editingSelf = Boolean(editingEmployee) && employees.find((item) => item.$id === editingEmployee)?.userId === user?.$id
|
||
|
||
const closeForm = () => {
|
||
setFormOpen(false)
|
||
setFormError(null)
|
||
setEmployeeForm(EMPTY_EMPLOYEE)
|
||
setEditingEmployee(null)
|
||
}
|
||
|
||
const createNew = () => {
|
||
setEmployeeForm(EMPTY_EMPLOYEE)
|
||
setEditingEmployee(null)
|
||
setFormError(null)
|
||
setFormOpen(true)
|
||
}
|
||
|
||
const editEmployee = (employee) => {
|
||
setEditingEmployee(employee.$id)
|
||
setEmployeeForm({
|
||
displayName: employee.displayName || '',
|
||
email: employee.email || '',
|
||
password: '',
|
||
shortcode: employee.shortcode || '',
|
||
isAdmin: Boolean(employee.isAdmin),
|
||
})
|
||
setFormError(null)
|
||
setFormOpen(true)
|
||
}
|
||
|
||
const submitEmployee = async (event) => {
|
||
event.preventDefault()
|
||
const message = validateEmployeeForm(employeeForm, Boolean(editingEmployee))
|
||
if (message) {
|
||
setFormError(message)
|
||
return
|
||
}
|
||
|
||
setFormError(null)
|
||
setEmployeeBusy(true)
|
||
const result = editingEmployee
|
||
? await updateEmployee(editingEmployee, employeeForm)
|
||
: await createEmployee(employeeForm)
|
||
setEmployeeBusy(false)
|
||
if (!result.success) {
|
||
setFormError(result.error || 'Mitarbeiter konnte nicht gespeichert werden.')
|
||
return
|
||
}
|
||
if (!editingEmployee) {
|
||
setCreatedCredential({
|
||
email: employeeForm.email.trim(),
|
||
password: employeeForm.password,
|
||
})
|
||
}
|
||
showNotice('success', editingEmployee ? 'Mitarbeiter aktualisiert.' : 'Mitarbeiter angelegt. Der Login ist sofort aktiv.')
|
||
closeForm()
|
||
}
|
||
|
||
const removeEmployee = async (employee) => {
|
||
if (!window.confirm(`${employee.displayName || employee.email} und dessen Login wirklich löschen?`)) return
|
||
const result = await deleteEmployee(employee.$id)
|
||
showNotice(
|
||
result.success ? 'success' : 'error',
|
||
result.success ? 'Mitarbeiter und Login gelöscht.' : result.error
|
||
)
|
||
}
|
||
|
||
const setField = (key) => (event) => setEmployeeForm({ ...employeeForm, [key]: event.target.value })
|
||
|
||
return (
|
||
<div className="page admin-page">
|
||
<PageHeader
|
||
eyebrow="Systemverwaltung"
|
||
title="Admin"
|
||
subtitle="Mitarbeiter und ihre Zugänge verwalten. Kunden pflegst du unter „Kunden“."
|
||
actions={
|
||
<>
|
||
<span className="muted small">Angemeldet als <strong>{user?.name || user?.email}</strong></span>
|
||
<Button variant="primary" type="button" onClick={createNew}>Mitarbeiter anlegen</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
<Notice notice={notice} />
|
||
|
||
<Tabs
|
||
tabs={[{ id: 'employees', label: `Mitarbeiter (${employees.length})` }]}
|
||
active={activeSection}
|
||
onChange={setActiveSection}
|
||
/>
|
||
|
||
{activeSection === 'employees' && (
|
||
<>
|
||
{createdCredential && (
|
||
<Card
|
||
title="Login wurde angelegt"
|
||
actions={
|
||
<button type="button" className="btn btn-quiet" onClick={() => setCreatedCredential(null)} aria-label="Hinweis schließen">×</button>
|
||
}
|
||
>
|
||
<p className="muted small">Diese Zugangsdaten jetzt sicher weitergeben.</p>
|
||
<div className="credential-list">
|
||
<CopyableCredential value={createdCredential.email} label="E-Mail" />
|
||
<CopyableCredential value={createdCredential.password} secret label="Passwort" />
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
<Card title="Mitarbeiterkonten">
|
||
<p className="muted small admin-lead">Aktive Logins für das Ticketsystem</p>
|
||
{employeesError && <div className="msg err" role="alert">{employeesError}</div>}
|
||
{employeesLoading ? (
|
||
<div className="loading">Mitarbeiter werden geladen …</div>
|
||
) : employees.length === 0 ? (
|
||
<EmptyState title="Noch keine Mitarbeiter" hint="Lege mit „Mitarbeiter anlegen“ den ersten Login an." />
|
||
) : (
|
||
<table className="table">
|
||
<thead>
|
||
<tr>
|
||
<th>Mitarbeiter</th>
|
||
<th>E-Mail</th>
|
||
<th className="hide-sm">Kürzel</th>
|
||
<th className="hide-sm">Rolle</th>
|
||
<th className="hide-sm">User-ID</th>
|
||
<th className="right"><span className="sr-only">Aktionen</span></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{employees.map((employee) => (
|
||
<tr key={employee.$id}>
|
||
<td><strong>{employee.displayName || '–'}</strong></td>
|
||
<td><CopyableCredential value={employee.email} label="E-Mail" /></td>
|
||
<td className="hide-sm"><span className="admin-shortcode">{employee.shortcode || '–'}</span></td>
|
||
<td className="hide-sm">
|
||
{employee.isAdmin ? <Badge tone="ok">Admin</Badge> : <span className="muted">Mitarbeiter</span>}
|
||
</td>
|
||
<td className="hide-sm"><CopyableCredential value={employee.userId} label="User-ID" /></td>
|
||
<td className="right">
|
||
<div className="admin-actions">
|
||
<button type="button" className="link" onClick={() => editEmployee(employee)} aria-label={`${employee.displayName} bearbeiten`}>Bearbeiten</button>
|
||
{employee.userId !== user?.$id && (
|
||
<Button variant="danger" type="button" onClick={() => removeEmployee(employee)} aria-label={`${employee.displayName} löschen`}>Löschen</Button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</Card>
|
||
</>
|
||
)}
|
||
|
||
{formOpen && (
|
||
<Dialog
|
||
as="form"
|
||
title={editingEmployee ? 'Mitarbeiter bearbeiten' : 'Mitarbeiter anlegen'}
|
||
busy={employeeBusy}
|
||
onClose={closeForm}
|
||
onSubmit={submitEmployee}
|
||
footer={
|
||
<>
|
||
<span className="grow" />
|
||
<Button type="button" disabled={employeeBusy} onClick={closeForm}>Abbrechen</Button>
|
||
<Button variant="primary" type="submit" disabled={employeeBusy}>
|
||
{employeeBusy ? 'Speichert …' : editingEmployee ? 'Änderungen speichern' : 'Mitarbeiter anlegen'}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
{formError && <div className="msg err" role="alert" ref={errorRef}>{formError}</div>}
|
||
<p className="muted small">Die User-ID wird automatisch vergeben.</p>
|
||
<Field label="Name" htmlFor="employee-displayName">
|
||
<input className="input" id="employee-displayName" required value={employeeForm.displayName} onChange={setField('displayName')} />
|
||
</Field>
|
||
<Field label="E-Mail" htmlFor="employee-email">
|
||
<input className="input" id="employee-email" type="email" required value={employeeForm.email} onChange={setField('email')} />
|
||
</Field>
|
||
<Field label={editingEmployee ? 'Neues Passwort (optional)' : 'Passwort'} htmlFor="employee-password">
|
||
<input
|
||
className="input"
|
||
id="employee-password"
|
||
type="password"
|
||
autoComplete="new-password"
|
||
required={!editingEmployee}
|
||
value={employeeForm.password}
|
||
onChange={setField('password')}
|
||
placeholder="Mindestens 8 Zeichen"
|
||
/>
|
||
</Field>
|
||
<Field label="Kürzel" htmlFor="employee-shortcode">
|
||
<input
|
||
className="input"
|
||
id="employee-shortcode"
|
||
maxLength={10}
|
||
value={employeeForm.shortcode}
|
||
onChange={(event) => setEmployeeForm({ ...employeeForm, shortcode: event.target.value.toUpperCase() })}
|
||
placeholder="z. B. KNSO"
|
||
/>
|
||
</Field>
|
||
<label className={`check admin-check${editingSelf ? ' is-disabled' : ''}`}>
|
||
<input
|
||
type="checkbox"
|
||
checked={employeeForm.isAdmin}
|
||
disabled={editingSelf}
|
||
onChange={(event) => setEmployeeForm({ ...employeeForm, isAdmin: event.target.checked })}
|
||
/>
|
||
<span>
|
||
<strong>Admin-Account</strong>
|
||
<small>Darf Systemkonfiguration und Benutzer verwalten.</small>
|
||
</span>
|
||
</label>
|
||
</Dialog>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|