P2-04: schedule editor UI
Closes the schedule foundations slice — operator can now drive the
plumbing P2-01..03 landed without touching the JSON API.
* New routes:
- GET /hosts/{id}/schedules (list)
- GET /hosts/{id}/schedules/new (create form)
- POST /hosts/{id}/schedules/new (create)
- GET /hosts/{id}/schedules/{sid}/edit (edit form)
- POST /hosts/{id}/schedules/{sid}/edit (update)
- POST /hosts/{id}/schedules/{sid}/delete (delete, confirm-then-redirect)
* List view (web/templates/pages/schedules_list.html):
status, cron, paths, retention summary, tags, edit/delete buttons.
Header shows "version N · agent in sync" or "agent at vM" when the
push hasn't been ack'd yet — backed by host_schedule_version +
applied_schedule_version. Empty-state CTA points at /schedules/new.
* Create/edit form (web/templates/pages/schedule_edit.html, shared):
cron expression with five quick-pick presets (daily 3am / every 6h
/ @hourly / weekly Sun / monthly 1st), paths textarea (one per
line), excludes textarea, tags (comma-separated), retention as six
numeric fields (mirrors restic's --keep-* flags one-for-one),
bandwidth caps, enabled toggle. Side panel explains the
reconciliation flow so the operator knows what saving actually
does. Validation errors re-render with operator's input intact.
* internal/server/http/ui_schedules.go owns the handlers; reuses
the same validateSchedule + pushScheduleSetAsync used by the JSON
API path. Each save audit-logs schedule.created / schedule.updated
/ schedule.deleted (matching the JSON API actions).
* store.RetentionPolicy gains a Summary() method ("last=7, d=14,
w=4" or "—"). Used by the list view's table cell so templates
don't have to do any conditional retention rendering.
* Two new template helpers: list (string varargs → []string, used
for the cron preset row) and joinComma (sibling to joinDot for
the rare list that wants commas). RetentionPolicy.Summary covers
the schedule-list case but the helpers are general.
* host_detail.html secondary tabs row converted from inert <div>s
into <a> links. Snapshots active by default; Schedules now points
at the new page. Jobs/Repo/Settings remain inert until their
P2 owners ship.
Hooks UI deferred to P2-15 (lands with the hook execution path).
Single-kind UI (backup only) by design — other kinds get a UI when
their job dispatch lands in P2-05..08.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -153,6 +153,13 @@ func (s *Server) routes(r chi.Router) {
|
||||
r.Post("/hosts/new", s.handleUIAddHostPost)
|
||||
// Host detail (Snapshots tab is the default).
|
||||
r.Get("/hosts/{id}", s.handleUIHostDetail)
|
||||
// Schedules tab + create/edit/delete forms.
|
||||
r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
|
||||
r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
|
||||
r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
|
||||
r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet)
|
||||
r.Post("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleSave)
|
||||
r.Post("/hosts/{id}/schedules/{sid}/delete", s.handleUIScheduleDelete)
|
||||
// Live job log.
|
||||
r.Get("/jobs/{id}", s.handleUIJobDetail)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
stdhttp "net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
||||
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
||||
)
|
||||
|
||||
// schedulesListPage carries everything the Schedules tab needs.
|
||||
type schedulesListPage struct {
|
||||
Host store.Host
|
||||
Schedules []store.Schedule
|
||||
Version int64
|
||||
AppliedVersion int64
|
||||
}
|
||||
|
||||
// scheduleEditPage drives both the Create form (Schedule.ID empty)
|
||||
// and the Edit form (Schedule populated). Errors come back via Error
|
||||
// to be rendered as a banner; FormValues holds the just-submitted
|
||||
// raw fields so a failed POST can re-render with the operator's
|
||||
// typed input still in place.
|
||||
type scheduleEditPage struct {
|
||||
Host store.Host
|
||||
IsNew bool
|
||||
ScheduleID string
|
||||
Error string
|
||||
// Form values — strings so partial input survives validation
|
||||
// errors (e.g. operator typed "abc" into keep_last).
|
||||
CronExpr string
|
||||
PathsRaw string
|
||||
ExcludesRaw string
|
||||
TagsRaw string
|
||||
KeepLast string
|
||||
KeepHourly string
|
||||
KeepDaily string
|
||||
KeepWeekly string
|
||||
KeepMonthly string
|
||||
KeepYearly string
|
||||
LimitUpKBps string
|
||||
LimitDownKBps string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// handleUISchedulesList renders the Schedules sub-tab on a host.
|
||||
func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rows, err := s.deps.Store.ListSchedulesByHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
version, _ := s.deps.Store.GetHostScheduleVersion(r.Context(), hostID)
|
||||
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = host.Name + " · schedules · restic-manager"
|
||||
view.Page = schedulesListPage{
|
||||
Host: *host,
|
||||
Schedules: rows,
|
||||
Version: version,
|
||||
AppliedVersion: host.AppliedScheduleVersion,
|
||||
}
|
||||
if err := s.deps.UI.Render(w, "schedules_list", view); err != nil {
|
||||
slog.Error("ui: render schedules_list", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleUIScheduleNewGet renders the empty Create form.
|
||||
func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = "New schedule · " + host.Name
|
||||
view.Page = scheduleEditPage{
|
||||
Host: *host,
|
||||
IsNew: true,
|
||||
CronExpr: "0 3 * * *",
|
||||
Enabled: true,
|
||||
}
|
||||
s.renderScheduleEdit(w, view)
|
||||
}
|
||||
|
||||
// handleUIScheduleEditGet renders the Edit form pre-filled from the
|
||||
// existing schedule row.
|
||||
func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
scheduleID := chi.URLParam(r, "sid")
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sched, err := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
page := scheduleEditPage{
|
||||
Host: *host,
|
||||
IsNew: false,
|
||||
ScheduleID: sched.ID,
|
||||
CronExpr: sched.CronExpr,
|
||||
PathsRaw: strings.Join(sched.Paths, "\n"),
|
||||
ExcludesRaw: strings.Join(sched.Excludes, "\n"),
|
||||
TagsRaw: strings.Join(sched.Tags, ", "),
|
||||
Enabled: sched.Enabled,
|
||||
}
|
||||
page.KeepLast = intStringPtr(sched.RetentionPolicy.KeepLast)
|
||||
page.KeepHourly = intStringPtr(sched.RetentionPolicy.KeepHourly)
|
||||
page.KeepDaily = intStringPtr(sched.RetentionPolicy.KeepDaily)
|
||||
page.KeepWeekly = intStringPtr(sched.RetentionPolicy.KeepWeekly)
|
||||
page.KeepMonthly = intStringPtr(sched.RetentionPolicy.KeepMonthly)
|
||||
page.KeepYearly = intStringPtr(sched.RetentionPolicy.KeepYearly)
|
||||
page.LimitUpKBps = intStringPtr(sched.Options.LimitUploadKBps)
|
||||
page.LimitDownKBps = intStringPtr(sched.Options.LimitDownloadKBps)
|
||||
|
||||
view := s.baseView(u, "dashboard")
|
||||
view.Title = "Edit schedule · " + host.Name
|
||||
view.Page = page
|
||||
s.renderScheduleEdit(w, view)
|
||||
}
|
||||
|
||||
// handleUIScheduleSave handles POST for both create and update. The
|
||||
// edit form posts to /hosts/{id}/schedules/new (for create) or
|
||||
// /hosts/{id}/schedules/{sid}/edit (for update); we branch on whether
|
||||
// {sid} is present in the route params.
|
||||
func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
scheduleID := chi.URLParam(r, "sid")
|
||||
storeUser, _, err := s.userByID(r, u.ID)
|
||||
if err != nil || storeUser == nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
host, err := s.deps.Store.GetHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
page := scheduleEditPage{
|
||||
Host: *host,
|
||||
IsNew: scheduleID == "",
|
||||
ScheduleID: scheduleID,
|
||||
CronExpr: strings.TrimSpace(r.PostForm.Get("cron_expr")),
|
||||
PathsRaw: r.PostForm.Get("paths"),
|
||||
ExcludesRaw: r.PostForm.Get("excludes"),
|
||||
TagsRaw: strings.TrimSpace(r.PostForm.Get("tags")),
|
||||
KeepLast: strings.TrimSpace(r.PostForm.Get("keep_last")),
|
||||
KeepHourly: strings.TrimSpace(r.PostForm.Get("keep_hourly")),
|
||||
KeepDaily: strings.TrimSpace(r.PostForm.Get("keep_daily")),
|
||||
KeepWeekly: strings.TrimSpace(r.PostForm.Get("keep_weekly")),
|
||||
KeepMonthly: strings.TrimSpace(r.PostForm.Get("keep_monthly")),
|
||||
KeepYearly: strings.TrimSpace(r.PostForm.Get("keep_yearly")),
|
||||
LimitUpKBps: strings.TrimSpace(r.PostForm.Get("limit_up_kbps")),
|
||||
LimitDownKBps: strings.TrimSpace(r.PostForm.Get("limit_down_kbps")),
|
||||
Enabled: r.PostForm.Get("enabled") == "on",
|
||||
}
|
||||
|
||||
// Convert the raw form values into store-shape data, surfacing
|
||||
// the first parse error as a banner.
|
||||
paths := splitPaths(page.PathsRaw)
|
||||
excludes := splitPaths(page.ExcludesRaw)
|
||||
tags := splitCSV(page.TagsRaw)
|
||||
|
||||
retention, err := parseRetention(page)
|
||||
if err != nil {
|
||||
page.Error = err.Error()
|
||||
s.renderEditPage(w, u, page)
|
||||
return
|
||||
}
|
||||
options, err := parseOptions(page)
|
||||
if err != nil {
|
||||
page.Error = err.Error()
|
||||
s.renderEditPage(w, u, page)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate against the same rules the JSON API uses (cron, paths,
|
||||
// hooks-on-non-backup) — the UI only handles backup kind today,
|
||||
// so we hardcode kind=backup here.
|
||||
apiShape := scheduleAPI{
|
||||
Kind: api.JobBackup,
|
||||
CronExpr: page.CronExpr,
|
||||
Paths: paths,
|
||||
}
|
||||
if code, msg := validateSchedule(&apiShape); code != "" {
|
||||
page.Error = uiErrorMessage(code, msg)
|
||||
s.renderEditPage(w, u, page)
|
||||
return
|
||||
}
|
||||
|
||||
if page.IsNew {
|
||||
row := store.Schedule{
|
||||
ID: ulid.Make().String(),
|
||||
HostID: hostID,
|
||||
Kind: string(api.JobBackup),
|
||||
CronExpr: page.CronExpr,
|
||||
Paths: paths,
|
||||
Excludes: excludes,
|
||||
Tags: tags,
|
||||
RetentionPolicy: retention,
|
||||
Options: options,
|
||||
Enabled: page.Enabled,
|
||||
}
|
||||
if err := s.deps.Store.CreateSchedule(r.Context(), &row); err != nil {
|
||||
page.Error = "Couldn't save schedule — see server log."
|
||||
slog.Error("ui schedule create", "err", err)
|
||||
s.renderEditPage(w, u, page)
|
||||
return
|
||||
}
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(),
|
||||
UserID: &storeUser.ID,
|
||||
Actor: "user",
|
||||
Action: "schedule.created",
|
||||
TargetKind: ptr("schedule"),
|
||||
TargetID: &row.ID,
|
||||
TS: nowUTC(),
|
||||
})
|
||||
s.pushScheduleSetAsync(hostID)
|
||||
} else {
|
||||
existing, err := s.deps.Store.GetSchedule(r.Context(), hostID, scheduleID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
existing.CronExpr = page.CronExpr
|
||||
existing.Paths = paths
|
||||
existing.Excludes = excludes
|
||||
existing.Tags = tags
|
||||
existing.RetentionPolicy = retention
|
||||
existing.Options = options
|
||||
existing.Enabled = page.Enabled
|
||||
if err := s.deps.Store.UpdateSchedule(r.Context(), existing); err != nil {
|
||||
page.Error = "Couldn't save schedule — see server log."
|
||||
slog.Error("ui schedule update", "err", err)
|
||||
s.renderEditPage(w, u, page)
|
||||
return
|
||||
}
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(),
|
||||
UserID: &storeUser.ID,
|
||||
Actor: "user",
|
||||
Action: "schedule.updated",
|
||||
TargetKind: ptr("schedule"),
|
||||
TargetID: &scheduleID,
|
||||
TS: nowUTC(),
|
||||
})
|
||||
s.pushScheduleSetAsync(hostID)
|
||||
}
|
||||
|
||||
stdhttp.Redirect(w, r, "/hosts/"+hostID+"/schedules", stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleUIScheduleDelete is the POST target of the Delete buttons on
|
||||
// the list view. Confirm-then-redirect; no AJAX.
|
||||
func (s *Server) handleUIScheduleDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
||||
u := s.requireUIUser(w, r)
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
hostID := chi.URLParam(r, "id")
|
||||
scheduleID := chi.URLParam(r, "sid")
|
||||
storeUser, _, err := s.userByID(r, u.ID)
|
||||
if err != nil || storeUser == nil {
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.deps.Store.DeleteSchedule(r.Context(), hostID, scheduleID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
stdhttp.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
|
||||
ID: ulid.Make().String(),
|
||||
UserID: &storeUser.ID,
|
||||
Actor: "user",
|
||||
Action: "schedule.deleted",
|
||||
TargetKind: ptr("schedule"),
|
||||
TargetID: &scheduleID,
|
||||
TS: nowUTC(),
|
||||
})
|
||||
s.pushScheduleSetAsync(hostID)
|
||||
stdhttp.Redirect(w, r, "/hosts/"+hostID+"/schedules", stdhttp.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) renderScheduleEdit(w stdhttp.ResponseWriter, view ui.ViewData) {
|
||||
if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil {
|
||||
slog.Error("ui: render schedule_edit", "err", err)
|
||||
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) renderEditPage(w stdhttp.ResponseWriter, u *ui.User, page scheduleEditPage) {
|
||||
view := s.baseView(u, "dashboard")
|
||||
if page.IsNew {
|
||||
view.Title = "New schedule · " + page.Host.Name
|
||||
} else {
|
||||
view.Title = "Edit schedule · " + page.Host.Name
|
||||
}
|
||||
view.Page = page
|
||||
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
|
||||
s.renderScheduleEdit(w, view)
|
||||
}
|
||||
|
||||
// ----- helpers --------------------------------------------------------
|
||||
|
||||
// splitCSV parses comma-separated values into a clean []string —
|
||||
// leading/trailing whitespace trimmed, blanks dropped.
|
||||
func splitCSV(s string) []string {
|
||||
out := []string{}
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseRetention(p scheduleEditPage) (store.RetentionPolicy, error) {
|
||||
var r store.RetentionPolicy
|
||||
for _, f := range []struct {
|
||||
raw string
|
||||
dest **int
|
||||
name string
|
||||
}{
|
||||
{p.KeepLast, &r.KeepLast, "keep last"},
|
||||
{p.KeepHourly, &r.KeepHourly, "keep hourly"},
|
||||
{p.KeepDaily, &r.KeepDaily, "keep daily"},
|
||||
{p.KeepWeekly, &r.KeepWeekly, "keep weekly"},
|
||||
{p.KeepMonthly, &r.KeepMonthly, "keep monthly"},
|
||||
{p.KeepYearly, &r.KeepYearly, "keep yearly"},
|
||||
} {
|
||||
v, err := parsePosInt(f.raw)
|
||||
if err != nil {
|
||||
return r, errFmtf("%s: %s", f.name, err)
|
||||
}
|
||||
*f.dest = v
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func parseOptions(p scheduleEditPage) (store.ScheduleOptions, error) {
|
||||
var o store.ScheduleOptions
|
||||
up, err := parsePosInt(p.LimitUpKBps)
|
||||
if err != nil {
|
||||
return o, errFmtf("limit upload: %s", err)
|
||||
}
|
||||
o.LimitUploadKBps = up
|
||||
down, err := parsePosInt(p.LimitDownKBps)
|
||||
if err != nil {
|
||||
return o, errFmtf("limit download: %s", err)
|
||||
}
|
||||
o.LimitDownloadKBps = down
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// parsePosInt turns a possibly-empty string into *int. Empty → nil
|
||||
// (no value). Non-empty must parse as a positive int.
|
||||
func parsePosInt(raw string) (*int, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return nil, errFmtf("must be a whole number")
|
||||
}
|
||||
if v < 0 {
|
||||
return nil, errFmtf("must be non-negative")
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
func intStringPtr(p *int) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(*p)
|
||||
}
|
||||
|
||||
// uiErrorMessage maps the JSON-API validation codes to operator-
|
||||
// friendly banner text.
|
||||
func uiErrorMessage(code, msg string) string {
|
||||
switch code {
|
||||
case "missing_cron_expr":
|
||||
return "Cron expression is required."
|
||||
case "invalid_cron_expr":
|
||||
return "Cron expression doesn't parse: " + msg
|
||||
case "missing_paths":
|
||||
return "At least one backup path is required (one per line)."
|
||||
case "invalid_kind":
|
||||
return "Unsupported schedule kind."
|
||||
default:
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
// errFmtf wraps fmt.Errorf so the validators read consistently.
|
||||
func errFmtf(format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
Reference in New Issue
Block a user