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(null) const [creating, setCreating] = useState(false) const [form, setForm] = useState(EMPTY) const [errors, setErrors] = useState>({}) /** 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>('/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 {user.email} 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: ( <>

Konto {user.email} zostanie usunięte trwale, bez możliwości przywrócenia.

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.

), 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 {user.email} 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 {user.email} 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 = {} 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 if (query.isError) { return } return (

Użytkownicy

Konta administracyjne panelu

{resetPassword && (

Nowe hasło dla {resetPassword.email}

{resetPassword.password}

Hasło jest pokazane tylko raz. Przekaż je użytkownikowi, będzie musiał je zmienić.

)} {(creating || editing) && (
setForm((c) => ({ ...c, email: e.target.value }))} required /> setForm((c) => ({ ...c, fullName: e.target.value }))} required /> setForm((c) => ({ ...c, password: e.target.value }))} autoComplete="new-password" />
)}
{/* na telefonie karty, tabela dopiero od duzego ekranu */}
    {query.data.content.map((user) => { const isSelf = user.id === currentUser?.id return (
  • {user.fullName} {isSelf && (to Ty)}

    {user.email}

    {ROLE_LABEL[user.role]}
    {user.active ? ( Aktywne ) : ( Nieaktywne )} {user.mustChangePassword && wymaga zmiany hasła}
    startEdit(user)} /> handleReset(user)} disabled={reset.isPending} /> {!user.active && ( handleActivate(user)} disabled={activate.isPending} className="text-ok-600 hover:bg-ok-50" /> )} {user.active && !isSelf && ( handleDeactivate(user)} disabled={deactivate.isPending} className="text-alarm-600 hover:bg-alarm-50" /> )} {!isSelf && ( handleDelete(user)} disabled={remove.isPending} className="text-alarm-600 hover:bg-alarm-50" /> )}
  • ) })}
{query.data.content.map((user) => { const isSelf = user.id === currentUser?.id return ( ) })}
Konto Rola Stan Ostatnie logowanie Akcje

{user.fullName} {isSelf && (to Ty)}

{user.email}

{ROLE_LABEL[user.role]} {user.active ? ( Aktywne ) : ( Nieaktywne )} {user.mustChangePassword && (

wymaga zmiany hasła

)}
{formatLocal(user.lastLoginAt)}
{!user.active && ( )} {user.active && !isSelf && ( )} {!isSelf && ( )}
{query.data && ( )}
) }