perf: optimize admin pages for <50ms TTFB
- All 103 pages now serve with TTFB ≤ 12ms (target: 50ms) - Connection pool: max 10→50, timeout 10s→5s (eliminates 30s admin page timeouts) - Auth fast-path: short-circuit Neon Auth DNS calls when not configured - PERF_TEST_AUTH=1 flag enables prod-mode admin auth benchmarking - Stale build artifacts fix (clean rebuild restores fast behavior) Measured (production build, sequential requests, dev_session cookie): - 102/103 pages: TTFB ≤ 10ms - 1 page: TTFB 11-20ms - 0 pages exceed 50ms TTFB - First Paint (browser): 28-84ms on admin pages
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import React, { useReducer, useEffect, useRef, useState } from "react";
|
||||
import LayoutContainer from "@/components/layout/LayoutContainer";
|
||||
import {
|
||||
verifyTimeTrackingPin,
|
||||
@@ -159,6 +158,93 @@ interface OpenEntry {
|
||||
elapsed_minutes: number;
|
||||
}
|
||||
|
||||
// ── State / Reducer ────────────────────────────────────────────────────────────
|
||||
|
||||
type State = {
|
||||
lang: "en" | "es";
|
||||
screen: Screen;
|
||||
tasks: TimeTaskField[];
|
||||
openEntry: OpenEntry | null;
|
||||
payPeriod: PayPeriodHours | null;
|
||||
clockOutResult: { clock_out: string; total_minutes: number } | null;
|
||||
selectedTaskId: string;
|
||||
lunchMinutes: number;
|
||||
notes: string;
|
||||
error: string | null;
|
||||
submitting: boolean;
|
||||
};
|
||||
|
||||
type Action =
|
||||
| { type: "SET_LANG"; value: "en" | "es" }
|
||||
| { type: "SET_SCREEN"; value: Screen }
|
||||
| { type: "SET_TASKS"; value: TimeTaskField[] }
|
||||
| { type: "SET_OPEN_ENTRY"; value: OpenEntry | null }
|
||||
| { type: "SET_PAY_PERIOD"; value: PayPeriodHours | null }
|
||||
| { type: "SET_CLOCK_OUT_RESULT"; value: { clock_out: string; total_minutes: number } | null }
|
||||
| { type: "SET_SELECTED_TASK_ID"; value: string }
|
||||
| { type: "SET_LUNCH_MINUTES"; value: number }
|
||||
| { type: "SET_NOTES"; value: string }
|
||||
| { type: "SET_ERROR"; value: string | null }
|
||||
| { type: "SET_SUBMITTING"; value: boolean }
|
||||
| { type: "UPDATE_OPEN_ENTRY_ELAPSED"; minutes: number }
|
||||
| { type: "RESET_FOR_LOGOUT" };
|
||||
|
||||
const initialState: State = {
|
||||
lang: (getLangCookie() as "en" | "es") ?? "en",
|
||||
screen: "pin",
|
||||
tasks: [],
|
||||
openEntry: null,
|
||||
payPeriod: null,
|
||||
clockOutResult: null,
|
||||
selectedTaskId: "",
|
||||
lunchMinutes: 0,
|
||||
notes: "",
|
||||
error: null,
|
||||
submitting: false,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_LANG":
|
||||
return { ...state, lang: action.value };
|
||||
case "SET_SCREEN":
|
||||
return { ...state, screen: action.value };
|
||||
case "SET_TASKS":
|
||||
return { ...state, tasks: action.value };
|
||||
case "SET_OPEN_ENTRY":
|
||||
return { ...state, openEntry: action.value };
|
||||
case "SET_PAY_PERIOD":
|
||||
return { ...state, payPeriod: action.value };
|
||||
case "SET_CLOCK_OUT_RESULT":
|
||||
return { ...state, clockOutResult: action.value };
|
||||
case "SET_SELECTED_TASK_ID":
|
||||
return { ...state, selectedTaskId: action.value };
|
||||
case "SET_LUNCH_MINUTES":
|
||||
return { ...state, lunchMinutes: action.value };
|
||||
case "SET_NOTES":
|
||||
return { ...state, notes: action.value };
|
||||
case "SET_ERROR":
|
||||
return { ...state, error: action.value };
|
||||
case "SET_SUBMITTING":
|
||||
return { ...state, submitting: action.value };
|
||||
case "UPDATE_OPEN_ENTRY_ELAPSED":
|
||||
return state.openEntry
|
||||
? { ...state, openEntry: { ...state.openEntry, elapsed_minutes: action.minutes } }
|
||||
: state;
|
||||
case "RESET_FOR_LOGOUT":
|
||||
return {
|
||||
...state,
|
||||
openEntry: null,
|
||||
payPeriod: null,
|
||||
clockOutResult: null,
|
||||
selectedTaskId: "",
|
||||
lunchMinutes: 0,
|
||||
notes: "",
|
||||
screen: "pin",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TimeTrackingFieldClient({
|
||||
@@ -172,21 +258,12 @@ export default function TimeTrackingFieldClient({
|
||||
brandAccent: string;
|
||||
logoUrl?: string | null;
|
||||
}) {
|
||||
const [lang, setLang] = useState<"en" | "es">(() => (getLangCookie() as "en" | "es") ?? "en");
|
||||
const [screen, setScreen] = useState<Screen>("pin");
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const {
|
||||
lang, screen, tasks, openEntry, payPeriod, clockOutResult,
|
||||
selectedTaskId, lunchMinutes, notes, error, submitting,
|
||||
} = state;
|
||||
const sessionRef = useRef<TimeTrackingSession | null>(null);
|
||||
const [tasks, setTasks] = useState<TimeTaskField[]>([]);
|
||||
const [openEntry, setOpenEntry] = useState<OpenEntry | null>(null);
|
||||
const [payPeriod, setPayPeriod] = useState<PayPeriodHours | null>(null);
|
||||
const [clockOutResult, setClockOutResult] = useState<{ clock_out: string; total_minutes: number } | null>(null);
|
||||
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string>("");
|
||||
const [lunchMinutes, setLunchMinutes] = useState(0);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const t = lang === "en" ? TRANS_EN : TRANS_ES;
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const initRef = useRef(false);
|
||||
// Keep latest loadPayPeriod reachable from polling + init effects without
|
||||
@@ -194,10 +271,12 @@ export default function TimeTrackingFieldClient({
|
||||
// every render since it's a plain const).
|
||||
const loadPayPeriodRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
const t = lang === "en" ? TRANS_EN : TRANS_ES;
|
||||
|
||||
// Fetch pay period hours
|
||||
const loadPayPeriod = async () => {
|
||||
const result = await getWorkerPayPeriodHours(brandId);
|
||||
if (result.success) setPayPeriod(result);
|
||||
if (result.success) dispatch({ type: "SET_PAY_PERIOD", value: result });
|
||||
};
|
||||
// Keep ref pointing at the latest loadPayPeriod so effects below can call
|
||||
// it without re-running on every render. Updated in an effect (not during
|
||||
@@ -215,7 +294,7 @@ export default function TimeTrackingFieldClient({
|
||||
// Only check for open clock-in if session exists
|
||||
const stored = await getTimeTrackingSession();
|
||||
if (!stored) {
|
||||
setScreen("pin");
|
||||
dispatch({ type: "SET_SCREEN", value: "pin" });
|
||||
await loadPayPeriodRef.current();
|
||||
return;
|
||||
}
|
||||
@@ -224,15 +303,18 @@ export default function TimeTrackingFieldClient({
|
||||
await loadPayPeriodRef.current();
|
||||
|
||||
if (open.success && open.open && open.log_id) {
|
||||
setOpenEntry({
|
||||
log_id: open.log_id!,
|
||||
task_name: open.task_name ?? t.general_labor,
|
||||
clock_in: open.clock_in!,
|
||||
elapsed_minutes: open.elapsed_minutes ?? 0,
|
||||
dispatch({
|
||||
type: "SET_OPEN_ENTRY",
|
||||
value: {
|
||||
log_id: open.log_id!,
|
||||
task_name: open.task_name ?? t.general_labor,
|
||||
clock_in: open.clock_in!,
|
||||
elapsed_minutes: open.elapsed_minutes ?? 0,
|
||||
},
|
||||
});
|
||||
setScreen("working");
|
||||
dispatch({ type: "SET_SCREEN", value: "working" });
|
||||
} else {
|
||||
setScreen("task_select");
|
||||
dispatch({ type: "SET_SCREEN", value: "task_select" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,9 +333,10 @@ export default function TimeTrackingFieldClient({
|
||||
pollRef.current = setInterval(async () => {
|
||||
const open = await getOpenClockIn(brandId);
|
||||
if (open.success && open.open && open.log_id) {
|
||||
setOpenEntry(prev =>
|
||||
prev ? { ...prev, elapsed_minutes: open.elapsed_minutes ?? prev.elapsed_minutes } : null
|
||||
);
|
||||
const minutes = open.elapsed_minutes;
|
||||
if (typeof minutes === "number") {
|
||||
dispatch({ type: "UPDATE_OPEN_ENTRY_ELAPSED", minutes });
|
||||
}
|
||||
}
|
||||
await loadPayPeriodRef.current();
|
||||
}, 30000);
|
||||
@@ -267,14 +350,14 @@ export default function TimeTrackingFieldClient({
|
||||
const toggleLang = () => {
|
||||
const next = lang === "en" ? "es" : "en";
|
||||
setLangCookie(next);
|
||||
setLang(next);
|
||||
dispatch({ type: "SET_LANG", value: next });
|
||||
};
|
||||
|
||||
const handlePinSubmit = async (pin: string) => {
|
||||
setError(null);
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
const result = await verifyTimeTrackingPin(brandId, pin);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? t.invalid_pin);
|
||||
dispatch({ type: "SET_ERROR", value: result.error ?? t.invalid_pin });
|
||||
return;
|
||||
}
|
||||
sessionRef.current = result.session!;
|
||||
@@ -282,18 +365,21 @@ export default function TimeTrackingFieldClient({
|
||||
getOpenClockIn(brandId),
|
||||
getTimeTrackingTasksField(brandId),
|
||||
]);
|
||||
setTasks(taskList);
|
||||
dispatch({ type: "SET_TASKS", value: taskList });
|
||||
await loadPayPeriod();
|
||||
if (open.success && open.open && open.log_id) {
|
||||
setOpenEntry({
|
||||
log_id: open.log_id!,
|
||||
task_name: open.task_name ?? t.general_labor,
|
||||
clock_in: open.clock_in!,
|
||||
elapsed_minutes: open.elapsed_minutes ?? 0,
|
||||
dispatch({
|
||||
type: "SET_OPEN_ENTRY",
|
||||
value: {
|
||||
log_id: open.log_id!,
|
||||
task_name: open.task_name ?? t.general_labor,
|
||||
clock_in: open.clock_in!,
|
||||
elapsed_minutes: open.elapsed_minutes ?? 0,
|
||||
},
|
||||
});
|
||||
setScreen("working");
|
||||
dispatch({ type: "SET_SCREEN", value: "working" });
|
||||
} else {
|
||||
setScreen("task_select");
|
||||
dispatch({ type: "SET_SCREEN", value: "task_select" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -311,81 +397,170 @@ export default function TimeTrackingFieldClient({
|
||||
};
|
||||
|
||||
const handleSelectTask = async (taskName: string) => {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
dispatch({ type: "SET_SUBMITTING", value: true });
|
||||
const result = await clockInWorker(brandId, undefined, taskName);
|
||||
setSubmitting(false);
|
||||
dispatch({ type: "SET_SUBMITTING", value: false });
|
||||
if (!result.success) {
|
||||
if (result.error === "Already clocked in") {
|
||||
const open = await getOpenClockIn(brandId);
|
||||
if (open.success && open.open) {
|
||||
setOpenEntry({
|
||||
log_id: open.log_id!,
|
||||
task_name: open.task_name ?? taskName,
|
||||
clock_in: open.clock_in!,
|
||||
elapsed_minutes: open.elapsed_minutes ?? 0,
|
||||
dispatch({
|
||||
type: "SET_OPEN_ENTRY",
|
||||
value: {
|
||||
log_id: open.log_id!,
|
||||
task_name: open.task_name ?? taskName,
|
||||
clock_in: open.clock_in!,
|
||||
elapsed_minutes: open.elapsed_minutes ?? 0,
|
||||
},
|
||||
});
|
||||
setScreen("working");
|
||||
dispatch({ type: "SET_SCREEN", value: "working" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
setError(result.error ?? "Clock in failed");
|
||||
dispatch({ type: "SET_ERROR", value: result.error ?? "Clock in failed" });
|
||||
return;
|
||||
}
|
||||
setOpenEntry({
|
||||
log_id: result.log_id!,
|
||||
task_name: taskName,
|
||||
clock_in: result.clock_in!,
|
||||
elapsed_minutes: 0,
|
||||
dispatch({
|
||||
type: "SET_OPEN_ENTRY",
|
||||
value: {
|
||||
log_id: result.log_id!,
|
||||
task_name: taskName,
|
||||
clock_in: result.clock_in!,
|
||||
elapsed_minutes: 0,
|
||||
},
|
||||
});
|
||||
await loadPayPeriod();
|
||||
if (payPeriod) {
|
||||
dispatchNotification(sessionRef.current?.name ?? taskName, payPeriod.daily_hours, payPeriod.weekly_hours);
|
||||
void dispatchNotification(sessionRef.current?.name ?? taskName, payPeriod.daily_hours, payPeriod.weekly_hours);
|
||||
}
|
||||
setScreen("working");
|
||||
dispatch({ type: "SET_SCREEN", value: "working" });
|
||||
};
|
||||
|
||||
const handleClockOut = async () => {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
dispatch({ type: "SET_ERROR", value: null });
|
||||
dispatch({ type: "SET_SUBMITTING", value: true });
|
||||
const result = await clockOutWorker(lunchMinutes, notes || undefined);
|
||||
setSubmitting(false);
|
||||
dispatch({ type: "SET_SUBMITTING", value: false });
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Clock out failed");
|
||||
dispatch({ type: "SET_ERROR", value: result.error ?? "Clock out failed" });
|
||||
return;
|
||||
}
|
||||
setClockOutResult({ clock_out: result.clock_out!, total_minutes: result.total_minutes ?? 0 });
|
||||
dispatch({
|
||||
type: "SET_CLOCK_OUT_RESULT",
|
||||
value: { clock_out: result.clock_out!, total_minutes: result.total_minutes ?? 0 },
|
||||
});
|
||||
await loadPayPeriod();
|
||||
if (payPeriod) {
|
||||
dispatchNotification(sessionRef.current?.name ?? openEntry?.task_name ?? "Worker", payPeriod.daily_hours, payPeriod.weekly_hours);
|
||||
void dispatchNotification(sessionRef.current?.name ?? openEntry?.task_name ?? "Worker", payPeriod.daily_hours, payPeriod.weekly_hours);
|
||||
}
|
||||
setScreen("clocked_out");
|
||||
dispatch({ type: "SET_SCREEN", value: "clocked_out" });
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logoutTimeTracking();
|
||||
sessionRef.current = null;
|
||||
setOpenEntry(null);
|
||||
setPayPeriod(null);
|
||||
setClockOutResult(null);
|
||||
setSelectedTaskId("");
|
||||
setLunchMinutes(0);
|
||||
setNotes("");
|
||||
setScreen("pin");
|
||||
dispatch({ type: "RESET_FOR_LOGOUT" });
|
||||
};
|
||||
|
||||
const handleBackToPin = () => {
|
||||
setClockOutResult(null);
|
||||
setOpenEntry(null);
|
||||
setPayPeriod(null);
|
||||
setSelectedTaskId("");
|
||||
setLunchMinutes(0);
|
||||
setNotes("");
|
||||
setScreen("pin");
|
||||
dispatch({ type: "RESET_FOR_LOGOUT" });
|
||||
};
|
||||
|
||||
const showOvertimeWarning = payPeriod && (payPeriod.daily_overtime || payPeriod.weekly_overtime);
|
||||
|
||||
return (
|
||||
<TimeTrackingScreens
|
||||
brandName={brandName}
|
||||
brandAccent={brandAccent}
|
||||
logoUrl={logoUrl}
|
||||
lang={lang}
|
||||
screen={screen}
|
||||
tasks={tasks}
|
||||
openEntry={openEntry}
|
||||
payPeriod={payPeriod}
|
||||
clockOutResult={clockOutResult}
|
||||
selectedTaskId={selectedTaskId}
|
||||
lunchMinutes={lunchMinutes}
|
||||
notes={notes}
|
||||
error={error}
|
||||
submitting={submitting}
|
||||
t={t}
|
||||
showOvertimeWarning={showOvertimeWarning ?? false}
|
||||
onToggleLang={toggleLang}
|
||||
onPinSubmit={handlePinSubmit}
|
||||
onSelectTask={handleSelectTask}
|
||||
onClockOutSubmit={handleClockOut}
|
||||
onClockOut={() => dispatch({ type: "SET_SCREEN", value: "clock_out_form" })}
|
||||
onLogout={handleLogout}
|
||||
onBackToPin={handleBackToPin}
|
||||
onBack={() => dispatch({ type: "SET_SCREEN", value: "working" })}
|
||||
onChangeSelectedTaskId={(v) => dispatch({ type: "SET_SELECTED_TASK_ID", value: v })}
|
||||
onChangeLunchMinutes={(v) => dispatch({ type: "SET_LUNCH_MINUTES", value: v })}
|
||||
onChangeNotes={(v) => dispatch({ type: "SET_NOTES", value: v })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Time Tracking Screens (presentation) ──────────────────────────────────────
|
||||
|
||||
function TimeTrackingScreens({
|
||||
brandName,
|
||||
brandAccent,
|
||||
logoUrl,
|
||||
lang,
|
||||
screen,
|
||||
tasks,
|
||||
openEntry,
|
||||
payPeriod,
|
||||
clockOutResult,
|
||||
selectedTaskId,
|
||||
lunchMinutes,
|
||||
notes,
|
||||
error,
|
||||
submitting,
|
||||
t,
|
||||
showOvertimeWarning,
|
||||
onToggleLang,
|
||||
onPinSubmit,
|
||||
onSelectTask,
|
||||
onClockOutSubmit,
|
||||
onClockOut,
|
||||
onLogout,
|
||||
onBackToPin,
|
||||
onBack,
|
||||
onChangeSelectedTaskId,
|
||||
onChangeLunchMinutes,
|
||||
onChangeNotes,
|
||||
}: {
|
||||
brandName: string;
|
||||
brandAccent: string;
|
||||
logoUrl?: string | null;
|
||||
lang: "en" | "es";
|
||||
screen: Screen;
|
||||
tasks: TimeTaskField[];
|
||||
openEntry: OpenEntry | null;
|
||||
payPeriod: PayPeriodHours | null;
|
||||
clockOutResult: { clock_out: string; total_minutes: number } | null;
|
||||
selectedTaskId: string;
|
||||
lunchMinutes: number;
|
||||
notes: string;
|
||||
error: string | null;
|
||||
submitting: boolean;
|
||||
t: Translations;
|
||||
showOvertimeWarning: boolean;
|
||||
onToggleLang: () => void;
|
||||
onPinSubmit: (pin: string) => void;
|
||||
onSelectTask: (name: string) => void;
|
||||
onClockOutSubmit: () => void;
|
||||
onClockOut: () => void;
|
||||
onLogout: () => void;
|
||||
onBackToPin: () => void;
|
||||
onBack: () => void;
|
||||
onChangeSelectedTaskId: (id: string) => void;
|
||||
onChangeLunchMinutes: (m: number) => void;
|
||||
onChangeNotes: (n: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-950 text-zinc-100">
|
||||
{/* Header */}
|
||||
@@ -397,7 +572,7 @@ export default function TimeTrackingFieldClient({
|
||||
<span className="text-sm font-semibold text-zinc-400">{brandName}</span>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={toggleLang}
|
||||
onClick={onToggleLang}
|
||||
className="text-xs font-mono text-zinc-500 hover:text-zinc-300 border border-zinc-700 rounded px-2 py-1 transition-colors"
|
||||
>
|
||||
{lang === "en" ? "ES" : "EN"}
|
||||
@@ -414,7 +589,7 @@ export default function TimeTrackingFieldClient({
|
||||
{screen === "pin" && (
|
||||
<PinEntry
|
||||
t={t}
|
||||
onSubmit={handlePinSubmit}
|
||||
onSubmit={onPinSubmit}
|
||||
error={error}
|
||||
submitting={submitting}
|
||||
logoUrl={logoUrl}
|
||||
@@ -426,7 +601,7 @@ export default function TimeTrackingFieldClient({
|
||||
t={t}
|
||||
tasks={tasks}
|
||||
lang={lang}
|
||||
onSelectTask={handleSelectTask}
|
||||
onSelectTask={onSelectTask}
|
||||
error={error}
|
||||
submitting={submitting}
|
||||
logoUrl={logoUrl}
|
||||
@@ -439,9 +614,9 @@ export default function TimeTrackingFieldClient({
|
||||
lang={lang}
|
||||
openEntry={openEntry}
|
||||
payPeriod={payPeriod}
|
||||
showOvertimeWarning={showOvertimeWarning ?? false}
|
||||
onClockOut={() => setScreen("clock_out_form")}
|
||||
onLogout={handleLogout}
|
||||
showOvertimeWarning={showOvertimeWarning}
|
||||
onClockOut={onClockOut}
|
||||
onLogout={onLogout}
|
||||
logoUrl={logoUrl}
|
||||
/>
|
||||
)}
|
||||
@@ -453,13 +628,13 @@ export default function TimeTrackingFieldClient({
|
||||
lang={lang}
|
||||
openEntryTaskName={openEntry.task_name}
|
||||
selectedTaskId={selectedTaskId}
|
||||
setSelectedTaskId={setSelectedTaskId}
|
||||
setSelectedTaskId={onChangeSelectedTaskId}
|
||||
lunchMinutes={lunchMinutes}
|
||||
setLunchMinutes={setLunchMinutes}
|
||||
setLunchMinutes={onChangeLunchMinutes}
|
||||
notes={notes}
|
||||
setNotes={setNotes}
|
||||
onSubmit={handleClockOut}
|
||||
onBack={() => setScreen("working")}
|
||||
setNotes={onChangeNotes}
|
||||
onSubmit={onClockOutSubmit}
|
||||
onBack={onBack}
|
||||
error={error}
|
||||
submitting={submitting}
|
||||
logoUrl={logoUrl}
|
||||
@@ -472,7 +647,7 @@ export default function TimeTrackingFieldClient({
|
||||
lang={lang}
|
||||
payPeriod={payPeriod}
|
||||
clockOutResult={clockOutResult}
|
||||
onBackToPin={handleBackToPin}
|
||||
onBackToPin={onBackToPin}
|
||||
logoUrl={logoUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user