package api import ( "errors" "net/http" "strings" "time" "github.com/jackc/pgx/v5" "github.com/loyaly/behavision-server/internal/auth" ) // The platform administrator's remaining shell-only jobs, as endpoints: // suspend or reinstate a company, reset its owner's password, delete it. // // All three are behind adminOnly (a principal with the role AND no client), and // all three read the company id from the path. None takes a client id from a // body - the same rule every tenant handler follows. // PATCH /api/admin/clients/{id} {"active": false} // // Suspension is the reversible step and it is complete: login refuses the // company's users, the broker's visits are dropped at ingest, and every live // session is revoked in the same transaction. Without the last, "suspend" would // mean "suspend some time tomorrow", which is not what anybody pressing it // believes they did. func (s *Server) handleSetClientActive(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) var in struct { Active *bool `json:"active"` } if err := decode(w, r, &in); err != nil { badRequest(w, err.Error()) return } if in.Active == nil { badRequest(w, `"active" is required: true to reinstate, false to suspend`) return } row, revoked, err := s.Store.SetClientActive(r.Context(), r.PathValue("id"), *in.Active) if err != nil { if errors.Is(err, pgx.ErrNoRows) { writeErr(w, http.StatusNotFound, "not_found", "No such company.") return } s.serverError(w, "set client active", err) return } action := "client.suspended" if *in.Active { action = "client.reinstated" } s.Store.Audit(r.Context(), AuditEntry{ ActorID: p.UserID, ActorKind: "user", Action: action, Entity: "client", EntityID: row.ID, Detail: map[string]any{"slug": row.Slug, "sessions_revoked": revoked}, }) writeJSON(w, http.StatusOK, map[string]any{"client": row, "sessions_revoked": revoked}) } // POST /api/admin/clients/{id}/owner-password {"email": "…"} // // The support case this exists for: the owner has locked themselves out and // there is nobody above them in the company to reset it. The new password is // generated, shown once, and every session that owner held is revoked. `email` // picks the owner when the company has more than one; with exactly one it may // be omitted. func (s *Server) handleResetOwnerPassword(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) clientID := r.PathValue("id") var in struct { Email string `json:"email"` } if err := decodeOptional(w, r, &in); err != nil { badRequest(w, err.Error()) return } owners, err := s.Store.ClientOwners(r.Context(), clientID) if err != nil { s.serverError(w, "list owners", err) return } var target *TeamMember switch { case len(owners) == 0: writeErr(w, http.StatusNotFound, "not_found", "That company has no active owner.") return case in.Email != "": want := auth.NormalizeEmail(in.Email) for i := range owners { if owners[i].Email == want { target = &owners[i] } } if target == nil { writeErr(w, http.StatusNotFound, "not_found", "No active owner with that address.") return } case len(owners) == 1: target = &owners[0] default: emails := make([]string, 0, len(owners)) for _, o := range owners { emails = append(emails, o.Email) } badRequest(w, "That company has several owners; say which with \"email\": "+strings.Join(emails, ", ")) return } password, err := auth.RandomPassword() if err != nil { s.serverError(w, "generate password", err) return } hash, err := auth.HashPassword(password) if err != nil { s.serverError(w, "hash password", err) return } m, err := s.Store.ResetMemberPassword(r.Context(), clientID, target.ID, hash) if err != nil { s.serverError(w, "reset owner password", err) return } s.Store.Audit(r.Context(), AuditEntry{ ActorID: p.UserID, ActorKind: "user", Action: "admin.reset_owner_password", Entity: "user", EntityID: m.ID, Detail: map[string]any{"email": m.Email, "client_id": clientID}, }) writeJSON(w, http.StatusOK, map[string]any{"email": m.Email, "password": password}) } // DELETE /api/admin/clients/{id} {"confirm": ""} // // Irreversible, and the data is biometric, so it is deliberately hard to do by // accident: the company must already be suspended, and the request must repeat // the slug. Objects go first - once the rows are gone nothing knows which files // to remove - then the broker logins, then the rows (everything cascades from // clients). A storage failure aborts before anything else is touched. func (s *Server) handleDeleteClient(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) clientID := r.PathValue("id") var in struct { Confirm string `json:"confirm"` } if err := decode(w, r, &in); err != nil { badRequest(w, err.Error()) return } rows, err := s.Store.ListClients(r.Context()) if err != nil { s.serverError(w, "list clients", err) return } var row *ClientRow for i := range rows { if rows[i].ID == clientID { row = &rows[i] } } if row == nil { writeErr(w, http.StatusNotFound, "not_found", "No such company.") return } if row.Active { writeErr(w, http.StatusConflict, "still_active", "Suspend the company first (PATCH active=false). Deleting is the second step, not the first.") return } if strings.TrimSpace(in.Confirm) != row.Slug { badRequest(w, "Repeat the company's slug in \"confirm\" to delete it.") return } keys, err := s.Store.ClientImageKeys(r.Context(), clientID) if err != nil { s.serverError(w, "list images for client delete", err) return } if s.Blob != nil { for _, key := range keys { if isDBKey(key) { continue // goes with the rows } if err := s.Blob.Delete(r.Context(), key); err != nil { s.logf("ERROR delete client %s: cannot delete %s: %v", row.Slug, key, err) writeErr(w, http.StatusBadGateway, "storage_error", "A stored photo could not be deleted, so the company was not deleted. Try again.") return } } } _, brokerUsers, err := s.Store.DeleteClient(r.Context(), clientID) if err != nil { s.serverError(w, "delete client", err) return } if s.Broker != nil { for _, u := range brokerUsers { if err := s.Broker.DeleteSite(r.Context(), u); err != nil { // The rows are gone and the login cannot publish anywhere the // server will accept (ingest resolves the site and finds none), // so this is a leftover to tidy, not a failure to report as one. s.logf("delete client %s: broker login %s not removed: %v", row.Slug, u, err) } } } s.Store.Audit(r.Context(), AuditEntry{ ActorID: p.UserID, ActorKind: "user", Action: "client.deleted", Entity: "client", EntityID: clientID, Detail: map[string]any{"slug": row.Slug, "images_deleted": len(keys), "broker_logins": brokerUsers}, }) s.logf("WARNING company %s deleted by %s: %d images, %d broker logins", row.Slug, p.UserID, len(keys), len(brokerUsers)) writeJSON(w, http.StatusOK, map[string]any{"deleted": row.Slug, "images_deleted": len(keys)}) } // DELETE /api/sites/{site} - an owner removes a shop opened by mistake. // // Only a shop with no visits and no cameras. A shop with history holds the // tenant's footfall and, through its visits, faces; taking that away is an // erasure decision, not a tidy-up, and there is no endpoint for it yet. func (s *Server) handleDeleteSite(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if p.Role != "owner" || p.ClientID == "" { writeErr(w, http.StatusForbidden, "forbidden", "Only the owner can remove a shop.") return } site, ok := s.resolveSite(w, r, r.PathValue("site")) if !ok { return } username, err := s.Store.DeleteEmptySite(r.Context(), p.ClientID, site) if err != nil { if errors.Is(err, ErrSiteInUse) { writeErr(w, http.StatusConflict, "in_use", "This shop has cameras or visits, so it cannot simply be removed. Remove its cameras first; a shop with visit history is kept.") return } if errors.Is(err, pgx.ErrNoRows) { writeErr(w, http.StatusNotFound, "not_found", "No such shop.") return } s.serverError(w, "delete site", err) return } if s.Broker != nil && username != "" { if err := s.Broker.DeleteSite(r.Context(), username); err != nil { s.logf("delete site %s: broker login %s not removed: %v", site, username, err) } } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "site.deleted", Entity: "site", EntityID: site, }) w.WriteHeader(http.StatusNoContent) } // ErrSiteInUse is returned by DeleteEmptySite for a shop that has anything // under it. var ErrSiteInUse = errors.New("site has cameras or visits") // PATCH /api/sites/{site} - rename a shop or change its timezone. Owner or // manager. The slug is not in the body and would be refused by the database // if it were: it is what the shop PC calls itself and a segment of the broker // topic, and renaming it would orphan both. func (s *Server) handleUpdateSite(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) if !p.CanManageSites() || p.ClientID == "" { writeErr(w, http.StatusForbidden, "forbidden", "Only a manager or the owner can change a shop.") return } site, ok := s.resolveSite(w, r, r.PathValue("site")) if !ok { return } var in SiteUpdate if err := decode(w, r, &in); err != nil { badRequest(w, err.Error()) return } if in.Name != nil { n := clip(trim(*in.Name), 120) if n == "" { badRequest(w, "The shop needs a name.") return } in.Name = &n } if in.Timezone != nil { if _, err := time.LoadLocation(strings.TrimSpace(*in.Timezone)); err != nil { badRequest(w, "Unknown timezone. Use an IANA name such as Asia/Kolkata.") return } } if in.Name == nil && in.Timezone == nil { badRequest(w, "Nothing to change: give a name or a timezone.") return } out, err := s.Store.UpdateSite(r.Context(), p.ClientID, site, in) if err != nil { if errors.Is(err, pgx.ErrNoRows) { writeErr(w, http.StatusNotFound, "not_found", "No such shop.") return } s.serverError(w, "update site", err) return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "site.updated", Entity: "site", EntityID: site, Detail: map[string]any{"name": out.Name, "timezone": out.Timezone}, }) writeJSON(w, http.StatusOK, out) }