Files
akademiawodociagowca/frontend/src/pages/users/UserListPage.tsx
T
debian 305b1e6144
Deploy PROD / deploy (push) Successful in 26s
Usuwanie kontaktow, blokowanie i usuwanie kont
KONTAKTY
Usuwanie istnialo w API, ale nie mialo przycisku na liscie. Doszedl, wraz
z endpointem `GET /admin/contacts/{id}/deletion-impact`: panel pyta
o potwierdzenie DOPIERO Z LICZBAMI, tak samo jak przy usuwaniu webinaru.

Jest to konieczne, bo usuniecie kontaktu kasuje KASKADOWO zapisy na kursy
i zgody. Przy zwyklym wpisie marketingowym odpowiedz jest zupelnie inna niz
przy kursancie z oplaconym kursem, ktoremu usuniecie odbiera dostep do
materialow. Pytanie mowi to wprost i dodaje, ze zamowienia zostaja: sa
dokumentem finansowym i maja `ON DELETE SET NULL`.

Gdy pobranie skutkow sie nie uda, potwierdzenie NIE JEST pokazywane wcale.
Pytanie o zgode na cos, czego skutkow nie udalo sie ustalic, byloby gorsze
niz brak pytania.

UZYTKOWNICY
Byla sama dezaktywacja. Doszly:

- odblokowanie (`POST /{id}/activate`), bo dezaktywacja jest odwracalna
  z zalozenia: konto zablokowane na czas urlopu ma dac sie wlaczyc bez
  zakladania nowego i bez tracenia sladu w dzienniku,
- usuniecie trwale (`DELETE /{id}`) dla kont zalozonych przez pomylke.

APLIKACJA NIE MOZE ZOSTAC BEZ ADMINISTRATORA
Usuwanie ma te same dwie bariery co dezaktywacja i uzywa TEJ SAMEJ metody
`assertNotLastActiveAdmin`, a nie drugiej kopii reguly: nie mozna usunac
wlasnego konta ani ostatniego aktywnego administratora. Jedno i drugie
konczyloby sie utrata dostepu do panelu bez mozliwosci odzyskania go
z poziomu aplikacji.

PRACA UZYTKOWNIKA NIE PRZEPADA
Wszystkie trzy odwolania do `users` maja `ON DELETE SET NULL`: pliki
w bibliotece, ogloszone zadania i odpowiedzi na pytania zostaja, tracac
jedynie informacje o autorze. Kasowanie kaskadowe zabieraloby tresc szkolen
razem z kontem, ktore ja wprowadzilo.

DEZAKTYWACJA NIE JEST OPERACJA NISZCZACA
Jej potwierdzenie celowo NIE ma czerwonego ostrzezenia: tresc mowi wprost, ze
konto da sie wlaczyc z powrotem, a od teraz jednym klknieciem obok. Czerwien
przeczylaby wlasnemu komunikatowi i rozmydlala sygnal tam, gdzie strata jest
prawdziwa, czyli przy usuwaniu konta i kontaktu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:40:16 +02:00

531 lines
20 KiB
TypeScript

import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage, fieldErrors } from '../../api/client'
import type { Page, User, UserRole } from '../../api/types'
import { useAuth } from '../../auth/AuthContext'
import { useConfirm } from '../../components/Confirm'
import { useToasts } from '../../components/Toasts'
import { Badge, Button, Card, ErrorState, Field, IconButton, Input, PasswordInput, Select, Spinner } from '../../components/ui'
import { formatLocal } from '../../lib/dates'
import { ROLE_LABEL } from '../../lib/labels'
import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
type FormState = {
email: string
fullName: string
role: UserRole
active: boolean
password: string
}
const EMPTY: FormState = { email: '', fullName: '', role: 'MODERATOR', active: true, password: '' }
export function UserListPage() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts()
const { user: currentUser } = useAuth()
const [editing, setEditing] = useState<User | null>(null)
const [creating, setCreating] = useState(false)
const [form, setForm] = useState<FormState>(EMPTY)
const [errors, setErrors] = useState<Record<string, string>>({})
/** Haslo po resecie, pokazywane jeden raz. */
const [resetPassword, setResetPassword] = useState<{ email: string; password: string } | null>(null)
const [page, setPage] = useState(0)
const query = useQuery({
queryKey: ['users', page],
queryFn: async () => {
const response = await client.get<Page<User>>('/admin/users', {
params: { page, size: PAGE_SIZE },
})
return response.data
},
})
function closeForm() {
setCreating(false)
setEditing(null)
setForm(EMPTY)
setErrors({})
}
const save = useMutation({
mutationFn: async (payload: FormState) => {
const body = {
email: payload.email.trim(),
fullName: payload.fullName.trim(),
role: payload.role,
active: payload.active,
// przy edycji puste haslo oznacza brak zmiany
password: payload.password.trim() || undefined,
}
if (editing) {
return client.put(`/admin/users/${editing.id}`, body)
}
return client.post('/admin/users', body)
},
onSuccess: () => {
toasts.success(editing ? 'Konto zostało zaktualizowane' : 'Konto zostało utworzone')
queryClient.invalidateQueries({ queryKey: ['users'] })
closeForm()
},
onError: (error) => {
setErrors(fieldErrors(error))
toasts.error(errorMessage(error, 'Nie udało się zapisać konta'))
},
})
const deactivate = useMutation({
mutationFn: (id: number) => client.post(`/admin/users/${id}/deactivate`),
onSuccess: () => {
toasts.success('Konto zostało dezaktywowane')
queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dezaktywować konta')),
})
const activate = useMutation({
mutationFn: (id: number) => client.post(`/admin/users/${id}/activate`),
onSuccess: () => {
toasts.success('Konto zostało odblokowane')
queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się odblokować konta')),
})
const remove = useMutation({
mutationFn: (id: number) => client.delete(`/admin/users/${id}`),
onSuccess: () => {
toasts.success('Konto zostało usunięte')
queryClient.invalidateQueries({ queryKey: ['users'] })
},
// backend odrzuca usuniecie wlasnego konta i ostatniego aktywnego
// administratora, wiec jego komunikat jest tu wazniejszy niz nasz zapasowy
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć konta')),
})
const reset = useMutation({
mutationFn: async (user: User) => {
const response = await client.post<{ password: string; message: string }>(
`/admin/users/${user.id}/reset-password`,
)
return { email: user.email, password: response.data.password }
},
onSuccess: (data) => {
setResetPassword(data)
queryClient.invalidateQueries({ queryKey: ['users'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zresetować hasła')),
})
/**
* Pytania o operacje na koncie w jednym miejscu.
*
* Widok ma dwa warianty, karty na telefonie i tabele od duzego ekranu.
* Wpisanie tresci osobno w kazdym oznaczaloby, ze poprawka w jednym omija
* drugi i to samo pytanie brzmi inaczej zaleznie od szerokosci okna.
*/
async function handleActivate(user: User) {
const ok = await confirm({
title: 'Odblokować konto?',
message: (
<>
Konto <strong>{user.email}</strong> odzyska dostęp do panelu w zakresie roli{' '}
{ROLE_LABEL[user.role]}. Hasło zostaje bez zmian.
</>
),
confirmLabel: 'Odblokuj konto',
})
if (ok) activate.mutate(user.id)
}
async function handleDelete(user: User) {
const ok = await confirm({
title: 'Usunąć konto?',
message: (
<>
<p>
Konto <strong>{user.email}</strong> zostanie usunięte trwale, bez możliwości
przywrócenia.
</p>
<p className="mt-2">
Jeśli chodzi tylko o odebranie dostępu, lepszym wyborem jest dezaktywacja: konto
zostaje wraz z historią i można je później włączyć z powrotem.
</p>
</>
),
confirmLabel: 'Usuń konto',
destructive: true,
})
if (ok) remove.mutate(user.id)
}
async function handleDeactivate(user: User) {
const ok = await confirm({
title: 'Dezaktywować konto?',
message: (
<>
Konto <strong>{user.email}</strong> straci dostęp do panelu natychmiast, także
w otwartych już sesjach. Można je później włączyć z powrotem.
</>
),
confirmLabel: 'Dezaktywuj konto',
/*
* Bez `destructive`: dezaktywacja jest ODWRACALNA, a od teraz jednym
* kliknieciem, bo obok stoi przycisk odblokowania. Czerwone ostrzezenie
* "tej operacji nie da sie cofnac" przeczyloby zdaniu wyzej, ktore mowi
* wprost, ze konto da sie wlaczyc z powrotem. Ostrzezenie uzyte wszedzie
* przestaje cokolwiek znaczyc tam, gdzie strata jest prawdziwa.
*/
})
if (ok) deactivate.mutate(user.id)
}
async function handleReset(user: User) {
const ok = await confirm({
title: 'Zresetować hasło?',
message: (
<>
Konto <strong>{user.email}</strong> dostanie nowe hasło jednorazowe i będzie musiało je
zmienić przy najbliższym logowaniu.
</>
),
confirmLabel: 'Zresetuj hasło',
})
if (ok) reset.mutate(user)
}
function startEdit(user: User) {
setEditing(user)
setCreating(false)
setErrors({})
setForm({ email: user.email, fullName: user.fullName, role: user.role, active: user.active, password: '' })
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault()
const found: Record<string, string> = {}
if (!form.email.trim()) found.email = 'Adres e-mail jest wymagany'
if (!form.fullName.trim()) found.fullName = 'Imię i nazwisko jest wymagane'
if (!editing && form.password.trim().length < 10) {
found.password = 'Hasło musi mieć co najmniej 10 znaków'
}
if (editing && form.password.trim() && form.password.trim().length < 10) {
found.password = 'Hasło musi mieć co najmniej 10 znaków'
}
setErrors(found)
if (Object.keys(found).length > 0) return
save.mutate(form)
}
if (query.isPending) return <Spinner />
if (query.isError) {
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać listy kont')} />
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-slate-900">Użytkownicy</h1>
<p className="text-sm text-slate-500">Konta administracyjne panelu</p>
</div>
<Button
icon="user-plus"
onClick={() => {
setCreating(true)
setEditing(null)
setForm(EMPTY)
setErrors({})
}}
>
Nowe konto
</Button>
</div>
{resetPassword && (
<div className="rounded-lg border border-warn-500/25 bg-warn-50 p-4">
<div className="flex items-start gap-2">
<Icon name="key" className="text-warn-600" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-warn-600">
Nowe hasło dla {resetPassword.email}
</p>
<p className="mt-1 break-all rounded bg-white px-2 py-1 font-mono text-sm text-slate-900">
{resetPassword.password}
</p>
<p className="mt-1 text-xs text-warn-600">
Hasło jest pokazane tylko raz. Przekaż je użytkownikowi, będzie musiał je zmienić.
</p>
</div>
<button
type="button"
onClick={() => setResetPassword(null)}
className="-m-1 rounded p-1 text-warn-600 transition duration-fast ease-standard hover:text-warn-500"
aria-label="Zamknij"
>
<Icon name="x" size={18} />
</button>
</div>
</div>
)}
{(creating || editing) && (
<Card title={editing ? `Edycja konta: ${editing.email}` : 'Nowe konto'}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Adres e-mail" error={errors.email}>
<Input
type="email"
value={form.email}
onChange={(e) => setForm((c) => ({ ...c, email: e.target.value }))}
required
/>
</Field>
<Field label="Imię i nazwisko" error={errors.fullName}>
<Input
value={form.fullName}
onChange={(e) => setForm((c) => ({ ...c, fullName: e.target.value }))}
required
/>
</Field>
<Field label="Rola" error={errors.role}>
<Select
value={form.role}
onChange={(e) => setForm((c) => ({ ...c, role: e.target.value as UserRole }))}
>
{(Object.keys(ROLE_LABEL) as UserRole[]).map((role) => (
<option key={role} value={role}>
{ROLE_LABEL[role]}
</option>
))}
</Select>
</Field>
<Field
label={editing ? 'Nowe hasło' : 'Hasło'}
error={errors.password}
hint={editing ? 'Puste oznacza brak zmiany' : 'Minimum 10 znaków'}
>
<PasswordInput
value={form.password}
onChange={(e) => setForm((c) => ({ ...c, password: e.target.value }))}
autoComplete="new-password"
/>
</Field>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.active}
onChange={(e) => setForm((c) => ({ ...c, active: e.target.checked }))}
className="h-4 w-4 rounded border-slate-200 text-blue-700 focus:ring-blue-600"
/>
<span className="font-medium text-slate-700">Konto aktywne</span>
</label>
<div className="flex justify-end gap-2">
<Button variant="secondary" type="button" onClick={closeForm}>
Anuluj
</Button>
<Button type="submit" icon="save" disabled={save.isPending}>
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
</Button>
</div>
</form>
</Card>
)}
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
{/* na telefonie karty, tabela dopiero od duzego ekranu */}
<ul className="divide-y divide-slate-100 lg:hidden">
{query.data.content.map((user) => {
const isSelf = user.id === currentUser?.id
return (
<li key={user.id} className="space-y-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-slate-900">
{user.fullName}
{isSelf && <span className="ml-1.5 text-xs text-slate-400">(to Ty)</span>}
</p>
<p className="truncate text-sm text-slate-500">{user.email}</p>
</div>
<Badge tone={user.role === 'ADMIN' ? 'brand' : 'neutral'}>
{ROLE_LABEL[user.role]}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2">
{user.active ? (
<Badge tone="ok" dot>Aktywne</Badge>
) : (
<Badge tone="alarm">Nieaktywne</Badge>
)}
{user.mustChangePassword && <Badge tone="warn">wymaga zmiany hasła</Badge>}
<div className="ml-auto flex gap-1">
<IconButton icon="pencil" label="Edytuj" onClick={() => startEdit(user)} />
<IconButton
icon="key"
label="Resetuj hasło"
onClick={() => handleReset(user)}
disabled={reset.isPending}
/>
{!user.active && (
<IconButton
icon="check"
label="Odblokuj"
onClick={() => handleActivate(user)}
disabled={activate.isPending}
className="text-ok-600 hover:bg-ok-50"
/>
)}
{user.active && !isSelf && (
<IconButton
icon="user-off"
label="Dezaktywuj"
onClick={() => handleDeactivate(user)}
disabled={deactivate.isPending}
className="text-alarm-600 hover:bg-alarm-50"
/>
)}
{!isSelf && (
<IconButton
icon="trash"
label="Usuń konto"
onClick={() => handleDelete(user)}
disabled={remove.isPending}
className="text-alarm-600 hover:bg-alarm-50"
/>
)}
</div>
</div>
</li>
)
})}
</ul>
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<tr>
<th className="px-4 py-3 font-semibold">Konto</th>
<th className="px-4 py-3 font-semibold">Rola</th>
<th className="px-4 py-3 font-semibold">Stan</th>
<th className="px-4 py-3 font-semibold">Ostatnie logowanie</th>
<th className="px-4 py-3 text-right font-semibold">Akcje</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{query.data.content.map((user) => {
const isSelf = user.id === currentUser?.id
return (
<tr key={user.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<p className="font-medium text-slate-900">
{user.fullName}
{isSelf && <span className="ml-1.5 text-xs text-slate-400">(to Ty)</span>}
</p>
<p className="text-xs text-slate-500">{user.email}</p>
</td>
<td className="px-4 py-3">
<Badge
className={
user.role === 'ADMIN'
? 'bg-blue-50 text-blue-700 ring-blue-200'
: 'bg-slate-100 text-slate-700 ring-slate-200'
}
>
{ROLE_LABEL[user.role]}
</Badge>
</td>
<td className="px-4 py-3">
{user.active ? (
<Badge className="bg-ok-50 text-ok-600 ring-ok-500/20">Aktywne</Badge>
) : (
<Badge className="bg-alarm-50 text-alarm-600 ring-alarm-500/20">Nieaktywne</Badge>
)}
{user.mustChangePassword && (
<p className="mt-1 text-xs text-warn-600">wymaga zmiany hasła</p>
)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-slate-600">
{formatLocal(user.lastLoginAt)}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1">
<Button variant="ghost" icon="pencil" onClick={() => startEdit(user)} title="Edytuj">
<span className="sr-only">Edytuj</span>
</Button>
<Button
variant="ghost"
icon="key"
onClick={() => handleReset(user)}
disabled={reset.isPending}
title="Resetuj hasło"
>
<span className="sr-only">Resetuj hasło</span>
</Button>
{!user.active && (
<Button
variant="ghost"
icon="check"
onClick={() => handleActivate(user)}
disabled={activate.isPending}
title="Odblokuj"
className="text-ok-600 hover:bg-ok-50"
>
<span className="sr-only">Odblokuj</span>
</Button>
)}
{user.active && !isSelf && (
<Button
variant="ghost"
icon="user-off"
onClick={() => handleDeactivate(user)}
disabled={deactivate.isPending}
title="Dezaktywuj"
className="text-alarm-600 hover:bg-alarm-50"
>
<span className="sr-only">Dezaktywuj</span>
</Button>
)}
{!isSelf && (
<Button
variant="ghost"
icon="trash"
onClick={() => handleDelete(user)}
disabled={remove.isPending}
title="Usuń konto"
className="text-alarm-600 hover:bg-alarm-50"
>
<span className="sr-only">Usuń konto</span>
</Button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{query.data && (
<Pagination
page={query.data.number}
totalPages={query.data.totalPages}
totalElements={query.data.totalElements}
onChange={setPage}
itemLabel="kont"
/>
)}
</div>
</div>
)
}