From 305b1e614424b9d60e1faea73e22e474b475a926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Zatorski?= Date: Sat, 5 Sep 2026 08:40:16 +0200 Subject: [PATCH] Usuwanie kontaktow, blokowanie i usuwanie kont 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 --- .../webinar/auth/UserAdminController.java | 21 ++ .../com/softspm/webinar/auth/UserService.java | 53 +++++ .../contact/ContactAdminController.java | 13 ++ .../webinar/contact/ContactService.java | 31 ++- .../webinar/repository/ConsentRepository.java | 8 + .../repository/EnrollmentRepository.java | 3 + .../src/pages/contacts/ContactListPage.tsx | 137 +++++++++++- frontend/src/pages/users/UserListPage.tsx | 198 +++++++++++++----- 8 files changed, 407 insertions(+), 57 deletions(-) diff --git a/backend/src/main/java/com/softspm/webinar/auth/UserAdminController.java b/backend/src/main/java/com/softspm/webinar/auth/UserAdminController.java index 7b96470..489de86 100644 --- a/backend/src/main/java/com/softspm/webinar/auth/UserAdminController.java +++ b/backend/src/main/java/com/softspm/webinar/auth/UserAdminController.java @@ -7,6 +7,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; 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.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -60,6 +61,26 @@ public class UserAdminController { 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") @Operation(summary = "Reset hasla. Nowe haslo zwracane jeden raz.") public ResetPasswordResponse resetPassword(@PathVariable Long id) { diff --git a/backend/src/main/java/com/softspm/webinar/auth/UserService.java b/backend/src/main/java/com/softspm/webinar/auth/UserService.java index 7929aaf..caf91a7 100644 --- a/backend/src/main/java/com/softspm/webinar/auth/UserService.java +++ b/backend/src/main/java/com/softspm/webinar/auth/UserService.java @@ -116,6 +116,59 @@ public class UserService { 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. */ @Transactional public ResetPasswordResponse resetPassword(Long id) { diff --git a/backend/src/main/java/com/softspm/webinar/contact/ContactAdminController.java b/backend/src/main/java/com/softspm/webinar/contact/ContactAdminController.java index 748c4c3..f68582e 100644 --- a/backend/src/main/java/com/softspm/webinar/contact/ContactAdminController.java +++ b/backend/src/main/java/com/softspm/webinar/contact/ContactAdminController.java @@ -86,6 +86,19 @@ public class ContactAdminController { 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}") @Operation(summary = "Usuniecie kontaktu") public ResponseEntity delete(@PathVariable Long id) { diff --git a/backend/src/main/java/com/softspm/webinar/contact/ContactService.java b/backend/src/main/java/com/softspm/webinar/contact/ContactService.java index e577312..dfa1484 100644 --- a/backend/src/main/java/com/softspm/webinar/contact/ContactService.java +++ b/backend/src/main/java/com/softspm/webinar/contact/ContactService.java @@ -56,17 +56,20 @@ public class ContactService { private final ContactGroupMemberRepository memberRepository; private final ConsentRepository consentRepository; private final SettingsService settings; + private final com.softspm.webinar.repository.EnrollmentRepository enrollmentRepository; public ContactService(ContactRepository contactRepository, ContactGroupRepository groupRepository, ContactGroupMemberRepository memberRepository, ConsentRepository consentRepository, - SettingsService settings) { + SettingsService settings, + com.softspm.webinar.repository.EnrollmentRepository enrollmentRepository) { this.contactRepository = contactRepository; this.groupRepository = groupRepository; this.memberRepository = memberRepository; this.consentRepository = consentRepository; this.settings = settings; + this.enrollmentRepository = enrollmentRepository; } // --- odczyt --- @@ -171,6 +174,32 @@ public class ContactService { 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 public void delete(Long id) { Contact contact = require(id); diff --git a/backend/src/main/java/com/softspm/webinar/repository/ConsentRepository.java b/backend/src/main/java/com/softspm/webinar/repository/ConsentRepository.java index c44f7b4..31ad82e 100644 --- a/backend/src/main/java/com/softspm/webinar/repository/ConsentRepository.java +++ b/backend/src/main/java/com/softspm/webinar/repository/ConsentRepository.java @@ -12,6 +12,14 @@ public interface ConsentRepository extends JpaRepository { List 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. */ @Query("select c from Consent c where c.registration.id in :registrationIds") List findByRegistrationIdIn(@Param("registrationIds") Collection registrationIds); diff --git a/backend/src/main/java/com/softspm/webinar/repository/EnrollmentRepository.java b/backend/src/main/java/com/softspm/webinar/repository/EnrollmentRepository.java index e89a17a..8882a00 100644 --- a/backend/src/main/java/com/softspm/webinar/repository/EnrollmentRepository.java +++ b/backend/src/main/java/com/softspm/webinar/repository/EnrollmentRepository.java @@ -43,6 +43,9 @@ public interface EnrollmentRepository extends JpaRepository { /** Wszystkie zapisy kursu, bez wzgledu na stan. Do podsumowania skutkow skasowania. */ 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. */ @Query(""" select e from Enrollment e diff --git a/frontend/src/pages/contacts/ContactListPage.tsx b/frontend/src/pages/contacts/ContactListPage.tsx index bc46036..6564e37 100644 --- a/frontend/src/pages/contacts/ContactListPage.tsx +++ b/frontend/src/pages/contacts/ContactListPage.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { client, errorMessage } from '../../api/client' import type { Contact, ContactGroup, Page } from '../../api/types' +import { useConfirm } from '../../components/Confirm' import { Icon } from '../../components/Icon' import { PAGE_SIZE, Pagination } from '../../components/Pagination' import { useToasts } from '../../components/Toasts' @@ -11,6 +12,7 @@ import { Card, EmptyState, ErrorState, + IconButton, Input, Select, Spinner, @@ -33,8 +35,22 @@ const SOURCE_LABEL: Record = { 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() { const queryClient = useQueryClient() + const confirm = useConfirm() const toasts = useToasts() const [search, setSearch] = useState('') @@ -87,6 +103,85 @@ export function ContactListPage() { 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( + `/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: ( + <> +

+ Kontakt {fullName || contact.email} ( + {contact.email}) zostanie usunięty bezpowrotnie. +

+ + {impact.student && ( +

+ To konto kursanta. Usunięcie odbiera dostęp do kursów: osoba nie + zaloguje się do Akademii i straci wgląd w materiały oraz certyfikaty. +

+ )} + + {impact.enrollments > 0 && ( +

+ Przepadnie {impact.enrollments}{' '} + {plural(impact.enrollments, 'zapis na kurs', 'zapisy na kursy', 'zapisów na kursy')}{' '} + wraz z postępem w nauce. +

+ )} + + {impact.consents > 0 && ( +

+ Zniknie także historia zgód ({impact.consents}{' '} + {plural(impact.consents, 'wpis', 'wpisy', 'wpisów')}). To ślad rozliczalny, czyli + dowód podstawy wysyłki. +

+ )} + + {impact.student && ( +

+ Zamówienia zostają: to dokumenty finansowe i przeżywają usunięcie konta. +

+ )} + + ), + confirmLabel: impact.student ? 'Usuń konto kursanta' : 'Usuń kontakt', + destructive: true, + }) + if (ok) remove.mutate(contact.id) + } + async function handleExport() { setExporting(true) try { @@ -262,6 +357,20 @@ export function ContactListPage() { {contact.company && (

{contact.company}

)} +
+ setEditing(contact)} + /> + handleDelete(contact)} + disabled={remove.isPending} + className="text-alarm-600 hover:bg-alarm-50" + /> +
))} @@ -306,7 +415,7 @@ export function ContactListPage() { -
+
+
@@ -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. * diff --git a/frontend/src/pages/users/UserListPage.tsx b/frontend/src/pages/users/UserListPage.tsx index 40682f6..30b5870 100644 --- a/frontend/src/pages/users/UserListPage.tsx +++ b/frontend/src/pages/users/UserListPage.tsx @@ -88,6 +88,26 @@ export function UserListPage() { 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 }>( @@ -102,6 +122,83 @@ export function UserListPage() { 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) @@ -275,43 +372,36 @@ export function UserListPage() { { - 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) - }} + onClick={() => handleReset(user)} disabled={reset.isPending} /> + {!user.active && ( + handleActivate(user)} + disabled={activate.isPending} + className="text-ok-600 hover:bg-ok-50" + /> + )} {user.active && !isSelf && ( { - 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', - }) - if (ok) deactivate.mutate(user.id) - }} + onClick={() => 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" + /> + )}
@@ -374,43 +464,29 @@ export function UserListPage() { + {!user.active && ( + + )} {user.active && !isSelf && ( )} + {!isSelf && ( + + )}