Initial commit
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
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 { 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 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 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')),
|
||||
})
|
||||
|
||||
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={() => {
|
||||
if (window.confirm(`Zresetować hasło konta ${user.email}?`)) reset.mutate(user)
|
||||
}}
|
||||
disabled={reset.isPending}
|
||||
/>
|
||||
{user.active && !isSelf && (
|
||||
<IconButton
|
||||
icon="user-off"
|
||||
label="Dezaktywuj"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Dezaktywować konto ${user.email}?`)) deactivate.mutate(user.id)
|
||||
}}
|
||||
disabled={deactivate.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={() => {
|
||||
if (window.confirm(`Zresetować hasło konta ${user.email}?`)) {
|
||||
reset.mutate(user)
|
||||
}
|
||||
}}
|
||||
disabled={reset.isPending}
|
||||
title="Resetuj hasło"
|
||||
>
|
||||
<span className="sr-only">Resetuj hasło</span>
|
||||
</Button>
|
||||
{user.active && !isSelf && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="user-off"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Dezaktywować konto ${user.email}?`)) {
|
||||
deactivate.mutate(user.id)
|
||||
}
|
||||
}}
|
||||
disabled={deactivate.isPending}
|
||||
title="Dezaktywuj"
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
<span className="sr-only">Dezaktywuj</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user