Backend

Staff API

A back office API: users, roles, sessions, IP bans and the numbers for a dashboard.

pleate_backend.staff is the API behind a back office: users, their roles and sessions, IP bans and the numbers for a dashboard. Build the screens with DataTable, StatCard and useList; the Django admin stays available for everything else.

Every endpoint needs is_staff or the matching model permission, which can be granted through a role: pleate_accounts.view_user, change_user and delete_user for users and stats, pleate_staff.*_ban for bans. A support role can therefore read users without being staff. These endpoints need a signed in session: an API key is refused with api_key_not_allowed, even for a superuser.

Only a superuser can change, sign out or delete another staff account or a superuser. Without superuser rights, roles can only add or remove roles whose permissions you hold yourself, and never your own.

Endpoints

Mounted at /api/staff.

MethodPathPurpose
GET/staff/usersPage list. search (email, name), filters is_active, is_staff, email_verified, role, ordering date_joined, email, last_login, name.
GET/staff/users/<id>The user plus has_password, locale, timezone, sessions_count, mfa_enabled.
PATCH/staff/users/<id>{name?, is_active?, is_staff?, email_verified?, roles?: [names]}. Deactivating signs the user out everywhere.
DELETE/staff/users/<id>204.
POST/staff/users/<id>/revoke-sessions{count}
POST/staff/users/<id>/password-reset202. Emails a reset link, the same email as the forgot password flow.
GET/staff/bansPage list. search (network, reason), ordering created_at, expires_at, network.
POST/staff/bans{network: "1.2.3.4" | "10.0.0.0/8" | "2001:db8::/32", reason?, expires_at?}
GETPATCH/staff/bans/<id>{id, network, reason, expires_at, is_active, created_by, created_at}
DELETE/staff/bans/<id>204, lifts the ban.
GET/staff/stats{users_total, users_active, users_new_7d, users_new_30d, sessions_active, signups_by_day}

User list items: id, email, name, display_name, avatar_url, email_verified, is_active, is_staff, is_superuser, date_joined, last_login, roles. signups_by_day covers 30 days, oldest first, as [{date, count}]; users_active counts accounts that are not deactivated.

A users screen

tsx
const users = useList<StaffUser>("/staff/users", { ordering: "-date_joined", syncUrl: true });
const client = usePleateClient();

<DataTable
  aria-label="Users"
  columns={columns}
  getRowId={(u) => u.id}
  {...users.tableProps}
  toolbar={
    <SegmentedControl
      size="sm"
      value={String(users.filters.is_active ?? "all")}
      onValueChange={(v) => users.setFilter("is_active", v === "all" ? undefined : v)}
      options={[{ value: "all", label: "All" }, { value: "true", label: "Active" }, { value: "false", label: "Deactivated" }]}
    />
  }
  onRowClick={(u) => router.push(`/admin/users/${u.id}`)}
/>

await client.staff.updateUser(id, { is_active: false, roles: ["support"] });
A dashboard
const stats = useQuery({ queryKey: ["staff", "stats"], queryFn: client.staff.stats });

<StatCard label="Users" value={stats.data?.users_total ?? 0} />
<StatCard label="New this week" value={stats.data?.users_new_7d ?? 0} />
<StatCard label="Signed in now" value={stats.data?.sessions_active ?? 0} />

IP bans

BanMiddleware answers 403 banned to clients whose address is in an active ban, using client_ip (so X-Forwarded-For from trusted proxies). /api/health is never blocked. Active networks are cached for 25 seconds in the Django cache and 5 seconds in each process; saving or deleting a ban drops both, so a warm request makes no database query. If the cache is down, requests pass.

python
from pleate_backend.staff.bans import is_banned, invalidate
from pleate_backend.staff.models import Ban

is_banned("203.0.113.24")      # True or False
Ban.objects.active()           # bans that have not expired

Error codes

CodeStatusWhen
cannot_modify_self400Deactivating, demoting or deleting yourself. 403 when changing your own roles.
cannot_modify_superuser403Only superusers change superusers.
cannot_modify_staff403Only superusers change another staff account.
permission_not_held403Giving or taking a role with a permission you do not hold.
api_key_not_allowed403The request used an API key.
permission_denied403Changing staff access needs is_staff; changing roles needs is_staff or auth.change_group.
unknown_role400A role name that does not exist.
user_inactive400Password reset for a deactivated user.
invalid_network400Not an IP address or CIDR network.
cannot_ban_self400The network contains your own address.
banned403From the middleware, on any request.