305b1e6144
Deploy PROD / deploy (push) Successful in 26s
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>
531 lines
20 KiB
TypeScript
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>
|
|
)
|
|
}
|