Usuwanie kontaktow, blokowanie i usuwanie kont
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>
This commit is contained in:
2026-09-05 08:40:16 +02:00
parent 0292b03d5e
commit 305b1e6144
8 changed files with 407 additions and 57 deletions
@@ -7,6 +7,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -60,6 +61,26 @@ public class UserAdminController {
return userService.deactivate(id, principal); return userService.deactivate(id, principal);
} }
@PostMapping("/{id}/activate")
@Operation(summary = "Przywrocenie dostepu zablokowanemu kontu")
public UserView activate(@PathVariable Long id) {
return userService.activate(id);
}
/**
* Trwale usuniecie konta.
*
* Osobne od dezaktywacji, bo to inna decyzja: dezaktywacja odbiera dostep
* i zostawia slad, usuniecie zabiera konto z listy. Praca uzytkownika
* zostaje w obu przypadkach.
*/
@DeleteMapping("/{id}")
@Operation(summary = "Trwale usuniecie konta")
public void delete(@PathVariable Long id,
@AuthenticationPrincipal AuthenticatedUser principal) {
userService.delete(id, principal);
}
@PostMapping("/{id}/reset-password") @PostMapping("/{id}/reset-password")
@Operation(summary = "Reset hasla. Nowe haslo zwracane jeden raz.") @Operation(summary = "Reset hasla. Nowe haslo zwracane jeden raz.")
public ResetPasswordResponse resetPassword(@PathVariable Long id) { public ResetPasswordResponse resetPassword(@PathVariable Long id) {
@@ -116,6 +116,59 @@ public class UserService {
return AuthService.toView(userRepository.save(user)); return AuthService.toView(userRepository.save(user));
} }
/**
* Przywrocenie dostepu wczesniej zablokowanemu kontu.
*
* Dezaktywacja jest odwracalna wlasnie po to: konto zablokowane na czas
* urlopu albo do wyjasnienia ma dac sie wlaczyc z powrotem, bez zakladania
* nowego i bez tracenia sladu w dzienniku.
*/
@Transactional
public UserView activate(Long id) {
User user = require(id);
user.setActive(true);
log.info("Aktywowano konto {}", user.getEmail());
return AuthService.toView(userRepository.save(user));
}
/**
* Trwale usuniecie konta.
*
* KIEDY ZAMIAST DEZAKTYWACJI
* -----------------------------------------------------------------------
* Dezaktywacja odbiera dostep, ale zostawia konto na liscie i w dzienniku.
* Usuniecie ma sens przy koncie zalozonym przez pomylke albo dla osoby,
* ktora nigdy nie powinna go dostac. Przy zwyklym odejsciu pracownika
* lepsza jest dezaktywacja: zostaje wtedy widoczne, kto co zrobil.
*
* CO SIE DZIEJE Z JEGO PRACA
* -----------------------------------------------------------------------
* Nic nie przepada. Pliki w bibliotece i ogloszone zadania maja
* `ON DELETE SET NULL` na kolumnie autora, wiec zostaja na miejscu,
* tracac jedynie informacje o tym, kto je dodal. Kasowanie kaskadowe
* zabieraloby tresc szkolen razem z kontem, ktore ja wprowadzilo.
*
* TE SAME DWIE BARIERY CO PRZY DEZAKTYWACJI
* -----------------------------------------------------------------------
* 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.
*/
@Transactional
public void delete(Long id, AuthenticatedUser actor) {
User user = require(id);
if (user.getId().equals(actor.id())) {
throw new ValidationException("Nie można usunąć własnego konta");
}
if (user.getRole() == UserRole.ADMIN && user.isActive()) {
assertNotLastActiveAdmin(user, "To ostatnie aktywne konto administratora, nie można go usunąć");
}
userRepository.delete(user);
log.info("Usunieto konto {}", user.getEmail());
}
/** Reset hasla przez administratora. Nowe haslo pokazywane jeden raz. */ /** Reset hasla przez administratora. Nowe haslo pokazywane jeden raz. */
@Transactional @Transactional
public ResetPasswordResponse resetPassword(Long id) { public ResetPasswordResponse resetPassword(Long id) {
@@ -86,6 +86,19 @@ public class ContactAdminController {
return contacts.update(id, request); return contacts.update(id, request);
} }
/**
* Co przepadnie razem z kontaktem.
*
* Panel pobiera to PRZED pytaniem o potwierdzenie, tak samo jak przy
* usuwaniu webinaru: liczby zamieniaja "czy na pewno?" w decyzje opartą
* na tym, co faktycznie zniknie.
*/
@GetMapping("/{id}/deletion-impact")
@Operation(summary = "Co przepadnie razem z kontaktem")
public ContactService.DeletionImpact deletionImpact(@PathVariable Long id) {
return contacts.deletionImpact(id);
}
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@Operation(summary = "Usuniecie kontaktu") @Operation(summary = "Usuniecie kontaktu")
public ResponseEntity<Void> delete(@PathVariable Long id) { public ResponseEntity<Void> delete(@PathVariable Long id) {
@@ -56,17 +56,20 @@ public class ContactService {
private final ContactGroupMemberRepository memberRepository; private final ContactGroupMemberRepository memberRepository;
private final ConsentRepository consentRepository; private final ConsentRepository consentRepository;
private final SettingsService settings; private final SettingsService settings;
private final com.softspm.webinar.repository.EnrollmentRepository enrollmentRepository;
public ContactService(ContactRepository contactRepository, public ContactService(ContactRepository contactRepository,
ContactGroupRepository groupRepository, ContactGroupRepository groupRepository,
ContactGroupMemberRepository memberRepository, ContactGroupMemberRepository memberRepository,
ConsentRepository consentRepository, ConsentRepository consentRepository,
SettingsService settings) { SettingsService settings,
com.softspm.webinar.repository.EnrollmentRepository enrollmentRepository) {
this.contactRepository = contactRepository; this.contactRepository = contactRepository;
this.groupRepository = groupRepository; this.groupRepository = groupRepository;
this.memberRepository = memberRepository; this.memberRepository = memberRepository;
this.consentRepository = consentRepository; this.consentRepository = consentRepository;
this.settings = settings; this.settings = settings;
this.enrollmentRepository = enrollmentRepository;
} }
// --- odczyt --- // --- odczyt ---
@@ -171,6 +174,32 @@ public class ContactService {
return get(id); return get(id);
} }
/**
* Co przepadnie razem z kontaktem.
*
* Panel pyta o potwierdzenie DOPIERO Z TYMI LICZBAMI, tak samo jak przy
* usuwaniu webinaru. "Czy na pewno?" bez nich nie daje administratorowi
* niczego, na czym moglby oprzec decyzje: przy zwyklym wpisie
* marketingowym odpowiedz jest zupelnie inna niz przy kursancie
* z oplaconym kursem.
*
* `student` rozstrzyga, czy kontakt ma konto w Akademii. Usuniecie takiego
* kontaktu **odbiera dostep do kursow**, bo zapisy wisza na nim kaskadowo.
* Zamowienia zostaja: sa dokumentem finansowym i maja `ON DELETE SET NULL`.
*/
@Transactional(readOnly = true)
public DeletionImpact deletionImpact(Long id) {
Contact contact = require(id);
return new DeletionImpact(
enrollmentRepository.countByContactId(id),
consentRepository.countByContactId(id),
contact.hasAccount());
}
/** @param student czy kontakt ma konto kursanta, czyli dostep do Akademii */
public record DeletionImpact(long enrollments, long consents, boolean student) {
}
@Transactional @Transactional
public void delete(Long id) { public void delete(Long id) {
Contact contact = require(id); Contact contact = require(id);
@@ -12,6 +12,14 @@ public interface ConsentRepository extends JpaRepository<Consent, Long> {
List<Consent> findByRegistrationId(Long registrationId); List<Consent> findByRegistrationId(Long registrationId);
/**
* Ile zgod ma kontakt.
*
* Uzywane przy ostrzezeniu przed usunieciem: zgody sa sladem rozliczalnym,
* czyli dowodem na to, na jakiej podstawie wolno bylo cokolwiek wyslac.
*/
long countByContactId(Long contactId);
/** Zgody dla wielu zapisow naraz, do listy i eksportu. */ /** Zgody dla wielu zapisow naraz, do listy i eksportu. */
@Query("select c from Consent c where c.registration.id in :registrationIds") @Query("select c from Consent c where c.registration.id in :registrationIds")
List<Consent> findByRegistrationIdIn(@Param("registrationIds") Collection<Long> registrationIds); List<Consent> findByRegistrationIdIn(@Param("registrationIds") Collection<Long> registrationIds);
@@ -43,6 +43,9 @@ public interface EnrollmentRepository extends JpaRepository<Enrollment, Long> {
/** Wszystkie zapisy kursu, bez wzgledu na stan. Do podsumowania skutkow skasowania. */ /** Wszystkie zapisy kursu, bez wzgledu na stan. Do podsumowania skutkow skasowania. */
long countByCourseId(Long courseId); long countByCourseId(Long courseId);
/** Ile zapisow na kursy ma kontakt. Uzywane przy ostrzezeniu przed usunieciem. */
long countByContactId(Long contactId);
/** Zapisani wraz z kontaktem, do listy w panelu i eksportu CSV. */ /** Zapisani wraz z kontaktem, do listy w panelu i eksportu CSV. */
@Query(""" @Query("""
select e from Enrollment e select e from Enrollment e
+136 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { client, errorMessage } from '../../api/client' import { client, errorMessage } from '../../api/client'
import type { Contact, ContactGroup, Page } from '../../api/types' import type { Contact, ContactGroup, Page } from '../../api/types'
import { useConfirm } from '../../components/Confirm'
import { Icon } from '../../components/Icon' import { Icon } from '../../components/Icon'
import { PAGE_SIZE, Pagination } from '../../components/Pagination' import { PAGE_SIZE, Pagination } from '../../components/Pagination'
import { useToasts } from '../../components/Toasts' import { useToasts } from '../../components/Toasts'
@@ -11,6 +12,7 @@ import {
Card, Card,
EmptyState, EmptyState,
ErrorState, ErrorState,
IconButton,
Input, Input,
Select, Select,
Spinner, Spinner,
@@ -33,8 +35,22 @@ const SOURCE_LABEL: Record<string, string> = {
REGISTRATION: 'Z zapisu na webinar', REGISTRATION: 'Z zapisu na webinar',
} }
/**
* Co przepadnie razem z kontaktem.
*
* Pytanie o usuniecie ma nazywac konkretne liczby, a nie samo "czy na pewno":
* kontakt marketingowy bez sladu to zupelnie inna decyzja niz konto kursanta
* z oplaconymi kursami.
*/
type ContactDeletionImpact = {
enrollments: number
consents: number
student: boolean
}
export function ContactListPage() { export function ContactListPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const confirm = useConfirm()
const toasts = useToasts() const toasts = useToasts()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@@ -87,6 +103,85 @@ export function ContactListPage() {
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się przypisać do grupy')), onError: (error) => toasts.error(errorMessage(error, 'Nie udało się przypisać do grupy')),
}) })
const remove = useMutation({
mutationFn: (id: number) => client.delete(`/admin/contacts/${id}`),
onSuccess: (_data, id) => {
toasts.success('Kontakt został usunięty')
// zaznaczenie nie moze przezyc usunietego wiersza: masowe przypisanie
// do grupy poszloby wtedy z identyfikatorem, ktorego juz nie ma
setSelected((current) => current.filter((x) => x !== id))
queryClient.invalidateQueries({ queryKey: ['contacts'] })
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
},
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć kontaktu')),
})
/**
* Pytanie o usuniecie zlozone z tego, co naprawde przepadnie.
*
* Impact pobieramy przed pokazaniem okna, bo dopiero on rozstrzyga, czy
* chodzi o zwykly wpis marketingowy, czy o konto kursanta z dostepem do
* oplaconych materialow.
*/
async function handleDelete(contact: Contact) {
let impact: ContactDeletionImpact
try {
const response = await client.get<ContactDeletionImpact>(
`/admin/contacts/${contact.id}/deletion-impact`,
)
impact = response.data
} catch (error) {
toasts.error(errorMessage(error, 'Nie udało się sprawdzić, co zniknie razem z kontaktem'))
return
}
const fullName = `${contact.firstName} ${contact.lastName}`.trim()
const ok = await confirm({
title: impact.student ? 'Usunąć konto kursanta?' : 'Usunąć kontakt?',
message: (
<>
<p>
Kontakt <strong>{fullName || contact.email}</strong> (
<span className="hx-data">{contact.email}</span>) zostanie usunięty bezpowrotnie.
</p>
{impact.student && (
<p className="mt-2">
To <strong>konto kursanta</strong>. Usunięcie odbiera dostęp do kursów: osoba nie
zaloguje się do Akademii i straci wgląd w materiały oraz certyfikaty.
</p>
)}
{impact.enrollments > 0 && (
<p className="mt-2">
Przepadnie <strong>{impact.enrollments}</strong>{' '}
{plural(impact.enrollments, 'zapis na kurs', 'zapisy na kursy', 'zapisów na kursy')}{' '}
wraz z postępem w nauce.
</p>
)}
{impact.consents > 0 && (
<p className="mt-2">
Zniknie także historia zgód (<strong>{impact.consents}</strong>{' '}
{plural(impact.consents, 'wpis', 'wpisy', 'wpisów')}). To ślad rozliczalny, czyli
dowód podstawy wysyłki.
</p>
)}
{impact.student && (
<p className="mt-2 text-slate-500">
Zamówienia zostają: to dokumenty finansowe i przeżywają usunięcie konta.
</p>
)}
</>
),
confirmLabel: impact.student ? 'Usuń konto kursanta' : 'Usuń kontakt',
destructive: true,
})
if (ok) remove.mutate(contact.id)
}
async function handleExport() { async function handleExport() {
setExporting(true) setExporting(true)
try { try {
@@ -262,6 +357,20 @@ export function ContactListPage() {
{contact.company && ( {contact.company && (
<p className="text-sm text-slate-500">{contact.company}</p> <p className="text-sm text-slate-500">{contact.company}</p>
)} )}
<div className="flex justify-end gap-1">
<IconButton
icon="pencil"
label={`Edytuj kontakt ${contact.email}`}
onClick={() => setEditing(contact)}
/>
<IconButton
icon="trash"
label={`Usuń kontakt ${contact.email}`}
onClick={() => handleDelete(contact)}
disabled={remove.isPending}
className="text-alarm-600 hover:bg-alarm-50"
/>
</div>
</li> </li>
))} ))}
</ul> </ul>
@@ -306,7 +415,7 @@ export function ContactListPage() {
<ConsentBadge contact={contact} /> <ConsentBadge contact={contact} />
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex justify-end"> <div className="flex justify-end gap-1">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -315,6 +424,17 @@ export function ContactListPage() {
> >
Edytuj Edytuj
</Button> </Button>
<Button
size="sm"
variant="ghost"
icon="trash"
onClick={() => handleDelete(contact)}
disabled={remove.isPending}
title={`Usuń kontakt ${contact.email}`}
className="text-alarm-600 hover:bg-alarm-50"
>
<span className="sr-only">Usuń kontakt {contact.email}</span>
</Button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -358,6 +478,21 @@ export function ContactListPage() {
) )
} }
/**
* Polska odmiana rzeczownika po liczbie.
*
* "1 zapis", "2 zapisy", "5 zapisow": bez tego komunikat o skutkach usuniecia
* czytalby sie jak tekst maszyny, a ma byc zdaniem, na ktorym uzytkownik
* opiera decyzje nieodwracalna.
*/
function plural(count: number, one: string, few: string, many: string): string {
if (count === 1) return one
const lastTwo = count % 100
const last = count % 10
if (lastTwo >= 12 && lastTwo <= 14) return many
return last >= 2 && last <= 4 ? few : many
}
/** /**
* Stan zgody jednym spojrzeniem. * Stan zgody jednym spojrzeniem.
* *
+143 -55
View File
@@ -88,6 +88,26 @@ export function UserListPage() {
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dezaktywować konta')), 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({ const reset = useMutation({
mutationFn: async (user: User) => { mutationFn: async (user: User) => {
const response = await client.post<{ password: string; message: string }>( const response = await client.post<{ password: string; message: string }>(
@@ -102,6 +122,83 @@ export function UserListPage() {
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zresetować hasła')), 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) { function startEdit(user: User) {
setEditing(user) setEditing(user)
setCreating(false) setCreating(false)
@@ -275,43 +372,36 @@ export function UserListPage() {
<IconButton <IconButton
icon="key" icon="key"
label="Resetuj hasło" label="Resetuj hasło"
onClick={async () => { onClick={() => handleReset(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)
}}
disabled={reset.isPending} 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 && ( {user.active && !isSelf && (
<IconButton <IconButton
icon="user-off" icon="user-off"
label="Dezaktywuj" label="Dezaktywuj"
onClick={async () => { onClick={() => handleDeactivate(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',
})
if (ok) deactivate.mutate(user.id)
}}
disabled={deactivate.isPending} disabled={deactivate.isPending}
className="text-alarm-600 hover:bg-alarm-50" 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>
</div> </div>
</li> </li>
@@ -374,43 +464,29 @@ export function UserListPage() {
<Button <Button
variant="ghost" variant="ghost"
icon="key" icon="key"
onClick={async () => { onClick={() => handleReset(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)
}}
disabled={reset.isPending} disabled={reset.isPending}
title="Resetuj hasło" title="Resetuj hasło"
> >
<span className="sr-only">Resetuj hasło</span> <span className="sr-only">Resetuj hasło</span>
</Button> </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 && ( {user.active && !isSelf && (
<Button <Button
variant="ghost" variant="ghost"
icon="user-off" icon="user-off"
onClick={async () => { onClick={() => handleDeactivate(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',
})
if (ok) deactivate.mutate(user.id)
}}
disabled={deactivate.isPending} disabled={deactivate.isPending}
title="Dezaktywuj" title="Dezaktywuj"
className="text-alarm-600 hover:bg-alarm-50" className="text-alarm-600 hover:bg-alarm-50"
@@ -418,6 +494,18 @@ export function UserListPage() {
<span className="sr-only">Dezaktywuj</span> <span className="sr-only">Dezaktywuj</span>
</Button> </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> </div>
</td> </td>
</tr> </tr>