feat: profile avatar, callback setUser fix, admin description/thumbnail, pagination limit=100
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 26s
All checks were successful
CI/CD — Build & Deploy / Build & Deploy (push) Successful in 26s
This commit is contained in:
@@ -9,6 +9,17 @@ export default function UserBadge({ user }: UserBadgeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
|
{user.avatar ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.nickname}
|
||||||
|
className="h-6 w-6 rounded-full object-cover border border-od-border"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-od-surface border border-od-border font-mono text-[10px] text-od-accent">
|
||||||
|
{user.nickname[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="font-mono text-xs text-od-accent">{user.nickname}</span>
|
<span className="font-mono text-xs text-od-accent">{user.nickname}</span>
|
||||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded border border-od-border text-od-muted">
|
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded border border-od-border text-od-muted">
|
||||||
{planLabel}
|
{planLabel}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface User {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
|
avatar?: string | null;
|
||||||
plan?: { slug: string; name: string; level: number } | null;
|
plan?: { slug: string; name: string; level: number } | null;
|
||||||
subscriptionLevel?: number;
|
subscriptionLevel?: number;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ function VideosTab() {
|
|||||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', storageType: 'youtube', storageKey: '',
|
title: '', description: '', thumbnailUrl: '',
|
||||||
|
storageType: 'youtube', storageKey: '',
|
||||||
requiredLevel: 0, isPublished: false,
|
requiredLevel: 0, isPublished: false,
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -106,7 +107,7 @@ function VideosTab() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<{ success: boolean; data: { videos: Video[] } }>('/admin/videos')
|
apiFetch<{ success: boolean; data: { videos: Video[] } }>('/admin/videos?limit=100')
|
||||||
.then((r) => setVideos(r.data.videos))
|
.then((r) => setVideos(r.data.videos))
|
||||||
.catch(() => setFetchError('Impossible de charger les vidéos.'))
|
.catch(() => setFetchError('Impossible de charger les vidéos.'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
@@ -144,7 +145,7 @@ function VideosTab() {
|
|||||||
{ method: 'POST', body: JSON.stringify(form) }
|
{ method: 'POST', body: JSON.stringify(form) }
|
||||||
);
|
);
|
||||||
setVideos((v) => [r.data.video, ...v]);
|
setVideos((v) => [r.data.video, ...v]);
|
||||||
setForm({ title: '', storageType: 'youtube', storageKey: '', requiredLevel: 0, isPublished: false });
|
setForm({ title: '', description: '', thumbnailUrl: '', storageType: 'youtube', storageKey: '', requiredLevel: 0, isPublished: false });
|
||||||
} catch {
|
} catch {
|
||||||
setError('Erreur lors de la création.');
|
setError('Erreur lors de la création.');
|
||||||
}
|
}
|
||||||
@@ -189,6 +190,21 @@ function VideosTab() {
|
|||||||
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
|
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
placeholder="Description (optionnel)"
|
||||||
|
rows={2}
|
||||||
|
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent resize-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
value={form.thumbnailUrl}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, thumbnailUrl: e.target.value }))}
|
||||||
|
placeholder="URL thumbnail (optionnel)"
|
||||||
|
className="rounded border border-od-border bg-od-bg px-3 py-2 text-sm text-od-text placeholder-od-muted outline-none focus:border-od-accent"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
value={form.storageType}
|
value={form.storageType}
|
||||||
@@ -312,7 +328,7 @@ function UsersTab({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users'),
|
apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users?limit=100'),
|
||||||
apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans'),
|
apiFetch<{ success: boolean; data: { plans: Plan[] } }>('/admin/plans'),
|
||||||
])
|
])
|
||||||
.then(([ur, pr]) => {
|
.then(([ur, pr]) => {
|
||||||
@@ -333,7 +349,7 @@ function UsersTab({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ planId }),
|
body: JSON.stringify({ planId }),
|
||||||
});
|
});
|
||||||
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users');
|
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users?limit=100');
|
||||||
setUsers(r.data.users);
|
setUsers(r.data.users);
|
||||||
setSelectedPlan((s) => { const n = { ...s }; delete n[userId]; return n; });
|
setSelectedPlan((s) => { const n = { ...s }; delete n[userId]; return n; });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -352,7 +368,7 @@ function UsersTab({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ roles: [role] }),
|
body: JSON.stringify({ roles: [role] }),
|
||||||
});
|
});
|
||||||
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users');
|
const r = await apiFetch<{ success: boolean; data: { users: AdminUser[] } }>('/admin/users?limit=100');
|
||||||
setUsers(r.data.users);
|
setUsers(r.data.users);
|
||||||
setSelectedRole((s) => { const n = { ...s }; delete n[userId]; return n; });
|
setSelectedRole((s) => { const n = { ...s }; delete n[userId]; return n; });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,29 +1,38 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { apiFetch } from '../lib/api';
|
import { apiFetch } from '../lib/api';
|
||||||
|
import { useAuthContext } from '../context/AuthContext';
|
||||||
|
import type { User } from '../context/AuthContext';
|
||||||
|
|
||||||
|
interface SessionResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: { user: User };
|
||||||
|
}
|
||||||
|
|
||||||
export default function CallbackPage() {
|
export default function CallbackPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { setUser } = useAuthContext();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const token = params.get('token');
|
const token = params.get('token');
|
||||||
|
|
||||||
// Pas de token dans l'URL → retour silencieux
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Envoie le token au backend → backend valide + pose le cookie httpOnly
|
apiFetch<SessionResponse>('/auth/session', {
|
||||||
apiFetch<void>('/auth/session', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ token }),
|
body: JSON.stringify({ token }),
|
||||||
})
|
})
|
||||||
.then(() => navigate('/', { replace: true }))
|
.then((res) => {
|
||||||
|
setUser(res.data.user);
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
})
|
||||||
.catch(() => setError("Échec de l'authentification. Réessaie."));
|
.catch(() => setError("Échec de l'authentification. Réessaie."));
|
||||||
}, [navigate]);
|
}, [navigate, setUser]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,41 +11,42 @@ interface MeResponse {
|
|||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, setUser } = useAuthContext();
|
const { user, setUser } = useAuthContext();
|
||||||
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editingNickname, setEditingNickname] = useState(false);
|
||||||
const [draft, setDraft] = useState(user?.nickname ?? '');
|
const [draftNickname, setDraftNickname] = useState(user?.nickname ?? '');
|
||||||
|
const [editingAvatar, setEditingAvatar] = useState(false);
|
||||||
|
const [draftAvatar, setDraftAvatar] = useState(user?.avatar ?? '');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
function handleEdit() {
|
function startEditNickname() {
|
||||||
setDraft(user!.nickname);
|
setDraftNickname(user!.nickname);
|
||||||
setError(null);
|
setError(null);
|
||||||
setEditing(true);
|
setEditingNickname(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCancel() {
|
function startEditAvatar() {
|
||||||
setEditing(false);
|
setDraftAvatar(user!.avatar ?? '');
|
||||||
|
setError(null);
|
||||||
|
setEditingAvatar(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setEditingNickname(false);
|
||||||
|
setEditingAvatar(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function save(patch: { nickname?: string; avatar?: string | null }) {
|
||||||
const trimmed = draft.trim();
|
|
||||||
if (!trimmed || trimmed === user!.nickname) {
|
|
||||||
setEditing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await apiFetch('/users/me', {
|
await apiFetch('/users/me', { method: 'PATCH', body: JSON.stringify(patch) });
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify({ nickname: trimmed }),
|
|
||||||
});
|
|
||||||
const res = await apiFetch<MeResponse>('/auth/me');
|
const res = await apiFetch<MeResponse>('/auth/me');
|
||||||
setUser(res.data.user);
|
setUser(res.data.user);
|
||||||
setEditing(false);
|
setEditingNickname(false);
|
||||||
|
setEditingAvatar(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? `Erreur ${e.status}` : 'Erreur réseau');
|
setError(e instanceof ApiError ? `Erreur ${e.status}` : 'Erreur réseau');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -53,70 +54,115 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSaveNickname() {
|
||||||
|
const trimmed = draftNickname.trim();
|
||||||
|
if (!trimmed || trimmed === user!.nickname) { cancel(); return; }
|
||||||
|
await save({ nickname: trimmed });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveAvatar() {
|
||||||
|
const trimmed = draftAvatar.trim();
|
||||||
|
const val = trimmed === '' ? null : trimmed;
|
||||||
|
if (val === (user!.avatar ?? null)) { cancel(); return; }
|
||||||
|
await save({ avatar: val });
|
||||||
|
}
|
||||||
|
|
||||||
const planLabel = user.plan?.name ?? 'Free';
|
const planLabel = user.plan?.name ?? 'Free';
|
||||||
const planSlug = user.plan?.slug ?? 'free';
|
const planSlug = user.plan?.slug ?? 'free';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg space-y-8">
|
<div className="max-w-lg space-y-8">
|
||||||
<h1 className="font-mono text-sm text-od-accent">Profil</h1>
|
<h1 className="font-mono text-sm text-od-accent">Profil</h1>
|
||||||
|
|
||||||
{/* Infos compte */}
|
{/* Compte */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xs font-semibold text-od-muted uppercase tracking-widest">
|
<h2 className="text-xs font-semibold text-od-muted uppercase tracking-widest">Compte</h2>
|
||||||
Compte
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="rounded border border-od-border bg-od-surface divide-y divide-od-border">
|
<div className="rounded border border-od-border bg-od-surface divide-y divide-od-border">
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 gap-4">
|
||||||
|
<span className="text-xs text-od-muted shrink-0">Avatar</span>
|
||||||
|
{editingAvatar ? (
|
||||||
|
<div className="flex flex-col items-end gap-2 flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<input
|
||||||
|
value={draftAvatar}
|
||||||
|
onChange={(e) => setDraftAvatar(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveAvatar(); if (e.key === 'Escape') cancel(); }}
|
||||||
|
placeholder="URL https://… (vide = supprimer)"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1 min-w-0 rounded border border-od-border bg-od-bg px-2 py-0.5 font-mono text-xs text-od-text focus:border-od-accent focus:outline-none disabled:opacity-50 placeholder-od-muted"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button onClick={handleSaveAvatar} disabled={saving}
|
||||||
|
className="font-mono text-xs text-od-accent hover:opacity-80 transition-opacity disabled:opacity-40">
|
||||||
|
{saving ? '…' : '✓'}
|
||||||
|
</button>
|
||||||
|
<button onClick={cancel} disabled={saving}
|
||||||
|
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors disabled:opacity-40">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{draftAvatar && (
|
||||||
|
<img src={draftAvatar} alt="preview" className="h-10 w-10 rounded-full object-cover border border-od-border" />
|
||||||
|
)}
|
||||||
|
{error && <span className="font-mono text-[10px] text-od-crit">{error}</span>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{user.avatar ? (
|
||||||
|
<img src={user.avatar} alt={user.nickname} className="h-8 w-8 rounded-full object-cover border border-od-border" />
|
||||||
|
) : (
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-od-surface-hi border border-od-border font-mono text-sm text-od-accent">
|
||||||
|
{user.nickname[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={startEditAvatar}
|
||||||
|
className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors">
|
||||||
|
modifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
<span className="text-xs text-od-muted">Email</span>
|
<span className="text-xs text-od-muted">Email</span>
|
||||||
<span className="font-mono text-xs text-od-text">
|
<span className="font-mono text-xs text-od-text">{user.email ?? '—'}</span>
|
||||||
{user.email ?? '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Nickname */}
|
{/* Nickname */}
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
<span className="text-xs text-od-muted">Pseudo</span>
|
<span className="text-xs text-od-muted">Pseudo</span>
|
||||||
{editing ? (
|
{editingNickname ? (
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
value={draft}
|
value={draftNickname}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraftNickname(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') handleCancel(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveNickname(); if (e.key === 'Escape') cancel(); }}
|
||||||
maxLength={32}
|
maxLength={100}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="w-36 rounded border border-od-border bg-od-bg px-2 py-0.5 font-mono text-xs text-od-text focus:border-od-accent focus:outline-none disabled:opacity-50"
|
className="w-36 rounded border border-od-border bg-od-bg px-2 py-0.5 font-mono text-xs text-od-text focus:border-od-accent focus:outline-none disabled:opacity-50"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
<button onClick={handleSaveNickname} disabled={saving}
|
||||||
onClick={handleSave}
|
className="font-mono text-xs text-od-accent hover:opacity-80 transition-opacity disabled:opacity-40">
|
||||||
disabled={saving}
|
|
||||||
className="font-mono text-xs text-od-accent hover:opacity-80 transition-opacity disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{saving ? '…' : '✓'}
|
{saving ? '…' : '✓'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={cancel} disabled={saving}
|
||||||
onClick={handleCancel}
|
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors disabled:opacity-40">
|
||||||
disabled={saving}
|
|
||||||
className="font-mono text-xs text-od-muted hover:text-od-crit transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && <span className="font-mono text-[10px] text-od-crit">{error}</span>}
|
||||||
<span className="font-mono text-[10px] text-od-crit">{error}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-mono text-xs text-od-text">{user.nickname}</span>
|
<span className="font-mono text-xs text-od-text">{user.nickname}</span>
|
||||||
<button
|
<button onClick={startEditNickname}
|
||||||
onClick={handleEdit}
|
className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors">
|
||||||
className="font-mono text-xs text-od-muted hover:text-od-accent transition-colors"
|
|
||||||
>
|
|
||||||
modifier
|
modifier
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,10 +174,7 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
{/* Plan */}
|
{/* Plan */}
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
<h2 className="text-xs font-semibold text-od-muted uppercase tracking-widest">
|
<h2 className="text-xs font-semibold text-od-muted uppercase tracking-widest">Abonnement</h2>
|
||||||
Abonnement
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="rounded border border-od-border bg-od-surface px-4 py-3 flex items-center justify-between">
|
<div className="rounded border border-od-border bg-od-surface px-4 py-3 flex items-center justify-between">
|
||||||
<p className="text-xs text-od-text">{planLabel}</p>
|
<p className="text-xs text-od-text">{planLabel}</p>
|
||||||
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded border border-od-border text-od-muted">
|
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded border border-od-border text-od-muted">
|
||||||
|
|||||||
Reference in New Issue
Block a user