fix(stressfrei): sync-forwarding sichtbar + Passwort-Push + Toast-Meldungen
Drei Verbesserungen am gestrigen Sync-Feature:
1. Bug-Fix: isProvisioned wurde nie auf true gesetzt
`createEmail` mit `provisionAtProvider: true` hat das Flag
`isProvisioned` nie gesetzt → blieb auf @default(false). Damit
blieb der Refresh-Button in der UI unsichtbar (Bedingung
`emailItem.isProvisioned`). Jetzt:
- createEmail setzt isProvisioned + provisionedAt korrekt
- Self-Healing: syncForwardingForEmail setzt das Flag nachträglich
auf true sobald der Provider-Aufruf erfolgreich war (Backfill
für historisch falsch markierte Einträge)
- UI-Sichtbarkeit: Bedingung entfernt – der Button erscheint jetzt
immer; ein Klick auf eine nicht-provisionierte Adresse liefert
eine sprechende Fehlermeldung statt stiller Verstecken
2. Passwort-Push bei hasMailbox: true
Bisher wurden nur die Forwards aktualisiert. Jetzt entschlüsselt
syncForwardingForEmail bei Mailbox-Adressen zusätzlich das im CRM
gespeicherte Passwort und setzt es am Provider neu – Self-Healing
für IMAP/SMTP-Logins falls jemand im Plesk-UI manuell ein anderes
Passwort gesetzt hat. Response enthält `passwordReset: true` als
Marker.
3. react-hot-toast statt alert()
Erfolgs-Toast listet die neu gesetzten Forward-Targets + Hinweis
ob Passwort-Reset durchgeführt wurde. Fehler-Toast zeigt die
Backend-Fehlermeldung (z.B. „E-Mail-Adresse beim Provider nicht
gefunden – wurde sie dort gelöscht?").
Audit-Log-Label enthält jetzt sowohl Forwards als auch Passwort-Reset-
Marker, damit der Vorgang im AuditLog nachvollziehbar bleibt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -114,12 +114,15 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const labelParts = [`Weiterleitungen: ${(result.forwardTargets || []).join(', ')}`];
|
||||||
|
if (result.passwordReset) labelParts.push('Mailbox-Passwort am Provider neu gesetzt');
|
||||||
|
|
||||||
await logChange({
|
await logChange({
|
||||||
req,
|
req,
|
||||||
action: 'UPDATE',
|
action: 'UPDATE',
|
||||||
resourceType: 'StressfreiEmail',
|
resourceType: 'StressfreiEmail',
|
||||||
resourceId: emailId.toString(),
|
resourceId: emailId.toString(),
|
||||||
label: `Weiterleitungen synchronisiert: ${(result.forwardTargets || []).join(', ')}`,
|
label: `Stressfrei-Sync: ${labelParts.join(' | ')}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -127,6 +130,7 @@ export async function syncForwarding(req: AuthRequest, res: Response): Promise<v
|
|||||||
data: {
|
data: {
|
||||||
forwardTargets: result.forwardTargets,
|
forwardTargets: result.forwardTargets,
|
||||||
customerEmail: result.customerEmail,
|
customerEmail: result.customerEmail,
|
||||||
|
passwordReset: result.passwordReset,
|
||||||
},
|
},
|
||||||
message: 'Weiterleitungen aktualisiert',
|
message: 'Weiterleitungen aktualisiert',
|
||||||
} as ApiResponse);
|
} as ApiResponse);
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ export async function createEmail(data: CreateEmailData) {
|
|||||||
...emailData,
|
...emailData,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasMailbox: true,
|
hasMailbox: true,
|
||||||
|
isProvisioned: true,
|
||||||
|
provisionedAt: new Date(),
|
||||||
emailPasswordEncrypted: passwordEncrypted,
|
emailPasswordEncrypted: passwordEncrypted,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -133,6 +135,11 @@ export async function createEmail(data: CreateEmailData) {
|
|||||||
...emailData,
|
...emailData,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
hasMailbox: createMailbox || false,
|
hasMailbox: createMailbox || false,
|
||||||
|
// Provisioned-Flag nur setzen wenn Provider-Aufruf gerade lief (oder
|
||||||
|
// die Mail bei Plesk schon existierte und der „existiert bereits"-Pfad
|
||||||
|
// gegriffen hat).
|
||||||
|
isProvisioned: !!provisionAtProvider,
|
||||||
|
provisionedAt: provisionAtProvider ? new Date() : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -257,27 +264,38 @@ export async function getDecryptedPassword(id: number): Promise<string | null> {
|
|||||||
// Stamm-E-Mail des Kunden). Ersetzt alle bestehenden Forwards durch
|
// Stamm-E-Mail des Kunden). Ersetzt alle bestehenden Forwards durch
|
||||||
// [aktuelle Kunden-E-Mail, defaultForwardEmail aus Provider-Config].
|
// [aktuelle Kunden-E-Mail, defaultForwardEmail aus Provider-Config].
|
||||||
//
|
//
|
||||||
|
// Wenn die Adresse `hasMailbox` ist: setzt zusätzlich das im CRM verschlüsselt
|
||||||
|
// hinterlegte Passwort am Provider neu (Use-Case: Plesk-Restore, manueller
|
||||||
|
// Eingriff im Plesk-UI etc. – CRM und Provider können sich entkoppeln, sodass
|
||||||
|
// IMAP/SMTP-Logins im CRM nicht mehr passen). Self-Healing.
|
||||||
|
//
|
||||||
// Idempotent: das Plesk-CLI `set:` überschreibt die Adressliste komplett, kein
|
// Idempotent: das Plesk-CLI `set:` überschreibt die Adressliste komplett, kein
|
||||||
// Duplikat-Risiko bei Mehrfachaufruf.
|
// Duplikat-Risiko bei Mehrfachaufruf. Wenn die Operation erfolgreich war wird
|
||||||
|
// das `isProvisioned`-Flag automatisch auf `true` gezogen (historische
|
||||||
|
// Einträge, bei denen das Flag nie gesetzt wurde, werden so geheilt).
|
||||||
export async function syncForwardingForEmail(
|
export async function syncForwardingForEmail(
|
||||||
id: number,
|
id: number,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
forwardTargets?: string[];
|
forwardTargets?: string[];
|
||||||
customerEmail?: string;
|
customerEmail?: string;
|
||||||
|
passwordReset?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
const stressfreiEmail = await prisma.stressfreiEmail.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { email: true, customerId: true, isProvisioned: true },
|
select: {
|
||||||
|
email: true,
|
||||||
|
customerId: true,
|
||||||
|
isProvisioned: true,
|
||||||
|
hasMailbox: true,
|
||||||
|
emailPasswordEncrypted: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!stressfreiEmail) {
|
if (!stressfreiEmail) {
|
||||||
return { success: false, error: 'StressfreiEmail nicht gefunden' };
|
return { success: false, error: 'StressfreiEmail nicht gefunden' };
|
||||||
}
|
}
|
||||||
if (!stressfreiEmail.isProvisioned) {
|
|
||||||
return { success: false, error: 'E-Mail ist nicht beim Provider angelegt – Sync nicht möglich' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const customer = await prisma.customer.findUnique({
|
const customer = await prisma.customer.findUnique({
|
||||||
where: { id: stressfreiEmail.customerId },
|
where: { id: stressfreiEmail.customerId },
|
||||||
@@ -294,15 +312,65 @@ export async function syncForwardingForEmail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const localPart = stressfreiEmail.email.split('@')[0];
|
const localPart = stressfreiEmail.email.split('@')[0];
|
||||||
const result = await setEmailForwardTargets(localPart, forwardTargets);
|
|
||||||
if (!result.success) {
|
// 1) Forwards neu setzen.
|
||||||
return { success: false, error: result.error || 'Provider-Update fehlgeschlagen' };
|
const forwardResult = await setEmailForwardTargets(localPart, forwardTargets);
|
||||||
|
if (!forwardResult.success) {
|
||||||
|
// Wenn Plesk meldet „nicht gefunden", liefern wir eine sprechende Meldung
|
||||||
|
// statt der rohen Provider-Nachricht.
|
||||||
|
const err = forwardResult.error || 'Provider-Update fehlgeschlagen';
|
||||||
|
const friendly = /not\s*found|nicht\s*gefunden/i.test(err)
|
||||||
|
? 'E-Mail-Adresse beim Provider nicht gefunden – wurde sie dort gelöscht?'
|
||||||
|
: err;
|
||||||
|
return { success: false, error: friendly };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Wenn Mailbox: Passwort aus CRM-Speicher entschlüsseln und am Provider
|
||||||
|
// neu setzen (Self-Healing nach Provider-seitigen Änderungen).
|
||||||
|
let passwordReset = false;
|
||||||
|
if (stressfreiEmail.hasMailbox && stressfreiEmail.emailPasswordEncrypted) {
|
||||||
|
try {
|
||||||
|
const password = decrypt(stressfreiEmail.emailPasswordEncrypted);
|
||||||
|
const pwResult = await updateMailboxPassword(localPart, password);
|
||||||
|
if (!pwResult.success) {
|
||||||
|
// Forwards waren schon erfolgreich – wir geben Forward-Erfolg + Passwort-
|
||||||
|
// Fehler kombiniert zurück, statt die ganze Operation rot zu machen.
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
forwardTargets,
|
||||||
|
customerEmail: customer.email,
|
||||||
|
error:
|
||||||
|
'Weiterleitungen aktualisiert, aber Passwort-Sync fehlgeschlagen: ' +
|
||||||
|
(pwResult.error || 'unbekannt'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
passwordReset = true;
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
forwardTargets,
|
||||||
|
customerEmail: customer.email,
|
||||||
|
error:
|
||||||
|
'Weiterleitungen aktualisiert, aber Passwort konnte nicht entschlüsselt werden – ' +
|
||||||
|
'evtl. wurde der ENCRYPTION_KEY rotiert',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Self-Healing: nach erfolgreichem Provider-Aufruf wissen wir definitiv,
|
||||||
|
// dass die Adresse beim Provider existiert → Flag korrigieren.
|
||||||
|
if (!stressfreiEmail.isProvisioned) {
|
||||||
|
await prisma.stressfreiEmail.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isProvisioned: true, provisionedAt: new Date() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
forwardTargets,
|
forwardTargets,
|
||||||
customerEmail: customer.email,
|
customerEmail: customer.email,
|
||||||
|
passwordReset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-8
@@ -97,16 +97,29 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
- [x] **🔁 Stressfrei-Adressen: Weiterleitungen manuell synchronisieren**
|
- [x] **🔁 Stressfrei-Adressen: Weiterleitungen + Passwort manuell synchronisieren**
|
||||||
- Refresh-Icon-Button in der Action-Reihe jeder provisionierten
|
- Refresh-Icon-Button in der Action-Reihe jeder Stressfrei-Adresse
|
||||||
Stressfrei-Adresse (Tooltip erklärt: „ersetzt die Forwards am Provider
|
(Tooltip erklärt: „ersetzt die Forwards am Provider durch
|
||||||
durch Kunden-Stamm-E-Mail + Service-Adresse"). Use-Case: nach
|
Kunden-Stamm-E-Mail + Service-Adresse"). Use-Case: nach Änderung der
|
||||||
Änderung der Stamm-E-Mail eines Kunden, oder nach Wechsel der
|
Stamm-E-Mail eines Kunden, oder nach Wechsel der
|
||||||
`defaultForwardEmail` in den Provider-Settings.
|
`defaultForwardEmail` in den Provider-Settings.
|
||||||
- Backend nutzt das bestehende Plesk `updateForwardTargets`
|
- **Bei `hasMailbox: true`** wird zusätzlich das im CRM verschlüsselt
|
||||||
(`set:email1,email2` → ersetzt komplett, idempotent).
|
hinterlegte Mailbox-Passwort am Provider neu gesetzt. Self-Healing
|
||||||
|
für den Fall, dass jemand im Plesk-UI manuell ein anderes Passwort
|
||||||
|
gesetzt hat und IMAP/SMTP im CRM nicht mehr passt.
|
||||||
|
- Backend nutzt Plesk's `updateForwardTargets` (`set:email1,email2`
|
||||||
|
→ ersetzt komplett, idempotent) + bei Mailbox auch
|
||||||
|
`updateMailboxPassword` (Plesk-Passwort-Update).
|
||||||
- Endpoint: `POST /api/stressfrei-emails/:id/sync-forwarding`,
|
- Endpoint: `POST /api/stressfrei-emails/:id/sync-forwarding`,
|
||||||
`customers:update`-Permission, Audit-Log mit Forward-Targets.
|
`customers:update`-Permission, Audit-Log mit Forward-Targets +
|
||||||
|
Passwort-Reset-Marker.
|
||||||
|
- Self-Healing: `isProvisioned`-Flag wird bei erfolgreichem
|
||||||
|
Provider-Aufruf automatisch auf `true` korrigiert (historischer Bug:
|
||||||
|
Flag wurde beim `createEmail` mit `provisionAtProvider: true` nie
|
||||||
|
gesetzt – jetzt behoben + Backfill via Sync).
|
||||||
|
- Erfolgs-/Fehler-Meldungen via `react-hot-toast` (statt `alert()`)
|
||||||
|
mit Liste der gesetzten Forward-Targets + Hinweis ob Passwort-Reset
|
||||||
|
durchgeführt wurde.
|
||||||
- In der Kundenakte (Stammdaten → Kontakt → E-Mail) externes
|
- In der Kundenakte (Stammdaten → Kontakt → E-Mail) externes
|
||||||
Link-Icon, das in neuem Tab direkt den Stressfrei-Tab des Kunden
|
Link-Icon, das in neuem Tab direkt den Stressfrei-Tab des Kunden
|
||||||
öffnet – sichtbar nur wenn Stressfrei-Adressen vorhanden sind.
|
öffnet – sichtbar nur wenn Stressfrei-Adressen vorhanden sind.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useParams, Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
import { useParams, Link, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||||
import { pushHistory, popHistory } from '../../utils/navigation';
|
import { pushHistory, popHistory } from '../../utils/navigation';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
|
import { customerApi, addressApi, bankCardApi, documentApi, meterApi, uploadApi, contractApi, stressfreiEmailApi, emailProviderApi, gdprApi, StressfreiEmail, ContractTreeNode } from '../../services/api';
|
||||||
import { EmailClientTab } from '../../components/email';
|
import { EmailClientTab } from '../../components/email';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
@@ -2976,21 +2977,26 @@ function StressfreiEmailsTab({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Weiterleitungen am Provider neu setzen (Stamm-Email-Wechsel-Use-Case).
|
// Weiterleitungen am Provider neu setzen (Stamm-Email-Wechsel-Use-Case).
|
||||||
|
// Wenn die Adresse hasMailbox=true ist, wird zusätzlich das im CRM
|
||||||
|
// hinterlegte Passwort am Provider neu gesetzt (Self-Healing nach
|
||||||
|
// manuellen Eingriffen am Provider).
|
||||||
const syncForwardingMutation = useMutation({
|
const syncForwardingMutation = useMutation({
|
||||||
mutationFn: stressfreiEmailApi.syncForwarding,
|
mutationFn: stressfreiEmailApi.syncForwarding,
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
const targets = res?.data?.forwardTargets || [];
|
const targets = res?.data?.forwardTargets || [];
|
||||||
alert(
|
const passwordReset = res?.data?.passwordReset;
|
||||||
targets.length > 0
|
const lines = [
|
||||||
? `Weiterleitungen aktualisiert:\n${targets.map((t) => `• ${t}`).join('\n')}`
|
'Weiterleitungen aktualisiert:',
|
||||||
: 'Weiterleitungen aktualisiert.',
|
...targets.map((t) => `• ${t}`),
|
||||||
);
|
];
|
||||||
|
if (passwordReset) lines.push('Mailbox-Passwort am Provider neu gesetzt.');
|
||||||
|
toast.success(lines.join('\n'), { duration: 5000 });
|
||||||
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
queryClient.invalidateQueries({ queryKey: ['customer', customerId] });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
alert(
|
toast.error(
|
||||||
'Fehler beim Aktualisieren der Weiterleitungen:\n' +
|
err?.response?.data?.error || err?.message || 'Fehler beim Aktualisieren der Weiterleitungen',
|
||||||
(err?.response?.data?.error || err?.message || 'Unbekannter Fehler'),
|
{ duration: 6000 },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -3054,30 +3060,38 @@ function StressfreiEmailsTab({
|
|||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{emailItem.isProvisioned && (
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
disabled={syncForwardingMutation.isPending}
|
||||||
disabled={syncForwardingMutation.isPending}
|
onClick={() => {
|
||||||
onClick={() => {
|
const lines = [
|
||||||
if (
|
`Weiterleitungen für ${emailItem.email} jetzt neu setzen?`,
|
||||||
confirm(
|
'',
|
||||||
'Weiterleitungen für ' + emailItem.email + ' jetzt neu setzen?\n\n' +
|
'Alle bestehenden Weiterleitungen am Provider werden ersetzt durch:',
|
||||||
'Alle bestehenden Weiterleitungen werden ersetzt durch:\n' +
|
'• die aktuelle Stamm-E-Mail des Kunden',
|
||||||
'• die aktuelle Stamm-E-Mail des Kunden\n' +
|
'• unsere Service-Weiterleitungsadresse aus den Provider-Einstellungen',
|
||||||
'• unsere Service-Weiterleitungsadresse aus den Provider-Einstellungen',
|
];
|
||||||
)
|
if (emailItem.hasMailbox) {
|
||||||
) {
|
lines.push(
|
||||||
syncForwardingMutation.mutate(emailItem.id);
|
'',
|
||||||
}
|
'Zusätzlich wird das im CRM hinterlegte Mailbox-Passwort am Provider neu gesetzt.',
|
||||||
}}
|
);
|
||||||
title="Weiterleitungen synchronisieren – ersetzt die Forwards am Provider durch (Kunden-Stamm-E-Mail + Service-Adresse). Nützlich nach Änderung der Stamm-E-Mail."
|
}
|
||||||
>
|
if (confirm(lines.join('\n'))) {
|
||||||
<RefreshCw
|
syncForwardingMutation.mutate(emailItem.id);
|
||||||
className={`w-4 h-4 ${syncForwardingMutation.isPending ? 'animate-spin' : ''}`}
|
}
|
||||||
/>
|
}}
|
||||||
</Button>
|
title={
|
||||||
)}
|
emailItem.hasMailbox
|
||||||
|
? 'Weiterleitungen + Mailbox-Passwort synchronisieren. Nützlich nach Änderung der Kunden-Stamm-E-Mail oder nach manuellem Eingriff am Provider.'
|
||||||
|
: 'Weiterleitungen synchronisieren – ersetzt die Forwards am Provider durch (Kunden-Stamm-E-Mail + Service-Adresse). Nützlich nach Änderung der Stamm-E-Mail.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`w-4 h-4 ${syncForwardingMutation.isPending ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
{emailItem.isActive ? (
|
{emailItem.isActive ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -427,11 +427,15 @@ export const stressfreiEmailApi = {
|
|||||||
const res = await api.post<ApiResponse<{ password: string }>>(`/stressfrei-emails/${id}/reset-password`);
|
const res = await api.post<ApiResponse<{ password: string }>>(`/stressfrei-emails/${id}/reset-password`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail)
|
// Weiterleitungen neu setzen (z.B. nach Änderung der Kunden-Stamm-E-Mail).
|
||||||
|
// Wenn die Adresse hasMailbox=true ist, wird zusätzlich das im CRM
|
||||||
|
// hinterlegte Passwort am Provider neu gesetzt (Self-Healing).
|
||||||
syncForwarding: async (id: number) => {
|
syncForwarding: async (id: number) => {
|
||||||
const res = await api.post<ApiResponse<{ forwardTargets: string[]; customerEmail: string }>>(
|
const res = await api.post<ApiResponse<{
|
||||||
`/stressfrei-emails/${id}/sync-forwarding`,
|
forwardTargets: string[];
|
||||||
);
|
customerEmail: string;
|
||||||
|
passwordReset?: boolean;
|
||||||
|
}>>(`/stressfrei-emails/${id}/sync-forwarding`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
// E-Mails synchronisieren
|
// E-Mails synchronisieren
|
||||||
|
|||||||
Reference in New Issue
Block a user