Backend
Staff API
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.
| Method | Path | Purpose |
|---|---|---|
| GET | /staff/users | Page 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-reset | 202. Emails a reset link, the same email as the forgot password flow. |
| GET | /staff/bans | Page 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
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"] });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.
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 expiredError codes
| Code | Status | When |
|---|---|---|
| cannot_modify_self | 400 | Deactivating, demoting or deleting yourself. 403 when changing your own roles. |
| cannot_modify_superuser | 403 | Only superusers change superusers. |
| cannot_modify_staff | 403 | Only superusers change another staff account. |
| permission_not_held | 403 | Giving or taking a role with a permission you do not hold. |
| api_key_not_allowed | 403 | The request used an API key. |
| permission_denied | 403 | Changing staff access needs is_staff; changing roles needs is_staff or auth.change_group. |
| unknown_role | 400 | A role name that does not exist. |
| user_inactive | 400 | Password reset for a deactivated user. |
| invalid_network | 400 | Not an IP address or CIDR network. |
| cannot_ban_self | 400 | The network contains your own address. |
| banned | 403 | From the middleware, on any request. |