Files
Behavision/server/internal/api/handlers_admin_clients.go
Suriyakumarvijayanayagam f88d441bbf A shop can be renamed and, while empty, removed - from head office
The display name was always meant to be editable and the slug frozen;
until now neither had a way in. PATCH /api/sites/{site} takes a name
and a timezone (manager and above), DELETE removes an empty shop
(owner). The shop drawer in head office gets both, with the short name
shown read-only and the reason beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 16:11:07 +05:30

315 lines
10 KiB
Go

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": "<slug>"}
//
// 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)
}