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
+143 -55
View File
@@ -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 <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) {
setEditing(user)
setCreating(false)
@@ -275,43 +372,36 @@ export function UserListPage() {
<IconButton
icon="key"
label="Resetuj hasło"
onClick={async () => {
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)
}}
onClick={() => handleReset(user)}
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 && (
<IconButton
icon="user-off"
label="Dezaktywuj"
onClick={async () => {
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)
}}
onClick={() => handleDeactivate(user)}
disabled={deactivate.isPending}
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>
</li>
@@ -374,43 +464,29 @@ export function UserListPage() {
<Button
variant="ghost"
icon="key"
onClick={async () => {
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)
}}
onClick={() => handleReset(user)}
disabled={reset.isPending}
title="Resetuj hasło"
>
<span className="sr-only">Resetuj hasło</span>
</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 && (
<Button
variant="ghost"
icon="user-off"
onClick={async () => {
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)
}}
onClick={() => handleDeactivate(user)}
disabled={deactivate.isPending}
title="Dezaktywuj"
className="text-alarm-600 hover:bg-alarm-50"
@@ -418,6 +494,18 @@ export function UserListPage() {
<span className="sr-only">Dezaktywuj</span>
</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>
</td>
</tr>