Rebuild API Server on the PilotVault structure
Mirror PilotVault's API Server layout and add the superadmin console,
plugin system, runtime PocketBase settings, and user/organization
management. The car domain (cars, service records, parts, sharing) is
carried over unchanged apart from the auth switch.
Layout: main.go -> cmd/server/main.go; module carcontrol/api ->
drivervault/apiserver. internal/api is split by concern (auth, users,
orgs, settings, plugins, status, health, respond).
Auth: replace the server-minted HS256 JWT and the sessions collection
with a PocketBase token proxy. /api/auth/login relays PocketBase's
{token, record}, and every protected request re-resolves that token
against PocketBase, so a role change or deletion takes effect at once
instead of waiting out a token. AUTH_SECRET is obsolete and internal/auth
is gone. Per-device session listing/revocation goes with it: PocketBase
tokens are stateless. Changing a password rotates the user's token key,
which invalidates every token already issued.
Roles: add superadmin alongside user/admin, plus an organizations
collection and users.organization. Admins are scoped to their own
organization; superadmins span all of them. Guards prevent changing your
own role, deleting your own account, an admin touching a superadmin, and
deleting an organization that still has members.
Plugins: new internal/plugins package with one contract over two kinds --
builtin (compiled in) and external (any HTTP service, registered at
runtime with no rebuild). State persists to plugins.json; secrets are
masked on read and preserved when saved back at the mask.
PocketBase settings: /api/admin/pb-config applies a new connection at
runtime and persists it to .env. It deliberately does not require a
working service account, so a wrong or unreachable connection can still
be fixed from the panel.
Panel: rebuilt as the superadmin console -- login gate, status, users,
organizations, PocketBase, plugins, and the endpoint reference.
Clients: update the Web App and Phone App for the PocketBase token shape,
the move of user management to /api/users ({users}/{user} envelopes, with
password resets folded into PATCH), and the removal of sessions. Both now
mirror the server's real guards rather than the old last-admin rule, and
parse PocketBase's field-level error shape.
Config: modern POCKETBASE_*/API_ADDR names with legacy PB_*/PORT
fallbacks, so existing .env files keep working. Also fixes /api/status
probing the Web App on 8090 instead of DriverVault's 5173.
Run scripts/setup-pocketbase.mjs to add the organizations collection and
grow users.role; every client must log in once more.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7d55f0a4cd
commit
ae6ed4ac1e
+239
-225
@@ -3,30 +3,117 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"carcontrol/api/internal/auth"
|
||||
)
|
||||
|
||||
// tokenTTL is how long an issued login token stays valid.
|
||||
const tokenTTL = 7 * 24 * time.Hour
|
||||
// Role names as stored in the PocketBase users.role select field. A missing or
|
||||
// empty value is treated as roleUser.
|
||||
const (
|
||||
roleUser = "user"
|
||||
roleAdmin = "admin"
|
||||
roleSuperadmin = "superadmin"
|
||||
)
|
||||
|
||||
// publicPaths bypass authentication. Everything else requires a valid token.
|
||||
// publicPaths bypass authentication. Everything else under /api/ requires a
|
||||
// valid PocketBase token.
|
||||
var publicPaths = map[string]bool{
|
||||
"/api/health": true,
|
||||
"/api/auth/login": true,
|
||||
"/api/health": true,
|
||||
"/api/status": true,
|
||||
"/api/auth/login": true,
|
||||
"/api/auth/validate": true,
|
||||
"/healthz": true,
|
||||
}
|
||||
|
||||
type ctxKey string
|
||||
type ctxKey int
|
||||
|
||||
const claimsKey ctxKey = "claims"
|
||||
const ctxCaller ctxKey = iota
|
||||
|
||||
// withAuth rejects requests to non-public paths that lack a valid bearer token.
|
||||
// callerIdentity is who the request token belongs to. It is resolved from
|
||||
// PocketBase on each request, so a role change or a deletion takes effect
|
||||
// immediately rather than lingering until a token expires.
|
||||
type callerIdentity struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
Role string
|
||||
OrgID string // organization record id ("" when the user belongs to no org)
|
||||
}
|
||||
|
||||
func (c *callerIdentity) isSuperadmin() bool { return c != nil && c.Role == roleSuperadmin }
|
||||
func (c *callerIdentity) isManager() bool {
|
||||
return c != nil && (c.Role == roleAdmin || c.Role == roleSuperadmin)
|
||||
}
|
||||
|
||||
// caller returns the identity stashed on the request context by withAuth.
|
||||
func caller(r *http.Request) *callerIdentity {
|
||||
if v, ok := r.Context().Value(ctxCaller).(*callerIdentity); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// currentUserID returns the authenticated user's id. Handlers on non-public
|
||||
// paths can treat "" as "not authenticated" — withAuth already rejected those.
|
||||
func (s *Server) currentUserID(r *http.Request) string {
|
||||
if who := caller(r); who != nil {
|
||||
return who.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// bearerToken extracts the caller's token, accepting both "Bearer <token>" and
|
||||
// a raw token (PocketBase's own SDKs send the latter).
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
return strings.TrimSpace(h)
|
||||
}
|
||||
|
||||
// identify resolves the caller's id/email/name/role/org from their PocketBase
|
||||
// token by asking PocketBase to refresh it. A non-200 status means the token is
|
||||
// invalid or expired.
|
||||
func (s *Server) identify(ctx context.Context, token string) (*callerIdentity, int, error) {
|
||||
raw, status, err := s.pb.AuthRefresh(ctx, s.usersCollection(), token)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, status, nil
|
||||
}
|
||||
|
||||
var out struct {
|
||||
Record struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Organization string `json:"organization"`
|
||||
} `json:"record"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, status, err
|
||||
}
|
||||
role := out.Record.Role
|
||||
if role == "" {
|
||||
role = roleUser
|
||||
}
|
||||
return &callerIdentity{
|
||||
ID: out.Record.ID,
|
||||
Email: out.Record.Email,
|
||||
Name: out.Record.Name,
|
||||
Role: role,
|
||||
OrgID: out.Record.Organization,
|
||||
}, http.StatusOK, nil
|
||||
}
|
||||
|
||||
// withAuth rejects requests to non-public /api/ paths that lack a valid
|
||||
// PocketBase token, and stashes the resolved identity on the request context.
|
||||
func (s *Server) withAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Public API paths, plus everything outside /api/ (the embedded web
|
||||
@@ -35,64 +122,155 @@ func (s *Server) withAuth(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing bearer token")
|
||||
who, ok := s.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims, err := auth.Verify(s.authSecret, token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
if s.sessionRevoked(r.Context(), claims.Jti) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), claimsKey, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxCaller, who)))
|
||||
})
|
||||
}
|
||||
|
||||
// sessionRevoked reports whether the token's backing session is missing or
|
||||
// revoked (e.g. via "log out this device" from the settings panel). Tokens
|
||||
// minted before session-tracking existed have no jti and are rejected too,
|
||||
// which simply forces one fresh login.
|
||||
func (s *Server) sessionRevoked(ctx context.Context, jti string) bool {
|
||||
if jti == "" {
|
||||
return true
|
||||
// authenticate resolves and validates the caller, writing the error response
|
||||
// itself and returning ok=false when the request should not proceed.
|
||||
func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (*callerIdentity, bool) {
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "missing bearer token")
|
||||
return nil, false
|
||||
}
|
||||
res, err := s.pb.List(ctx, colSessions, url.Values{
|
||||
"filter": {fmt.Sprintf("jti='%s'", jti)},
|
||||
"perPage": {"1"},
|
||||
})
|
||||
who, status, err := s.identify(r.Context(), token)
|
||||
if err != nil {
|
||||
return true
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return nil, false
|
||||
}
|
||||
var recs []sessionRecord
|
||||
if err := json.Unmarshal(res.Items, &recs); err != nil || len(recs) == 0 {
|
||||
return true
|
||||
if status != http.StatusOK || who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired token")
|
||||
return nil, false
|
||||
}
|
||||
return recs[0].Revoked
|
||||
return who, true
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
// requireRole is the shared gate for privileged handlers: it needs the service
|
||||
// account (every privileged flow runs through it) and a caller satisfying ok.
|
||||
// withAuth has already established the identity.
|
||||
func (s *Server) requireRole(next http.HandlerFunc, ok func(*callerIdentity) bool, denied string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.pb.Configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "user management not configured on the server")
|
||||
return
|
||||
}
|
||||
if !ok(caller(r)) {
|
||||
writeError(w, http.StatusForbidden, denied)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
// Accept both "Bearer <token>" and a raw token.
|
||||
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
|
||||
return strings.TrimSpace(after)
|
||||
}
|
||||
return strings.TrimSpace(h)
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
// requireManager wraps a handler so only managers (admin or superadmin) proceed.
|
||||
func (s *Server) requireManager(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.requireRole(next, func(c *callerIdentity) bool { return c.isManager() }, "admin role required")
|
||||
}
|
||||
|
||||
// requireSuperadmin wraps a handler so only superadmins proceed.
|
||||
func (s *Server) requireSuperadmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.requireRole(next, func(c *callerIdentity) bool { return c.isSuperadmin() }, "superadmin role required")
|
||||
}
|
||||
|
||||
// requireSuperadminAuth gates a handler on a superadmin caller WITHOUT requiring
|
||||
// the service account to already be configured. Used by the PocketBase settings
|
||||
// endpoints, whose whole purpose is to configure that service account.
|
||||
func (s *Server) requireSuperadminAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !caller(r).isSuperadmin() {
|
||||
writeError(w, http.StatusForbidden, "superadmin role required")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/auth/login
|
||||
// Body: {"email"|"identity":"...","password":"..."}
|
||||
// Proxies to the PocketBase users auth-with-password and relays its response —
|
||||
// the client gets PocketBase's own token and user record. PocketBase's address
|
||||
// lives only in this server and is never exposed to clients.
|
||||
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Identity string `json:"identity"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
identity := body.Identity
|
||||
if identity == "" {
|
||||
identity = body.Email
|
||||
}
|
||||
if identity == "" || body.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "email and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
raw, status, err := s.pb.LoginWithPassword(r.Context(), s.usersCollection(), identity, body.Password)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
relay(w, status, raw)
|
||||
}
|
||||
|
||||
// GET /api/auth/validate (Authorization: <pb token>)
|
||||
// Proxies to PocketBase auth-refresh to confirm a token is still valid.
|
||||
func (s *Server) handleAuthValidate(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]any{"valid": false})
|
||||
return
|
||||
}
|
||||
raw, status, err := s.pb.AuthRefresh(r.Context(), s.usersCollection(), token)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"valid": false, "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
relay(w, status, raw)
|
||||
}
|
||||
|
||||
// GET /api/auth/me — the caller's identity, from their token.
|
||||
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
if who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, userInfo{ID: who.ID, Email: who.Email, Name: who.Name, Role: who.Role})
|
||||
}
|
||||
|
||||
// GET /api/identity — like /api/auth/me, plus the caller's organization. The
|
||||
// panel uses this to decide which management cards to show.
|
||||
func (s *Server) handleIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
if who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
orgName := ""
|
||||
if who.OrgID != "" {
|
||||
orgName = s.orgName(r.Context(), who.OrgID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": who.ID,
|
||||
"email": who.Email,
|
||||
"name": who.Name,
|
||||
"role": who.Role,
|
||||
"organization": who.OrgID,
|
||||
"organizationName": orgName,
|
||||
})
|
||||
}
|
||||
|
||||
// userInfo is the compact identity shape returned by /api/auth/me.
|
||||
type userInfo struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
@@ -100,173 +278,9 @@ type userInfo struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Token string `json:"token"`
|
||||
User userInfo `json:"user"`
|
||||
}
|
||||
|
||||
// handleLogin verifies credentials against the PocketBase users collection and,
|
||||
// on success, mints an API Server JWT.
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var in loginRequest
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Email == "" || in.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "email and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
rec, err := s.pb.AuthWithPassword(r.Context(), s.usersCollection, in.Email, in.Password)
|
||||
if err != nil {
|
||||
// Don't leak whether it was the email or the password.
|
||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
// The auth record doesn't carry the role field; fetch it so the token and
|
||||
// login response reflect the user's access role. Default to "user".
|
||||
role := "user"
|
||||
if u, err := s.fetchUser(r, rec.ID); err == nil {
|
||||
role = orDefault(u.Role, "user")
|
||||
}
|
||||
|
||||
jti, err := auth.NewJTI()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not issue token")
|
||||
return
|
||||
}
|
||||
if err := s.createSession(r.Context(), rec.ID, jti, r); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create session")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.Sign(s.authSecret, auth.Claims{
|
||||
Sub: rec.ID,
|
||||
Email: rec.Email,
|
||||
Name: rec.Name,
|
||||
Role: role,
|
||||
Jti: jti,
|
||||
}, tokenTTL)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not issue token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, loginResponse{
|
||||
Token: token,
|
||||
User: userInfo{ID: rec.ID, Email: rec.Email, Name: rec.Name, Role: role},
|
||||
})
|
||||
}
|
||||
|
||||
// sessionRecord is one row of the "sessions" collection — a login token's
|
||||
// device/revocation record, used to power "active sessions" in the settings
|
||||
// panel and to let a user log a device out remotely.
|
||||
type sessionRecord struct {
|
||||
ID string `json:"id"`
|
||||
User string `json:"user"`
|
||||
Jti string `json:"jti"`
|
||||
DeviceLabel string `json:"device_label"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Revoked bool `json:"revoked"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
func (s *Server) createSession(ctx context.Context, userID, jti string, r *http.Request) error {
|
||||
payload := map[string]any{
|
||||
"user": userID,
|
||||
"jti": jti,
|
||||
"device_label": deviceLabelFromUA(r.UserAgent()),
|
||||
"ip": clientIP(r),
|
||||
"user_agent": r.UserAgent(),
|
||||
"revoked": false,
|
||||
"expires_at": time.Now().Add(tokenTTL).UTC().Format(time.RFC3339),
|
||||
}
|
||||
return s.pb.Create(ctx, colSessions, payload, nil)
|
||||
}
|
||||
|
||||
// clientIP prefers a forwarding header (in case of a future reverse proxy)
|
||||
// and otherwise strips the port from the raw remote address.
|
||||
func clientIP(r *http.Request) string {
|
||||
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
|
||||
return strings.TrimSpace(strings.Split(xf, ",")[0])
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// deviceLabelFromUA turns a User-Agent header into a short human label like
|
||||
// "Chrome on Windows" for the active-sessions list. Best-effort only.
|
||||
func deviceLabelFromUA(ua string) string {
|
||||
if ua == "" {
|
||||
return "Unknown device"
|
||||
}
|
||||
|
||||
var os string
|
||||
switch {
|
||||
case strings.Contains(ua, "Android"):
|
||||
os = "Android"
|
||||
case strings.Contains(ua, "iPhone"), strings.Contains(ua, "iPad"):
|
||||
os = "iOS"
|
||||
case strings.Contains(ua, "Windows"):
|
||||
os = "Windows"
|
||||
case strings.Contains(ua, "Mac OS X"), strings.Contains(ua, "Macintosh"):
|
||||
os = "macOS"
|
||||
case strings.Contains(ua, "Linux"):
|
||||
os = "Linux"
|
||||
}
|
||||
|
||||
var app string
|
||||
switch {
|
||||
case strings.Contains(ua, "Edg/"):
|
||||
app = "Edge"
|
||||
case strings.Contains(ua, "Chrome/"):
|
||||
app = "Chrome"
|
||||
case strings.Contains(ua, "Firefox/"):
|
||||
app = "Firefox"
|
||||
case strings.Contains(ua, "Safari/") && !strings.Contains(ua, "Chrome"):
|
||||
app = "Safari"
|
||||
case strings.Contains(ua, "Dart") || strings.Contains(ua, "okhttp"):
|
||||
app = "Car Control app"
|
||||
}
|
||||
|
||||
switch {
|
||||
case app != "" && os != "":
|
||||
return app + " on " + os
|
||||
case app != "":
|
||||
return app
|
||||
case os != "":
|
||||
return os
|
||||
case len(ua) > 60:
|
||||
return ua[:60]
|
||||
default:
|
||||
return ua
|
||||
}
|
||||
}
|
||||
|
||||
// currentUserID returns the authenticated user's id from the request context.
|
||||
// Returns "" only if called on an unauthenticated request (withAuth already
|
||||
// guards every non-public path, so handlers can treat "" as "not authenticated").
|
||||
func (s *Server) currentUserID(r *http.Request) string {
|
||||
if claims, ok := r.Context().Value(claimsKey).(*auth.Claims); ok {
|
||||
return claims.Sub
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// handleMe returns the authenticated user from the token claims.
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := r.Context().Value(claimsKey).(*auth.Claims)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, userInfo{ID: claims.Sub, Email: claims.Email, Name: claims.Name, Role: claims.Role})
|
||||
// relay copies an upstream PocketBase status + JSON body to the client.
|
||||
func relay(w http.ResponseWriter, status int, body []byte) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user