Frontend : écran Messages branché sur l'envoi d'emails
- api : PlanningNotificationRequest/Result dans schema.ts, méthode
notifications.sendPlanning (http + mock), hook useSendPlanningNotification
- mock : compte les destinataires avec/sans email, émet des liens magiques
quand {lien_connexion} est présent
- ConfigMail : plus une maquette. Éditeur objet/corps, variables cliquables
({prenom} {nom} {nb_creneaux} {nb_heures} {creneaux} {lien_connexion}),
boutons « Envoyer le récapitulatif » et « Envoyer avec lien de connexion »,
retour d'envoi (queued / sans adresse) et ErrorNote
- i18n config.mail.* mis à jour ; onglet « MESSAGES » (plus « (MAQUETTE) »)
typecheck + lint + build + smoke : OK
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b268e308ab
commit
6b08dcc953
+1
-1
@@ -56,7 +56,7 @@ d'écran « Confirmations » (ancien onglet 03).
|
|||||||
| 02 | `/benevoles` | `volunteers` + `shifts` + `assignments` | lecture (surcharge / oubliés calculés côté client) |
|
| 02 | `/benevoles` | `volunteers` + `shifts` + `assignments` | lecture (surcharge / oubliés calculés côté client) |
|
||||||
| 04 | `/mobile-benevole` | `assignments?volunteer_id` (du compte) | lecture¹ |
|
| 04 | `/mobile-benevole` | `assignments?volunteer_id` (du compte) | lecture¹ |
|
||||||
| 05 | `/mobile-referent` | poste du référent → `assignments?shift_id` | pointage **local** (hors périmètre) |
|
| 05 | `/mobile-referent` | poste du référent → `assignments?shift_id` | pointage **local** (hors périmètre) |
|
||||||
| 06 | `/configuration` | | `positions` CRUD, `shifts` CRUD (min/idéal, ajout/retrait), `volunteers` CRUD (dont suppression RGPD). Messages = maquette locale. |
|
| 06 | `/configuration` | `positions` · `shifts` · `volunteers` | CRUD postes / créneaux (min/idéal) / bénévoles (dont suppression RGPD) ; onglet Messages → `POST /notifications/planning` (récapitulatif + lien de connexion) |
|
||||||
|
|
||||||
¹ En mode `http`, l'API n'expose pas encore aux bénévoles le détail des
|
¹ En mode `http`, l'API n'expose pas encore aux bénévoles le détail des
|
||||||
créneaux : l'écran dégrade proprement (créneaux sans détail, message d'info).
|
créneaux : l'écran dégrade proprement (créneaux sans détail, message d'info).
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
AssignmentCreate,
|
AssignmentCreate,
|
||||||
AssignmentUpdate,
|
AssignmentUpdate,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
|
PlanningNotificationRequest,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
PositionUpdate,
|
PositionUpdate,
|
||||||
ShiftCreate,
|
ShiftCreate,
|
||||||
@@ -228,3 +229,10 @@ export function useDeleteAssignment() {
|
|||||||
onSuccess: invalidate.planning,
|
onSuccess: invalidate.planning,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSendPlanningNotification() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: PlanningNotificationRequest) =>
|
||||||
|
api.notifications.sendPlanning(edition, body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AssignmentValidateRequest,
|
AssignmentValidateRequest,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
MagicLinkRequest,
|
MagicLinkRequest,
|
||||||
|
PlanningNotificationRequest,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
PositionUpdate,
|
PositionUpdate,
|
||||||
ShiftCreate,
|
ShiftCreate,
|
||||||
@@ -119,6 +120,13 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
matrix: {
|
matrix: {
|
||||||
get: (editionId) => request(`${base(editionId)}/matrix`),
|
get: (editionId) => request(`${base(editionId)}/matrix`),
|
||||||
},
|
},
|
||||||
|
notifications: {
|
||||||
|
sendPlanning: (editionId, body: PlanningNotificationRequest) =>
|
||||||
|
request(`${base(editionId)}/notifications/planning`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
},
|
||||||
auth: {
|
auth: {
|
||||||
login: (editionId, body: LoginRequest) =>
|
login: (editionId, body: LoginRequest) =>
|
||||||
request(`${base(editionId)}/auth/login`, { method: 'POST', body: JSON.stringify(body) }),
|
request(`${base(editionId)}/auth/login`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
|
PlanningNotificationRequest,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
Violation,
|
Violation,
|
||||||
@@ -425,6 +426,36 @@ export function createMockApi(): PlanningApi {
|
|||||||
get: () => delay(buildMatrix()),
|
get: () => delay(buildMatrix()),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
notifications: {
|
||||||
|
sendPlanning: (_edition, body: PlanningNotificationRequest) => {
|
||||||
|
const ids = body.volunteer_ids ?? null;
|
||||||
|
const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id));
|
||||||
|
const withEmail = targets.filter((v) => v.email);
|
||||||
|
const wantsLink =
|
||||||
|
(body.subject ?? '').includes('{lien_connexion}') ||
|
||||||
|
(body.body ?? '').includes('{lien_connexion}');
|
||||||
|
for (const volunteer of withEmail) {
|
||||||
|
if (!wantsLink) continue;
|
||||||
|
const account = state.accounts.find(
|
||||||
|
(a) => a.volunteer_id === volunteer.id && a.auth_method === 'magic_link',
|
||||||
|
);
|
||||||
|
if (!account) continue;
|
||||||
|
const raw = `mlt.${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
||||||
|
magicTokens.set(raw, {
|
||||||
|
accountId: account.id,
|
||||||
|
expiresAt: Date.now() + MAGIC_LINK_TTL_MS,
|
||||||
|
used: false,
|
||||||
|
});
|
||||||
|
lastMagicLink.set(account.email.toLowerCase(), raw);
|
||||||
|
}
|
||||||
|
return delay({
|
||||||
|
queued: withEmail.length,
|
||||||
|
skipped_no_email: targets.length - withEmail.length,
|
||||||
|
recipients: withEmail.map((v) => v.id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
login: (_edition, { email, password }) => {
|
login: (_edition, { email, password }) => {
|
||||||
const account = accountByEmail(email);
|
const account = accountByEmail(email);
|
||||||
|
|||||||
@@ -239,3 +239,24 @@ export interface MagicLinkRequest {
|
|||||||
export interface VerifyRequest {
|
export interface VerifyRequest {
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Notifications par email //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
/**
|
||||||
|
* `subject` / `body` sont des modèles. Variables : {prenom} {nom}
|
||||||
|
* {nb_creneaux} {nb_heures} {creneaux} {lien_connexion} (ce dernier déclenche
|
||||||
|
* l'émission d'un lien magique personnel).
|
||||||
|
*/
|
||||||
|
export interface PlanningNotificationRequest {
|
||||||
|
subject?: string | null;
|
||||||
|
body?: string | null;
|
||||||
|
/** null / absent = tous les bénévoles de l'édition qui ont un email. */
|
||||||
|
volunteer_ids?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanningNotificationResult {
|
||||||
|
queued: number;
|
||||||
|
skipped_no_email: number;
|
||||||
|
recipients: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
LoginRequest,
|
LoginRequest,
|
||||||
MagicLinkRequest,
|
MagicLinkRequest,
|
||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
|
PlanningNotificationRequest,
|
||||||
|
PlanningNotificationResult,
|
||||||
PositionCreate,
|
PositionCreate,
|
||||||
PositionRead,
|
PositionRead,
|
||||||
PositionUpdate,
|
PositionUpdate,
|
||||||
@@ -66,6 +68,13 @@ export interface PlanningApi {
|
|||||||
matrix: {
|
matrix: {
|
||||||
get(editionId: string): Promise<MatrixResponse>;
|
get(editionId: string): Promise<MatrixResponse>;
|
||||||
};
|
};
|
||||||
|
notifications: {
|
||||||
|
/** Récapitulatif de planning par email (COORDINATION). */
|
||||||
|
sendPlanning(
|
||||||
|
editionId: string,
|
||||||
|
body: PlanningNotificationRequest,
|
||||||
|
): Promise<PlanningNotificationResult>;
|
||||||
|
};
|
||||||
auth: {
|
auth: {
|
||||||
login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
|
login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
|
||||||
/** 202 sans divulgation : ne dit pas si le compte existe. */
|
/** 202 sans divulgation : ne dit pas si le compte existe. */
|
||||||
|
|||||||
@@ -151,7 +151,7 @@
|
|||||||
"config.reserved": "RÉSERVÉ À LA COORDINATION",
|
"config.reserved": "RÉSERVÉ À LA COORDINATION",
|
||||||
"config.tab.grid": "POSTES & CRÉNEAUX",
|
"config.tab.grid": "POSTES & CRÉNEAUX",
|
||||||
"config.tab.volunteers": "BÉNÉVOLES & IMPORT",
|
"config.tab.volunteers": "BÉNÉVOLES & IMPORT",
|
||||||
"config.tab.mail": "MESSAGES (MAQUETTE)",
|
"config.tab.mail": "MESSAGES",
|
||||||
"config.tab.rules": "RÈGLES & ÉDITION",
|
"config.tab.rules": "RÈGLES & ÉDITION",
|
||||||
|
|
||||||
"config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX",
|
"config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX",
|
||||||
@@ -187,16 +187,20 @@
|
|||||||
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?",
|
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?",
|
||||||
"config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade (RGPD).",
|
"config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade (RGPD).",
|
||||||
|
|
||||||
"config.mail.draftBanner": "Écran maquette : l'envoi d'emails / SMS est hors périmètre. Ces réglages ne sont pas enregistrés.",
|
"config.mail.smtpBanner": "Envoie un récapitulatif de planning à chaque bénévole. L'email part si le serveur SMTP est configuré (EMAIL_ENABLED) ; sinon il est seulement journalisé.",
|
||||||
"config.mail.templateTitle": "MODÈLE DE MESSAGE DE PUBLICATION",
|
"config.mail.templateTitle": "MODÈLE DE MESSAGE",
|
||||||
"config.mail.from": "EXPÉDITEUR",
|
"config.mail.senderNote": "Expéditeur défini côté serveur (SMTP_FROM).",
|
||||||
"config.mail.replyTo": "RÉPONDRE À",
|
|
||||||
"config.mail.subject": "OBJET",
|
"config.mail.subject": "OBJET",
|
||||||
"config.mail.body": "CORPS DU MESSAGE",
|
"config.mail.body": "CORPS DU MESSAGE",
|
||||||
"config.mail.variables": "VARIABLES DISPONIBLES",
|
"config.mail.variables": "VARIABLES DISPONIBLES",
|
||||||
|
"config.mail.variablesHint": "Cliquer une variable l'ajoute au corps. {lien_connexion} insère un lien de connexion personnel.",
|
||||||
|
"config.mail.send": "ENVOYER LE RÉCAPITULATIF",
|
||||||
|
"config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION",
|
||||||
|
"config.mail.sending": "ENVOI EN COURS…",
|
||||||
|
"config.mail.sent": "Envoi lancé : {queued} email(s), {skipped} bénévole(s) sans adresse.",
|
||||||
|
"config.mail.loginLinkLine": "Accède à ton planning : {lien_connexion}",
|
||||||
"config.mail.previewTitle": "APERÇU",
|
"config.mail.previewTitle": "APERÇU",
|
||||||
"config.mail.previewFrom": "DE : {from}",
|
"config.mail.previewSender": "DE : expéditeur configuré côté serveur",
|
||||||
"config.mail.cta": "VOIR MON PLANNING →",
|
|
||||||
|
|
||||||
"config.rules.editionTitle": "ÉDITION & CONNEXION",
|
"config.rules.editionTitle": "ÉDITION & CONNEXION",
|
||||||
"config.rules.editionId": "Identifiant d'édition",
|
"config.rules.editionId": "Identifiant d'édition",
|
||||||
|
|||||||
@@ -1,37 +1,47 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
|
import { useSendPlanningNotification } from '@/api/hooks';
|
||||||
|
import { ErrorNote } from '@/components/state';
|
||||||
|
import { Btn, FieldLabel, Panel, PanelHeader } from '@/components/ui';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
const MAIL_VARIABLES = ['{prenom}', '{nom}', '{nb_creneaux}', '{nb_heures}', '{poste}', '{referent}', '{lien_magique}'];
|
const MAIL_VARIABLES = [
|
||||||
|
'{prenom}',
|
||||||
|
'{nom}',
|
||||||
|
'{nb_creneaux}',
|
||||||
|
'{nb_heures}',
|
||||||
|
'{creneaux}',
|
||||||
|
'{lien_connexion}',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_SUBJECT = 'Ton planning bénévole';
|
||||||
|
const DEFAULT_BODY =
|
||||||
|
'Salut {prenom},\n\nVoici ton planning : {nb_creneaux} créneau(x), soit {nb_heures} h.\n\n{creneaux}\n\nÀ très vite sur le festival !';
|
||||||
|
|
||||||
|
function withLoginLink(body: string): string {
|
||||||
|
if (body.includes('{lien_connexion}')) return body;
|
||||||
|
return `${body}\n\n${t('config.mail.loginLinkLine')}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Écran maquette : l'envoi d'emails et la publication versionnée sont hors
|
|
||||||
// périmètre (CLAUDE.md §4). Les réglages ci-dessous ne sont pas persistés.
|
|
||||||
export function ConfigMail() {
|
export function ConfigMail() {
|
||||||
const [from, setFrom] = useState('coordination@lessonsdautomne.fr');
|
const [subject, setSubject] = useState(DEFAULT_SUBJECT);
|
||||||
const [replyTo, setReplyTo] = useState('marie.bertin@lessonsdautomne.fr');
|
const [body, setBody] = useState(DEFAULT_BODY);
|
||||||
const [subject, setSubject] = useState('Ton planning bénévole a été mis à jour');
|
const send = useSendPlanningNotification();
|
||||||
const [body, setBody] = useState(
|
|
||||||
"Salut {prenom},\n\nTon planning vient d'être mis à jour. Tu as {nb_creneaux} créneaux, soit {nb_heures} heures.\n\nRetrouve le détail sur ton planning personnel.",
|
const submit = (payloadBody: string) => send.mutate({ subject, body: payloadBody });
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="mb-4 border-[3px] border-dashed border-ink bg-yellow-soft p-3 font-display text-[12px] font-bold">
|
<div className="mb-4 border-[3px] border-dashed border-ink bg-yellow-soft p-3 font-display text-[12px] font-bold">
|
||||||
{t('config.mail.draftBanner')}
|
{t('config.mail.smtpBanner')}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
|
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
|
||||||
<Panel shadow="lg">
|
<Panel shadow="lg">
|
||||||
<PanelHeader>{t('config.mail.templateTitle')}</PanelHeader>
|
<PanelHeader>{t('config.mail.templateTitle')}</PanelHeader>
|
||||||
<div className="flex flex-col gap-3 p-[13px]">
|
<div className="flex flex-col gap-3 p-[13px]">
|
||||||
<label className="block">
|
<div className="font-mono text-[10px] leading-relaxed opacity-60">
|
||||||
<FieldLabel>{t('config.mail.from')}</FieldLabel>
|
{t('config.mail.senderNote')}
|
||||||
<TextInput mono value={from} onChange={setFrom} />
|
</div>
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<FieldLabel>{t('config.mail.replyTo')}</FieldLabel>
|
|
||||||
<TextInput mono value={replyTo} onChange={setReplyTo} />
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('config.mail.subject')}</FieldLabel>
|
<FieldLabel>{t('config.mail.subject')}</FieldLabel>
|
||||||
<input
|
<input
|
||||||
@@ -43,7 +53,7 @@ export function ConfigMail() {
|
|||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('config.mail.body')}</FieldLabel>
|
<FieldLabel>{t('config.mail.body')}</FieldLabel>
|
||||||
<textarea
|
<textarea
|
||||||
rows={7}
|
rows={8}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.target.value)}
|
onChange={(e) => setBody(e.target.value)}
|
||||||
className="w-full resize-y border-2 border-ink bg-cream p-[9px] font-display text-[12.5px] leading-relaxed"
|
className="w-full resize-y border-2 border-ink bg-cream p-[9px] font-display text-[12.5px] leading-relaxed"
|
||||||
@@ -53,15 +63,43 @@ export function ConfigMail() {
|
|||||||
<FieldLabel>{t('config.mail.variables')}</FieldLabel>
|
<FieldLabel>{t('config.mail.variables')}</FieldLabel>
|
||||||
<div className="flex flex-wrap gap-[6px]">
|
<div className="flex flex-wrap gap-[6px]">
|
||||||
{MAIL_VARIABLES.map((variable) => (
|
{MAIL_VARIABLES.map((variable) => (
|
||||||
<span
|
<button
|
||||||
key={variable}
|
key={variable}
|
||||||
className="border-2 border-ink bg-blue px-[7px] py-[5px] font-mono text-[10.5px]"
|
type="button"
|
||||||
|
onClick={() => setBody((current) => `${current}${variable}`)}
|
||||||
|
className="border-2 border-ink bg-blue px-[7px] py-[5px] font-mono text-[10.5px] hover:bg-yellow"
|
||||||
>
|
>
|
||||||
{variable}
|
{variable}
|
||||||
</span>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1 font-mono text-[9.5px] opacity-60">
|
||||||
|
{t('config.mail.variablesHint')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 border-t-2 border-ink pt-3">
|
||||||
|
<Btn disabled={send.isPending} onClick={() => submit(body)}>
|
||||||
|
{send.isPending ? t('config.mail.sending') : t('config.mail.send')}
|
||||||
|
</Btn>
|
||||||
|
<Btn
|
||||||
|
variant="dark"
|
||||||
|
disabled={send.isPending}
|
||||||
|
onClick={() => submit(withLoginLink(body))}
|
||||||
|
>
|
||||||
|
{t('config.mail.sendWithLink')}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{send.isSuccess ? (
|
||||||
|
<div className="border-2 border-ink bg-green p-[9px] font-display text-[12px] font-bold text-white">
|
||||||
|
{t('config.mail.sent', {
|
||||||
|
queued: send.data.queued,
|
||||||
|
skipped: send.data.skipped_no_email,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{send.isError ? <ErrorNote error={send.error} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
@@ -69,15 +107,12 @@ export function ConfigMail() {
|
|||||||
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
|
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
|
||||||
<div className="p-[13px]">
|
<div className="p-[13px]">
|
||||||
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
|
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
|
||||||
{t('config.mail.previewFrom', { from })}
|
{t('config.mail.previewSender')}
|
||||||
</div>
|
</div>
|
||||||
<div className="my-2 border-t-2 border-ink pt-2 font-display text-[13.5px] font-extrabold leading-snug">
|
<div className="my-2 border-t-2 border-ink pt-2 font-display text-[13.5px] font-extrabold leading-snug">
|
||||||
{subject}
|
{subject}
|
||||||
</div>
|
</div>
|
||||||
<div className="whitespace-pre-wrap font-display text-[12.5px] leading-relaxed">{body}</div>
|
<div className="whitespace-pre-wrap font-display text-[12.5px] leading-relaxed">{body}</div>
|
||||||
<div className="mt-[11px] inline-block border-2 border-ink bg-yellow px-3 py-[9px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal">
|
|
||||||
{t('config.mail.cta')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user