Fix QA review findings: C-01 through C-04, W-01 through W-07, S-01/S-04/S-05/S-06
Critical fixes: - C-01: Pass user_id to _mark_sent/_already_sent (ntfy crash) - C-02: Align frontend HTTP methods with backend routes (PATCH->PUT, DELETE->POST, fix reset-password/enforce-mfa/disable-mfa paths) - C-03: Add X-Requested-With to CORS allow_headers - C-04: Replace scalar_one_or_none with func.count for auth/status Warning fixes: - W-01: Batch audit log into same transaction in create_user, setup, register - W-02: Extract users array from UserListResponse wrapper in useAdminUsers - W-03: Update password hint from "8 chars" to "12 chars" in CreateUserDialog - W-04: Remove password input from reset flow, show returned temp password - W-06: Remove unused actor_alias variable in admin_dashboard - W-07: Resolve usernames in dashboard audit entries via JOIN, remove ip_address column from recent_logins (not tracked on User model) Suggestions applied: - S-01/S-06: Add extra="forbid" to all admin mutation schemas - S-04: Add ondelete="SET NULL" to audit_log.actor_user_id FK - S-05: Improve registration error message for better UX Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e5a7ce13e0
commit
e57a5b00c9
@ -76,7 +76,7 @@ def upgrade() -> None:
|
||||
sa.Column("ip_address", sa.String(45), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"]),
|
||||
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["target_user_id"], ["users.id"], ondelete="SET NULL"
|
||||
),
|
||||
|
||||
@ -40,15 +40,18 @@ UMBRA_URL = "http://10.0.69.35"
|
||||
|
||||
# ── Dedup helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _already_sent(db: AsyncSession, key: str) -> bool:
|
||||
async def _already_sent(db: AsyncSession, key: str, user_id: int) -> bool:
|
||||
result = await db.execute(
|
||||
select(NtfySent).where(NtfySent.notification_key == key)
|
||||
select(NtfySent).where(
|
||||
NtfySent.user_id == user_id,
|
||||
NtfySent.notification_key == key,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def _mark_sent(db: AsyncSession, key: str) -> None:
|
||||
db.add(NtfySent(notification_key=key))
|
||||
async def _mark_sent(db: AsyncSession, key: str, user_id: int) -> None:
|
||||
db.add(NtfySent(notification_key=key, user_id=user_id))
|
||||
await db.commit()
|
||||
|
||||
|
||||
@ -76,7 +79,7 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim
|
||||
|
||||
# Key includes user_id to prevent cross-user dedup collisions
|
||||
key = f"reminder:{settings.user_id}:{reminder.id}:{reminder.remind_at.date()}"
|
||||
if await _already_sent(db, key):
|
||||
if await _already_sent(db, key, settings.user_id):
|
||||
continue
|
||||
|
||||
payload = build_reminder_notification(
|
||||
@ -91,7 +94,7 @@ async def _dispatch_reminders(db: AsyncSession, settings: Settings, now: datetim
|
||||
**payload,
|
||||
)
|
||||
if sent:
|
||||
await _mark_sent(db, key)
|
||||
await _mark_sent(db, key, settings.user_id)
|
||||
|
||||
|
||||
async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime) -> None:
|
||||
@ -124,7 +127,7 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime)
|
||||
for event in events:
|
||||
# Key includes user_id to prevent cross-user dedup collisions
|
||||
key = f"event:{settings.user_id}:{event.id}:{event.start_datetime.strftime('%Y-%m-%dT%H:%M')}"
|
||||
if await _already_sent(db, key):
|
||||
if await _already_sent(db, key, settings.user_id):
|
||||
continue
|
||||
|
||||
payload = build_event_notification(
|
||||
@ -142,7 +145,7 @@ async def _dispatch_events(db: AsyncSession, settings: Settings, now: datetime)
|
||||
**payload,
|
||||
)
|
||||
if sent:
|
||||
await _mark_sent(db, key)
|
||||
await _mark_sent(db, key, settings.user_id)
|
||||
|
||||
|
||||
async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None:
|
||||
@ -165,7 +168,7 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None:
|
||||
for todo in todos:
|
||||
# Key includes user_id to prevent cross-user dedup collisions
|
||||
key = f"todo:{settings.user_id}:{todo.id}:{today}"
|
||||
if await _already_sent(db, key):
|
||||
if await _already_sent(db, key, settings.user_id):
|
||||
continue
|
||||
|
||||
payload = build_todo_notification(
|
||||
@ -181,7 +184,7 @@ async def _dispatch_todos(db: AsyncSession, settings: Settings, today) -> None:
|
||||
**payload,
|
||||
)
|
||||
if sent:
|
||||
await _mark_sent(db, key)
|
||||
await _mark_sent(db, key, settings.user_id)
|
||||
|
||||
|
||||
async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> None:
|
||||
@ -204,7 +207,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non
|
||||
for project in projects:
|
||||
# Key includes user_id to prevent cross-user dedup collisions
|
||||
key = f"project:{settings.user_id}:{project.id}:{today}"
|
||||
if await _already_sent(db, key):
|
||||
if await _already_sent(db, key, settings.user_id):
|
||||
continue
|
||||
|
||||
payload = build_project_notification(
|
||||
@ -219,7 +222,7 @@ async def _dispatch_projects(db: AsyncSession, settings: Settings, today) -> Non
|
||||
**payload,
|
||||
)
|
||||
if sent:
|
||||
await _mark_sent(db, key)
|
||||
await _mark_sent(db, key, settings.user_id)
|
||||
|
||||
|
||||
async def _dispatch_for_user(db: AsyncSession, settings: Settings, now: datetime) -> None:
|
||||
|
||||
@ -53,7 +53,7 @@ app.add_middleware(
|
||||
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization", "Cookie"],
|
||||
allow_headers=["Content-Type", "Authorization", "Cookie", "X-Requested-With"],
|
||||
)
|
||||
|
||||
# Include routers with /api prefix
|
||||
|
||||
@ -14,7 +14,7 @@ class AuditLog(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
actor_user_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id"), nullable=True, index=True
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
target_user_id: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
|
||||
@ -176,7 +176,6 @@ async def create_user(
|
||||
await db.flush() # populate new_user.id
|
||||
|
||||
await _create_user_defaults(db, new_user.id)
|
||||
await db.commit()
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
@ -582,8 +581,7 @@ async def admin_dashboard(
|
||||
|
||||
mfa_adoption = (totp_count / total_users) if total_users else 0.0
|
||||
|
||||
# 10 most recent logins — join to get username
|
||||
actor_alias = sa.alias(User.__table__, name="actor")
|
||||
# 10 most recent logins
|
||||
recent_logins_result = await db.execute(
|
||||
sa.select(User.username, User.last_login_at)
|
||||
.where(User.last_login_at != None)
|
||||
@ -595,20 +593,28 @@ async def admin_dashboard(
|
||||
for row in recent_logins_result
|
||||
]
|
||||
|
||||
# 10 most recent audit entries
|
||||
# 10 most recent audit entries — resolve usernames via JOINs
|
||||
actor_user = sa.orm.aliased(User, name="actor_user")
|
||||
target_user = sa.orm.aliased(User, name="target_user")
|
||||
recent_audit_result = await db.execute(
|
||||
sa.select(AuditLog).order_by(AuditLog.created_at.desc()).limit(10)
|
||||
sa.select(
|
||||
AuditLog,
|
||||
actor_user.username.label("actor_username"),
|
||||
target_user.username.label("target_username"),
|
||||
)
|
||||
.outerjoin(actor_user, AuditLog.actor_user_id == actor_user.id)
|
||||
.outerjoin(target_user, AuditLog.target_user_id == target_user.id)
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
recent_audit_entries = [
|
||||
{
|
||||
"id": e.id,
|
||||
"action": e.action,
|
||||
"actor_user_id": e.actor_user_id,
|
||||
"target_user_id": e.target_user_id,
|
||||
"detail": e.detail,
|
||||
"created_at": e.created_at,
|
||||
"action": row.AuditLog.action,
|
||||
"actor_username": row.actor_username,
|
||||
"target_username": row.target_username,
|
||||
"created_at": row.AuditLog.created_at,
|
||||
}
|
||||
for e in recent_audit_result.scalars()
|
||||
for row in recent_audit_result
|
||||
]
|
||||
|
||||
return AdminDashboardResponse(
|
||||
|
||||
@ -22,7 +22,7 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Cookie
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
@ -249,7 +249,6 @@ async def setup(
|
||||
await db.flush()
|
||||
|
||||
await _create_user_defaults(db, new_user.id)
|
||||
await db.commit()
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
user_agent = request.headers.get("user-agent")
|
||||
@ -376,7 +375,7 @@ async def register(
|
||||
select(User).where(User.username == data.username)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Registration failed")
|
||||
raise HTTPException(status_code=400, detail="Registration could not be completed. Please try a different username.")
|
||||
|
||||
password_hash = hash_password(data.password)
|
||||
# SEC-01: Explicit field assignment — never **data.model_dump()
|
||||
@ -395,7 +394,6 @@ async def register(
|
||||
await db.flush()
|
||||
|
||||
await _create_user_defaults(db, new_user.id)
|
||||
await db.commit()
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
user_agent = request.headers.get("user-agent")
|
||||
@ -458,9 +456,10 @@ async def auth_status(
|
||||
"""
|
||||
Check authentication status, role, and whether initial setup/registration is available.
|
||||
"""
|
||||
user_result = await db.execute(select(User))
|
||||
existing_user = user_result.scalar_one_or_none()
|
||||
setup_required = existing_user is None
|
||||
user_count_result = await db.execute(
|
||||
select(func.count()).select_from(User)
|
||||
)
|
||||
setup_required = user_count_result.scalar_one() == 0
|
||||
authenticated = False
|
||||
role = None
|
||||
|
||||
|
||||
@ -64,14 +64,17 @@ class CreateUserRequest(BaseModel):
|
||||
|
||||
|
||||
class UpdateUserRoleRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
role: Literal["admin", "standard", "public_event_manager"]
|
||||
|
||||
|
||||
class ToggleActiveRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ToggleMfaEnforceRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
enforce: bool
|
||||
|
||||
|
||||
@ -87,6 +90,7 @@ class SystemConfigResponse(BaseModel):
|
||||
|
||||
|
||||
class SystemConfigUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
allow_registration: Optional[bool] = None
|
||||
enforce_mfa_new_users: Optional[bool] = None
|
||||
|
||||
|
||||
@ -135,9 +135,6 @@ export default function AdminDashboardPage() {
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
When
|
||||
</th>
|
||||
<th className="px-5 py-2.5 text-left text-[11px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
IP
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -153,9 +150,6 @@ export default function AdminDashboardPage() {
|
||||
<td className="px-5 py-2.5 text-xs text-muted-foreground">
|
||||
{getRelativeTime(entry.last_login_at)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-xs text-muted-foreground font-mono">
|
||||
{entry.ip_address ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@ -75,11 +75,11 @@ export default function CreateUserDialog({ open, onOpenChange }: CreateUserDialo
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
placeholder="Min. 12 characters"
|
||||
required
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Must be at least 8 characters. The user will be prompted to change it on first login.
|
||||
Min. 12 characters with at least one letter and one non-letter. User must change on first login.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -41,8 +41,7 @@ const ROLES: { value: UserRole; label: string }[] = [
|
||||
export default function UserActionsMenu({ user }: UserActionsMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [roleSubmenuOpen, setRoleSubmenuOpen] = useState(false);
|
||||
const [showResetPassword, setShowResetPassword] = useState(false);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [tempPassword, setTempPassword] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateRole = useUpdateRole();
|
||||
@ -162,50 +161,38 @@ export default function UserActionsMenu({ user }: UserActionsMenuProps) {
|
||||
</div>
|
||||
|
||||
{/* Reset Password */}
|
||||
{!showResetPassword ? (
|
||||
{tempPassword ? (
|
||||
<div className="px-3 py-2 space-y-1.5">
|
||||
<p className="text-[11px] text-muted-foreground">Temporary password:</p>
|
||||
<code className="block px-2 py-1.5 bg-card-elevated rounded text-xs font-mono text-accent select-all break-all">
|
||||
{tempPassword}
|
||||
</code>
|
||||
<button
|
||||
className="w-full rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
setTempPassword(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
|
||||
onClick={() => setShowResetPassword(true)}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await resetPassword.mutateAsync(user.id);
|
||||
setTempPassword((result as { temporary_password: string }).temporary_password);
|
||||
toast.success('Password reset — user must change on next login');
|
||||
} catch (err) {
|
||||
toast.error(getErrorMessage(err, 'Password reset failed'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||
Reset Password
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
<input
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="flex-1 rounded-md bg-accent/15 px-2 py-1 text-xs text-accent hover:bg-accent/25 transition-colors"
|
||||
onClick={() => {
|
||||
if (!newPassword.trim()) return;
|
||||
handleAction(
|
||||
() => resetPassword.mutateAsync({ userId: user.id, new_password: newPassword }),
|
||||
'Password reset'
|
||||
);
|
||||
setNewPassword('');
|
||||
setShowResetPassword(false);
|
||||
}}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
setShowResetPassword(false);
|
||||
setNewPassword('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="my-1 border-t border-border" />
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type {
|
||||
AdminUser,
|
||||
AdminUserDetail,
|
||||
AdminDashboardData,
|
||||
SystemConfig,
|
||||
@ -9,11 +8,14 @@ import type {
|
||||
UserRole,
|
||||
} from '@/types';
|
||||
|
||||
interface UserListResponse {
|
||||
users: AdminUserDetail[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface AuditLogResponse {
|
||||
entries: AuditLogEntry[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
interface CreateUserPayload {
|
||||
@ -27,9 +29,9 @@ interface UpdateRolePayload {
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
interface ResetPasswordPayload {
|
||||
userId: number;
|
||||
new_password: string;
|
||||
interface ResetPasswordResult {
|
||||
message: string;
|
||||
temporary_password: string;
|
||||
}
|
||||
|
||||
// ── Queries ──────────────────────────────────────────────────────────────────
|
||||
@ -38,8 +40,8 @@ export function useAdminUsers() {
|
||||
return useQuery<AdminUserDetail[]>({
|
||||
queryKey: ['admin', 'users'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<AdminUserDetail[]>('/admin/users');
|
||||
return data;
|
||||
const { data } = await api.get<UserListResponse>('/admin/users');
|
||||
return data.users;
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -84,12 +86,12 @@ export function useAuditLog(
|
||||
|
||||
// ── Mutations ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function useAdminMutation<TVariables>(
|
||||
mutationFn: (vars: TVariables) => Promise<unknown>,
|
||||
function useAdminMutation<TVariables, TData = unknown>(
|
||||
mutationFn: (vars: TVariables) => Promise<TData>,
|
||||
onSuccess?: () => void
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<unknown, Error, TVariables>({
|
||||
return useMutation<TData, Error, TVariables>({
|
||||
mutationFn,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin'] });
|
||||
@ -107,42 +109,42 @@ export function useCreateUser() {
|
||||
|
||||
export function useUpdateRole() {
|
||||
return useAdminMutation(async ({ userId, role }: UpdateRolePayload) => {
|
||||
const { data } = await api.patch(`/admin/users/${userId}/role`, { role });
|
||||
const { data } = await api.put(`/admin/users/${userId}/role`, { role });
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetPassword() {
|
||||
return useAdminMutation(async ({ userId, new_password }: ResetPasswordPayload) => {
|
||||
const { data } = await api.post(`/admin/users/${userId}/reset-password`, { new_password });
|
||||
return useAdminMutation(async (userId: number) => {
|
||||
const { data } = await api.post<ResetPasswordResult>(`/admin/users/${userId}/reset-password`);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisableMfa() {
|
||||
return useAdminMutation(async (userId: number) => {
|
||||
const { data } = await api.delete(`/admin/users/${userId}/totp`);
|
||||
const { data } = await api.post(`/admin/users/${userId}/disable-mfa`);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnforceMfa() {
|
||||
return useAdminMutation(async (userId: number) => {
|
||||
const { data } = await api.post(`/admin/users/${userId}/enforce-mfa`);
|
||||
const { data } = await api.put(`/admin/users/${userId}/enforce-mfa`, { enforce: true });
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveMfaEnforcement() {
|
||||
return useAdminMutation(async (userId: number) => {
|
||||
const { data } = await api.delete(`/admin/users/${userId}/enforce-mfa`);
|
||||
const { data } = await api.put(`/admin/users/${userId}/enforce-mfa`, { enforce: false });
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleUserActive() {
|
||||
return useAdminMutation(async ({ userId, active }: { userId: number; active: boolean }) => {
|
||||
const { data } = await api.patch(`/admin/users/${userId}/active`, { is_active: active });
|
||||
const { data } = await api.put(`/admin/users/${userId}/active`, { is_active: active });
|
||||
return data;
|
||||
});
|
||||
}
|
||||
@ -156,7 +158,7 @@ export function useRevokeSessions() {
|
||||
|
||||
export function useUpdateConfig() {
|
||||
return useAdminMutation(async (config: Partial<SystemConfig>) => {
|
||||
const { data } = await api.patch('/admin/config', config);
|
||||
const { data } = await api.put('/admin/config', config);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
@ -258,7 +258,6 @@ export interface AdminDashboardData {
|
||||
recent_logins: Array<{
|
||||
username: string;
|
||||
last_login_at: string;
|
||||
ip_address: string;
|
||||
}>;
|
||||
recent_audit_entries: Array<{
|
||||
action: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user