The platform admin's last shell-only jobs are endpoints
Suspend or reinstate a company (PATCH /api/admin/clients/{id}), reset
its owner's password (shown once), and delete it - and an owner can
remove a shop opened by mistake (DELETE /api/sites/{site}, empty only).
Suspension ends every session the company holds in the same
transaction: login and ingest already refused an inactive client, but a
live access token would have kept reading for up to twelve hours, so
'suspend' would have meant 'suspend some time tomorrow'. Deletion is
deliberately two steps - the company must already be suspended and the
request repeats the slug - because the data under it is biometric.
Face images go first (a storage failure aborts with nothing touched),
then the broker logins, then the rows by cascade.
Exercised against the local Postgres and broker: create, open a shop,
remove it (two plugin commands), refuse delete while active, suspend
(owner's token 401 immediately), reset, delete, zero rows left.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
128
server/internal/api/admin_clients_test.go
Normal file
128
server/internal/api/admin_clients_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seedTenantWithOwner(fs *fakeStore) {
|
||||
fs.clients = []ClientRow{{ID: "client-acme", Slug: "acme", Name: "Acme Retail", Active: true}}
|
||||
fs.addUser("owner@acme.com", "correct horse battery", UserRecord{
|
||||
ID: "u-owner", ClientID: "client-acme", Role: "owner", Active: true, Email: "owner@acme.com",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSuspendingACompanyEndsItsSessionsNow(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
seedTenantWithOwner(fs)
|
||||
owner := login(t, s, "owner@acme.com", "correct horse battery")
|
||||
admin := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
rec := do(t, s, "PATCH", "/api/admin/clients/client-acme", admin.Token, map[string]any{"active": false})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
SessionsRevoked int `json:"sessions_revoked"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.SessionsRevoked != 1 {
|
||||
t.Fatalf("expected the owner's one session revoked, got %d", out.SessionsRevoked)
|
||||
}
|
||||
// The owner's token stops working immediately, not at expiry.
|
||||
if rec := do(t, s, "GET", "/api/team", owner.Token, nil); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("suspended tenant's session still works: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletingACompanyIsATwoStepDecision(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
seedTenantWithOwner(fs)
|
||||
b := &fakeBroker{}
|
||||
s.Broker = b
|
||||
admin := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
// Active: refused, whatever the confirmation says.
|
||||
rec := do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acme"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("deleted an active company: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
do(t, s, "PATCH", "/api/admin/clients/client-acme", admin.Token, map[string]any{"active": false})
|
||||
// Suspended but the slug is wrong: refused.
|
||||
rec = do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acm"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("deleted without the slug: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = do(t, s, "DELETE", "/api/admin/clients/client-acme", admin.Token, map[string]any{"confirm": "acme"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(fs.clients) != 0 {
|
||||
t.Fatal("company row survived")
|
||||
}
|
||||
if len(b.deleted) != 1 || b.deleted[0] != "acme.shop1" {
|
||||
t.Fatalf("broker logins not removed: %v", b.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminResetsTheOwnersPasswordAndItIsShownOnce(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedPlatformAdmin(fs)
|
||||
seedTenantWithOwner(fs)
|
||||
admin := login(t, s, "root@loyaly.ai", "admin123")
|
||||
|
||||
rec := do(t, s, "POST", "/api/admin/clients/client-acme/owner-password", admin.Token, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct{ Email, Password string }
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.Email != "owner@acme.com" || out.Password == "" {
|
||||
t.Fatalf("unexpected result: %s", rec.Body.String())
|
||||
}
|
||||
if rec := do(t, s, "POST", "/api/auth/login", "", map[string]string{"email": "owner@acme.com", "password": "correct horse battery"}); rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("old password still works: %d", rec.Code)
|
||||
}
|
||||
login(t, s, "owner@acme.com", out.Password)
|
||||
}
|
||||
|
||||
func TestATenantUserCannotReachTheAdminClientRoutes(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedTenantWithOwner(fs)
|
||||
owner := login(t, s, "owner@acme.com", "correct horse battery")
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"PATCH", "/api/admin/clients/client-acme"},
|
||||
{"POST", "/api/admin/clients/client-acme/owner-password"},
|
||||
{"DELETE", "/api/admin/clients/client-acme"},
|
||||
} {
|
||||
if rec := do(t, s, c.method, c.path, owner.Token, map[string]any{"active": false, "confirm": "acme"}); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s: tenant user got %d, want 404", c.method, c.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOwnerRemovesAnEmptyShopButNotOneWithCameras(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
b := &fakeBroker{}
|
||||
s.Broker = b
|
||||
seedTenantWithOwner(fs)
|
||||
fs.sites = []SiteHealth{
|
||||
{SiteID: "site-empty", Slug: "empty", Name: "Empty"},
|
||||
{SiteID: siteA, Slug: "chennai", Name: "TeNext Chennai"},
|
||||
}
|
||||
fs.cameras = []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}}
|
||||
owner := login(t, s, "owner@acme.com", "correct horse battery")
|
||||
|
||||
if rec := do(t, s, "DELETE", "/api/sites/chennai", owner.Token, nil); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("removed a shop with a camera: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := do(t, s, "DELETE", "/api/sites/empty", owner.Token, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(b.deleted) != 1 {
|
||||
t.Fatalf("broker login not removed: %v", b.deleted)
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,28 @@ type Store interface {
|
||||
// --- platform administration ---
|
||||
CreateClientWithOwner(ctx context.Context, in NewClientInput) (NewClientResult, error)
|
||||
ListClients(ctx context.Context) ([]ClientRow, error)
|
||||
// SetClientActive suspends or reinstates a company. Suspending revokes every
|
||||
// session its users hold in the same transaction - login and ingest already
|
||||
// refuse an inactive client, but a live access token would otherwise keep
|
||||
// reading for up to twelve hours. Returns the slug and how many sessions
|
||||
// were ended.
|
||||
SetClientActive(ctx context.Context, clientID string, active bool) (ClientRow, int, error)
|
||||
// ClientOwners lists the active owners of a company, for a platform admin
|
||||
// resetting one of their passwords.
|
||||
ClientOwners(ctx context.Context, clientID string) ([]TeamMember, error)
|
||||
// ClientImageKeys is every face image a company holds - the first step of
|
||||
// deleting the company, for the same reason it is the first step of erasing
|
||||
// a person: once the rows are gone nothing knows which objects to remove.
|
||||
ClientImageKeys(ctx context.Context, clientID string) ([]string, error)
|
||||
// DeleteClient removes a SUSPENDED company and everything under it, and
|
||||
// returns the broker usernames of its sites so their logins can be removed.
|
||||
// Refuses an active company: suspension first is what makes this a
|
||||
// two-step decision instead of one click.
|
||||
DeleteClient(ctx context.Context, clientID string) (ClientRow, []string, error)
|
||||
// DeleteEmptySite removes a shop that has no visits and no cameras - the
|
||||
// one opened by mistake - and returns its broker username. A shop with
|
||||
// history is closed, not deleted.
|
||||
DeleteEmptySite(ctx context.Context, clientID, siteID string) (string, error)
|
||||
|
||||
// --- enrolment ---
|
||||
RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error)
|
||||
@@ -278,6 +300,7 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion))
|
||||
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
|
||||
mux.HandleFunc("POST /api/sites", s.authed(s.handleCreateSite))
|
||||
mux.HandleFunc("DELETE /api/sites/{site}", s.authed(s.handleDeleteSite))
|
||||
|
||||
// Cameras, onboarded from head office. The shop PC still does the
|
||||
// connecting - it is the only thing on the camera's network - so these
|
||||
@@ -322,6 +345,9 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
// because creating the first admin cannot require being signed in as one.
|
||||
mux.HandleFunc("GET /api/admin/clients", s.adminOnly(s.handleListClients))
|
||||
mux.HandleFunc("POST /api/admin/clients", s.adminOnly(s.handleCreateClient))
|
||||
mux.HandleFunc("PATCH /api/admin/clients/{id}", s.adminOnly(s.handleSetClientActive))
|
||||
mux.HandleFunc("POST /api/admin/clients/{id}/owner-password", s.adminOnly(s.handleResetOwnerPassword))
|
||||
mux.HandleFunc("DELETE /api/admin/clients/{id}", s.adminOnly(s.handleDeleteClient))
|
||||
|
||||
// Not session-authenticated: this is how a PC with no credentials gets
|
||||
// some. The enrolment token is the credential.
|
||||
|
||||
@@ -502,6 +502,75 @@ func (f *fakeStore) ReleaseStaleChecks(_ context.Context, _ time.Duration) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetClientActive(_ context.Context, clientID string, active bool) (ClientRow, int, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i := range f.clients {
|
||||
if f.clients[i].ID != clientID {
|
||||
continue
|
||||
}
|
||||
f.clients[i].Active = active
|
||||
revoked := 0
|
||||
if !active {
|
||||
for _, sess := range f.sessions {
|
||||
if sess.p.ClientID == clientID && !sess.revoked {
|
||||
sess.revoked = true
|
||||
revoked++
|
||||
}
|
||||
}
|
||||
}
|
||||
return f.clients[i], revoked, nil
|
||||
}
|
||||
return ClientRow{}, 0, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) ClientOwners(_ context.Context, clientID string) ([]TeamMember, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var out []TeamMember
|
||||
for _, u := range f.users {
|
||||
if u.ClientID == clientID && u.Role == "owner" && u.Active {
|
||||
out = append(out, TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName, Role: u.Role, Active: u.Active})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ClientImageKeys(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
||||
|
||||
func (f *fakeStore) DeleteClient(_ context.Context, clientID string) (ClientRow, []string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i, c := range f.clients {
|
||||
if c.ID != clientID {
|
||||
continue
|
||||
}
|
||||
if c.Active {
|
||||
return c, nil, errors.New("client is active")
|
||||
}
|
||||
f.clients = append(f.clients[:i], f.clients[i+1:]...)
|
||||
return c, []string{c.Slug + ".shop1"}, nil
|
||||
}
|
||||
return ClientRow{}, nil, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteEmptySite(_ context.Context, _ string, siteID string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, c := range f.cameras {
|
||||
if c.SiteID == siteID {
|
||||
return "", ErrSiteInUse
|
||||
}
|
||||
}
|
||||
for i, s := range f.sites {
|
||||
if s.SiteID == siteID {
|
||||
f.sites = append(f.sites[:i], f.sites[i+1:]...)
|
||||
return "acme." + s.Slug, nil
|
||||
}
|
||||
}
|
||||
return "", pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) ListClients(_ context.Context) ([]ClientRow, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
259
server/internal/api/handlers_admin_clients.go
Normal file
259
server/internal/api/handlers_admin_clients.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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")
|
||||
@@ -416,6 +416,7 @@ type ClientRow struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
Sites int `json:"sites"`
|
||||
Users int `json:"users"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
|
||||
Reference in New Issue
Block a user