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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user