Metadata-Version: 2.5
Name: plain.admin
Version: 0.86.0
Summary: Manage your app with a backend interface.
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
License-Expression: BSD-3-Clause
License-File: LICENSE
Requires-Python: >=3.13
Requires-Dist: plain-auth<1.0.0,>=0.29.0
Requires-Dist: plain-elements<1.0.0
Requires-Dist: plain-htmx<1.0.0
Requires-Dist: plain-tailwind<1.0.0,>=0.21.0
Requires-Dist: plain-templates<1.0.0,>=0.5.0
Requires-Dist: plain<1.0.0,>=0.145.0
Description-Content-Type: text/markdown

# plain.admin

**Manage your app with a backend interface.**

- [Overview](#overview)
- [Admin viewsets](#admin-viewsets)
    - [Model views](#model-views)
    - [Object views](#object-views)
    - [Navigation](#navigation)
- [Links](#links)
- [Admin cards](#admin-cards)
    - [Basic cards](#basic-cards)
    - [Trend cards](#trend-cards)
    - [Table cards](#table-cards)
    - [Key/value cards](#keyvalue-cards)
- [Admin forms](#admin-forms)
- [List filters](#list-filters)
- [Actions](#actions)
- [Toolbar](#toolbar)
- [Impersonate](#impersonate)
- [Customization](#customization)
    - [Theming](#theming)
    - [Components](#components)
    - [Header branding](#header-branding)
    - [User avatar](#user-avatar)
    - [User menu items](#user-menu-items)
    - [Field value templates](#field-value-templates)
- [Access control](#access-control)
    - [Per-view restriction](#per-view-restriction)
    - [Global restriction](#global-restriction)
- [FAQs](#faqs)
- [Installation](#installation)

## Overview

The Plain Admin provides a combination of built-in views and the flexibility to create your own. You can use it to quickly get visibility into your app's data and to manage it.

![Plain Admin user example](https://assets.plainframework.com/docs/plain-pageviews-user.png)

The most common use of the admin is to manage your `plain.postgres`. To do this, create a [viewset](./views/viewsets.py#AdminViewset) with inner/nested views:

```python
# app/users/admin.py
from plain.admin.views import (
    AdminModelDetailView,
    AdminModelListView,
    AdminModelUpdateView,
    AdminViewset,
    register_viewset,
)
from plain.postgres.forms import ModelForm

from .models import User


class UserForm(ModelForm):
    class Meta:
        model = User
        fields = ("email",)


@register_viewset
class UserAdmin(AdminViewset):
    class ListView(AdminModelListView):
        model = User
        fields = (
            "id",
            "email",
            "created_at__date",
        )
        queryset_order = ("-created_at",)
        search_fields = ("email",)

    class DetailView(AdminModelDetailView):
        model = User

    class UpdateView(AdminModelUpdateView):
        template_name = "admin/users/user_form.html"
        model = User
        form_class = UserForm
```

## Admin viewsets

The [`AdminViewset`](./views/viewsets.py#AdminViewset) automatically recognizes inner views named `ListView`, `CreateView`, `DetailView`, `UpdateView`, and `DeleteView`. It interlinks these views automatically in the UI and sets up form success URLs. You can define additional views too, but you will need to implement a couple methods to hook them up.

### Model views

For working with database models, use the model-specific view classes. These handle common patterns like automatic URL paths, queryset ordering, and search.

- [`AdminModelListView`](./views/models.py#AdminModelListView) - Lists model instances with pagination, search, and sorting
- [`AdminModelDetailView`](./views/models.py#AdminModelDetailView) - Shows a single model instance with all its fields
- [`AdminModelCreateView`](./views/models.py#AdminModelCreateView) - Creates new model instances using a form
- [`AdminModelUpdateView`](./views/models.py#AdminModelUpdateView) - Updates existing model instances
- [`AdminModelDeleteView`](./views/models.py#AdminModelDeleteView) - Deletes model instances with confirmation

```python
@register_viewset
class ProductAdmin(AdminViewset):
    class ListView(AdminModelListView):
        model = Product
        fields = ("id", "name", "price", "created_at")
        queryset_order = ("-created_at",)
        search_fields = ("name", "description")

    class DetailView(AdminModelDetailView):
        model = Product
        fields = ("id", "name", "description", "price", "created_at", "updated_at")

    class CreateView(AdminModelCreateView):
        model = Product
        form_class = ProductForm

    class UpdateView(AdminModelUpdateView):
        model = Product
        form_class = ProductForm

    class DeleteView(AdminModelDeleteView):
        model = Product
```

The `fields` attribute on list and detail views supports the `__` syntax for accessing related objects and calling methods. For example, `"created_at__date"` will call the `date()` method on the datetime field.

### Object views

For working with non-model data (API responses, files, etc.), use the base object views. These require you to implement `get_initial_objects()` or `get_object()` methods.

- [`AdminListView`](./views/objects.py#AdminListView) - Base list view for any iterable
- [`AdminDetailView`](./views/objects.py#AdminDetailView) - Base detail view for any object
- [`AdminCreateView`](./views/objects.py#AdminCreateView) - Base create view
- [`AdminUpdateView`](./views/objects.py#AdminUpdateView) - Base update view
- [`AdminDeleteView`](./views/objects.py#AdminDeleteView) - Base delete view

```python
from plain.admin.views import AdminListView, AdminViewset, register_viewset


@register_viewset
class ExternalAPIAdmin(AdminViewset):
    class ListView(AdminListView):
        title = "External Items"
        nav_section = "Integrations"
        path = "external-items/"
        fields = ("id", "name", "status")

        def get_initial_objects(self):
            # Fetch from an external API, file, or any data source
            return external_api.get_items()
```

### Navigation

Views appear in the admin sidebar based on their `nav_section` and `nav_title` attributes. Set `nav_section` to group related views together.

```python
class ListView(AdminModelListView):
    model = Order
    nav_section = "Sales"  # Groups this view under "Sales" in the sidebar
    nav_title = "Orders"  # Display name (defaults to model name)
    nav_icon = "shopping-cart"  # Icon for the section
```

Icons come from [Bootstrap Icons](https://icons.getbootstrap.com/). To search available icon names:

```bash
uv run plain admin icons         # List all ~2,078 icons
uv run plain admin icons cart    # Search by keyword
```

Setting `nav_section = None` hides a view from the navigation entirely.

## Links

Admin views can display action links in their title bar. There are two types: **links** appear as visible buttons, while **extra links** are tucked into a three-dots overflow dropdown.

On mobile, both link types are combined into a single dropdown menu.

```python
from plain.urls import reverse


@register_viewset
class OrderAdmin(AdminViewset):
    class ListView(AdminModelListView):
        model = Order

        def get_extra_links(self):
            extra_links = super().get_extra_links()
            extra_links["Import"] = reverse("orders:import")
            return extra_links

    class DetailView(AdminModelDetailView):
        model = Order

        def get_extra_links(self):
            extra_links = super().get_extra_links()
            extra_links["Export PDF"] = reverse("orders:export", pk=self.object.pk)
            return extra_links
```

Viewsets already populate `links` automatically — list views get a "New" link if a `CreateView` exists, and detail views get "Edit" and "Delete" links for their respective views. Use `get_extra_links()` for additional actions that don't need primary button visibility.

## Admin cards

Cards display summary information on admin pages. You can add them to any view by setting the `cards` attribute.

### Basic cards

The base [`Card`](./cards/base.py#Card) class displays a simple card with a title, optional description, metric, text, and link. When both a metric and link are provided, the metric itself becomes a clickable link.

```python
from plain.admin.cards import Card
from plain.admin.views import AdminView, register_view


class UsersCard(Card):
    title = "Total Users"
    size = Card.Sizes.SMALL

    def get_metric(self):
        from app.users.models import User

        return User.query.count()

    def get_link(self):
        return "/admin/p/user/"


@register_view
class DashboardView(AdminView):
    title = "Dashboard"
    path = "dashboard/"
    nav_section = ""
    cards = (UsersCard,)
```

Card sizes control how much horizontal space they occupy in a four-column grid:

- `Card.Sizes.SMALL` - 1 column (default)
- `Card.Sizes.MEDIUM` - 2 columns
- `Card.Sizes.LARGE` - 3 columns
- `Card.Sizes.FULL` - 4 columns (full width)

### Trend cards

The [`TrendCard`](./cards/charts.py#TrendCard) displays a bar chart showing data over time. It works with models that have a datetime field.

```python
from plain.admin.cards import TrendCard
from plain.admin.dates import DatetimeRangeAliases


class SignupsTrendCard(TrendCard):
    title = "User Signups"
    size = TrendCard.Sizes.MEDIUM
    model = User
    datetime_field = "created_at"
    default_preset = DatetimeRangeAliases.SINCE_30_DAYS_AGO
```

Trend cards include built-in date range presets like "Today", "This Week", "Last 30 Days", etc. Users can switch between presets in the UI.

For custom chart data, override the `get_trend_data()` method to return a dict mapping date strings to counts.

### Table cards

The [`TableCard`](./cards/tables.py#TableCard) displays tabular data with headers, rows, and optional footers.

```python
from plain.admin.cards import TableCard


class RecentOrdersCard(TableCard):
    title = "Recent Orders"
    size = TableCard.Sizes.FULL  # Tables typically use full width

    def get_headers(self):
        return ["Order ID", "Customer", "Total", "Status"]

    def get_rows(self):
        orders = Order.query.order_by("-created_at")[:5]
        return [
            [order.id, order.customer.name, order.total, order.status]
            for order in orders
        ]
```

### Key/value cards

The [`KeyValueCard`](./cards/key_values.py#KeyValueCard) displays a list of label/value pairs as a definition list. Useful for short metric- or identity-style data (server stats, version info, account summaries) where the values are pre-formatted strings.

```python
from plain.admin.cards import KeyValueCard


class ServerCard(KeyValueCard):
    title = "Server"
    size = KeyValueCard.Sizes.MEDIUM

    def get_items(self):
        return {
            "Version": "1.2.3",
            "Uptime": "3d 4h",
            "Region": "us-east",
        }
```

For a single object's full field set, use a [model detail view](#model-views) instead — it dispatches each value through a per-type renderer.

## Admin forms

Admin forms work with standard [plain.forms](/plain/plain/forms/README.md). For model-based forms, use [`ModelForm`](/plain-postgres/plain/postgres/forms.py#ModelForm).

```python
from plain.postgres.forms import ModelForm
from plain.admin.views import AdminModelUpdateView


class UserForm(ModelForm):
    class Meta:
        model = User
        fields = ("email", "first_name", "last_name", "is_active")


class UpdateView(AdminModelUpdateView):
    model = User
    form_class = UserForm
    template_name = "admin/users/user_form.html"  # Optional custom template
```

The form template extends the admin base and renders fields with the admin's [Plain elements](/plain/plain/elements/README.md):

```html
{% extends "admin/base.html" %}
{% use_elements %}

{% block content %}
<form method="post" class="space-y-4">
    <admin.InputField label="Email" field={form.email} />
    <admin.InputField label="First name" field={form.first_name} />
    <admin.InputField label="Last name" field={form.last_name} />
    <admin.CheckboxField label="Active" field={form.is_active} />

    <admin.Submit>Save</admin.Submit>
</form>
{% endblock %}
```

CSRF is automatic (no `{{ csrf_input }}` needed). Plain forms are headless — there's no `as_p()` / `as_table()`; you compose the markup yourself with the elements above.

### Elements

The admin ships these [Plain elements](/plain/plain/elements/README.md) for building admin templates. Paired field elements (label + input + help + errors) cover the common case; unwrapped primitives are there when you need custom layouts.

| Element                 | Renders                                                                                                                |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `<admin.Submit>`        | Right-aligned `<button>` with `admin-btn admin-btn-primary` styling                                                    |
| `<admin.InputField>`    | `<admin.Label>` + `<admin.Input>` + `<admin.Help>` + `<admin.FieldErrors>`                                             |
| `<admin.CheckboxField>` | `<admin.Checkbox>` + `<admin.Label>` + `<admin.Help>` + `<admin.FieldErrors>`                                          |
| `<admin.SelectField>`   | `<admin.Label>` + `<admin.Select>` + `<admin.Help>` + `<admin.FieldErrors>`                                            |
| `<admin.TextareaField>` | `<admin.Label>` + `<admin.Textarea>` + `<admin.Help>` + `<admin.FieldErrors>`                                          |
| `<admin.Input>`         | `<input class="admin-input">`; pass `type="email"` etc. via the `type` prop                                            |
| `<admin.Checkbox>`      | `<input type="checkbox" class="admin-input">`                                                                          |
| `<admin.Select>`        | `<select class="admin-select">` with `<option>`s from `field.field.choices`                                            |
| `<admin.Textarea>`      | `<textarea class="admin-textarea">` (set `rows` via prop)                                                              |
| `<admin.Label>`         | `<label class="admin-label">` with required `*` indicator                                                              |
| `<admin.Help>`          | `<p class="text-admin-muted-foreground">` of help text                                                                 |
| `<admin.FieldErrors>`   | Error messages with danger icon                                                                                        |
| `<admin.SearchInput>`   | `<input class="admin-input pl-8">` with a leading search-icon overlay (used in list filters / topbar)                  |
| `<admin.Icon>`          | `<i class="bi bi-{name}">` — pass `name="plus-lg"` etc. (full set: [bootstrap-icons](https://icons.getbootstrap.com/)) |

Source: [`templates/elements/admin/`](./templates/elements/admin/) — read the file directly to see exactly what each element accepts.

## List filters

On [`AdminListView`](./views/objects.py#AdminListView) and [`AdminModelListView`](./views/models.py#AdminModelListView), you can define different `filters` to build predefined views of your data. The filter choices will be shown in a dropdown in the UI.

### Declarative filters

On `AdminModelListView`, filters can be defined as a `dict[str, Q]` mapping filter names to `Q` objects. The framework handles the queryset filtering automatically.

```python
from plain.postgres import Q


@register_viewset
class UserAdmin(AdminViewset):
    class ListView(AdminModelListView):
        model = User
        fields = ("id", "email", "created_at__date")
        filters = {
            "Active": Q(is_active=True),
            "Inactive": Q(is_active=False),
            "Staff": Q(is_staff=True),
        }
```

Use prefixed names to organize filters that span multiple dimensions:

```python
filters = {
    "Host: GitHub": Q(host_type="GITHUB"),
    "Host: GitLab": Q(host_type="GITLAB"),
    "Mode: Active": Q(mode="ACTIVE"),
    "Mode: Disabled": Q(mode="DISABLED"),
}
```

### Custom filters

For filters that need custom logic (e.g., annotations), use a `list[str]` and override `filter_queryset`:

```python
@register_viewset
class UserAdmin(AdminViewset):
    class ListView(AdminModelListView):
        model = User
        fields = ("id", "email", "created_at__date")
        filters = ("Active users", "Inactive users")

        def filter_queryset(self, queryset):
            if self.filter == "Active users":
                return queryset.filter(is_active=True)
            elif self.filter == "Inactive users":
                return queryset.filter(is_active=False)
            return queryset
```

## Actions

List views support bulk actions on selected items. Define actions as a list of action names, then implement `perform_action()` to handle them.

```python
class ListView(AdminModelListView):
    model = User
    fields = ("id", "email", "is_active")
    actions = ("Activate", "Deactivate", "Delete selected")

    def perform_action(self, action, objects):
        if action == "Activate":
            objects.update(is_active=True)
        elif action == "Deactivate":
            objects.update(is_active=False)
        elif action == "Delete selected":
            objects.delete()

        # Return None to redirect back to the list, or return a Response
        return None
```

The `objects` parameter is the set of selected items, already narrowed to the current filtered view. On a model list it's a queryset, so you can run set-based `.update()`/`.delete()` directly without re-querying — and "Select all N" scales, because the whole page's worth of ids never has to be materialized. Users can select individual rows, use the header checkbox to select the whole current page, or click "Select all N" to target the entire filtered queryset across every page. Choosing an action from the **Actions** dropdown confirms before submitting.

## Toolbar

The admin includes a toolbar component that appears on your frontend when an admin user is logged in. This toolbar provides quick access to the admin and shows a link to edit the current object if one is detected.

The toolbar is registered automatically when you include `plain.admin` in your installed packages. It uses [`plain.toolbar`](/plain-toolbar/plain/toolbar/README.md) to render on your pages.

To enable the toolbar on your frontend, add the toolbar middleware and include the toolbar template tag in your base template:

```python
# app/settings.py
MIDDLEWARE = [
    # ...other middleware
    "plain.toolbar.ToolbarMiddleware",
]
```

```html
<!-- In your base template -->
{% load toolbar %}
{% toolbar %}
```

When viewing a page that has an `object` in the template context, the toolbar will show a link to that object's admin detail page (if one exists).

## Impersonate

The impersonate feature lets admin users log in as another user to debug issues or provide support. This is useful for seeing exactly what a user sees without needing their credentials.

To start impersonating, visit a user's detail page in the admin and click the "Impersonate" link. The admin toolbar will show who you're impersonating and provide a link to stop.

By default, users with `is_admin=True` can impersonate other users. Admin users cannot be impersonated (for security). You can customize who can impersonate by defining `IMPERSONATE_ALLOWED` in your settings:

```python
# app/settings.py
def IMPERSONATE_ALLOWED(user):
    # Only superusers can impersonate
    return user.is_superuser
```

The impersonate URLs are included automatically with the admin router. You can check if the current request is impersonated using [`get_request_impersonator`](./impersonate/requests.py#get_request_impersonator):

```python
from plain.admin.impersonate import get_request_impersonator


def my_view(request):
    impersonator = get_request_impersonator(request)
    if impersonator:
        # The request is being impersonated
        # `impersonator` is the admin user doing the impersonating
        # `request.user` is the user being impersonated
        pass
```

## Customization

### Theming

The admin's UI is built on a per-component CSS layer with Plain's brand
palette layered on top. Every color, radius, and chrome surface is a CSS
custom property declared on `.plain-admin` (light) and
`.dark.plain-admin, .dark .plain-admin` (dark) — redeclare any token in
your own stylesheet to retheme the admin without forking templates.

The cleanest place for overrides is your project's Tailwind input file
(typically `tailwind.css` at the repo root), since `plain.tailwind`
auto-discovers `plain.admin`'s `tailwind.css` and compiles its tokens
into the same bundle. Anything you add after that import wins:

```css
/* tailwind.css */
@import "tailwindcss";
@import "./.plain/tailwind.css";

.plain-admin {
  --primary: #4f46e5;             /* drives .admin-btn-primary, focus rings, active tab */
  --primary-foreground: white;
  --ring: #4f46e5;
  --header-bg: #eef2ff;           /* sticky top header surface */
  --link: #4f46e5;
  --link-hover: #3730a3;
}
.dark.plain-admin,
.dark .plain-admin {
  --primary: #818cf8;
  --primary-foreground: #1e1b4b;
  --ring: #818cf8;
  --header-bg: oklch(0.22 0.05 270);
  --link: #c7d2fe;
  --link-hover: #e0e7ff;
}
```

Match the source selectors so specificity ties — a bare `:root { --primary: ... }`
will be beaten by the admin's own `.plain-admin` declarations and won't take
effect.

The most commonly retuned tokens:

- **Brand palette** — `--primary`, `--primary-foreground`, `--ring`, `--link`,
  `--link-hover`, `--header-bg`.
- **Status family** — `--success`, `--warning`, `--danger`, `--info`, each with
  a `*-foreground` for legible text on solid fills (used by `.admin-btn-danger`,
  `.admin-alert-warning`, etc.).
- **Chart palette** — `--chart-1` through `--chart-5`, used by `TrendCard` and
  any other chart in the admin.
- **Radius** — every component has its own `--radius-*` token (`--radius-card`,
  `--radius-button`, `--radius-input`, `--radius-dialog`, …). Override one to
  retune just that component, or override `--radius` (the master) to shift
  every default proportionally. Generic `rounded-admin-sm`/`-md`/`-lg`
  utilities remain for ad-hoc template use.
- **Surfaces** — `--background`, `--card`, `--muted`, `--accent`, `--popover`,
  plus their `*-foreground` pairs.

The full list of tokens with side-by-side swatches is rendered live at
[`/admin/ui/`](#components) — visit it in your own running admin
to see every token, every component variant, and copy-pasteable markup.

The admin ships with light and dark mode out of the box. A toggle in the top
bar cycles **Light → Dark → System**, persisted in `localStorage`. The dark
class is applied to `<html>` before paint via an inline init script, so there
is no flash of the wrong theme on page load.

To force the admin to a single appearance, set `ADMIN_FORCE_THEME` to
`"light"` or `"dark"` (default `None` keeps the per-user toggle). Useful
when you've only customized tokens for one mode, or want every screenshot
and shared link to look the same. When forced, the toggle is hidden and
the `<html>` class is rendered server-side.

```python
# app/settings.py
ADMIN_FORCE_THEME = "dark"
```

You can also set it via the `PLAIN_ADMIN_FORCE_THEME` environment variable.

### Beyond tokens

When a token swap doesn't cover what you need, override the `.admin-*`
classes directly — in the same stylesheet you put token overrides in:

```css
/* tailwind.css */
.admin-card {
  border-width: 2px;
  box-shadow: 0 1px 0 var(--border) inset;
}

.admin-btn-primary {
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
```

The classes shown in the [catalog](#components) are a stable surface, and
the admin's component rules live in `@layer components` — unlayered
declarations in your stylesheet beat them automatically. No specificity
gymnastics needed.

Forking templates remains as an escape hatch ([FAQ](#faqs)) but should be
the last resort: you take on keeping the fork in sync as the admin evolves.

### Fonts

The admin ships [Inter](https://github.com/rsms/inter) (sans) and
[JetBrains Mono](https://github.com/JetBrains/JetBrainsMono) (mono), vendored
under `plain.admin`'s assets and licensed under the SIL Open Font License 1.1.
Their `@font-face` declarations live in an overridable `admin_fonts` block
of `admin/_base.html`; the defaults of `--font-sans` and `--font-mono` lead
with these families and fall back to the system stack.

Three override patterns:

```css
/* 1. Use the bundled fonts but lean on the system stack as the
      primary — Inter / JetBrains Mono still load. */
.plain-admin {
  --font-sans: ui-sans-serif, system-ui, sans-serif;
}
```

```jinja
{# 2. Replace the bundled fonts with your own.
   In app/templates/admin/base.html: #}
{% extends "admin/_base.html" %}
{% block admin_fonts %}
<style nonce="{{ request.csp_nonce }}">
@font-face {
  font-family: "Geist";
  src: url("{{ asset('fonts/Geist.woff2') }}") format("woff2");
}
</style>
{% endblock %}
```

```jinja
{# 3. Drop the bundled fonts entirely and use the system stack. #}
{% block admin_fonts %}{% endblock %}
```

Pair (2) and (3) with a `--font-sans` / `--font-mono` override on `.plain-admin`
so the cascade actually picks up the new family.

### Components

The admin includes a live component catalog at `/admin/ui/`.
Each section shows the rendered component with copy-pasteable markup —
buttons, badges, alerts, cards, form fields, dialogs, dropdowns, tabs,
tables, and icons. Use these classes when building admin views and
you'll inherit both light/dark theming and the user's brand overrides.

The catalog page is rendered from
[`templates/admin/ui.html`](./templates/admin/ui.html) — read that file
directly for copy-pasteable markup if you can't open the running admin
(e.g. when an agent is generating templates). The canonical class list
lives one file per primitive in
[`styles/components/`](./styles/components/).

| Pattern           | Class(es)                                                                                                                                                                                       |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Buttons           | Compose `.admin-btn` with one of `.admin-btn-primary` / `.admin-btn-secondary` / `.admin-btn-outline` / `.admin-btn-ghost` / `.admin-btn-link`                                                  |
| Sizes / icon-only | Stack `.admin-btn-sm` or `.admin-btn-lg`; add `.admin-btn-icon` for a square icon-only button (e.g. `class="admin-btn admin-btn-sm admin-btn-icon admin-btn-ghost"`)                            |
| Status buttons    | `.admin-btn-success`, `.admin-btn-warning`, `.admin-btn-danger`, `.admin-btn-info` (solid fill + paired fg)                                                                                     |
| Badges            | Compose `.admin-badge` with one of `.admin-badge-primary` / `.admin-badge-secondary` / `.admin-badge-outline`                                                                                   |
| Status badges     | Stack `.admin-badge-success`, `.admin-badge-warning`, `.admin-badge-danger`, `.admin-badge-info` (translucent fill + saturated text)                                                            |
| Alerts            | Compose `.admin-alert` (neutral surface) with `.admin-alert-success` / `.admin-alert-warning` / `.admin-alert-danger` / `.admin-alert-info` for tone                                            |
| Cards             | `.admin-card` — visual shell only (bg + border + radius); compose layout/padding inline (e.g. `class="admin-card flex flex-col gap-6 p-6"`)                                                     |
| Form inputs       | `.admin-input`, `.admin-textarea`, `.admin-select` — opt in via class; pair with `-sm` for compact rows                                                                                         |
| Dialogs           | `<dialog class="admin-dialog">` opened via `<button command="show-modal" commandfor="…">`                                                                                                       |
| Tabs              | `.admin-tabs > [role="tablist"] > [role="tab"]` (uses `tabs.js`)                                                                                                                                |
| Dropdowns         | `.admin-dropdown-menu` wrapping a `<button>` + sibling `[data-popover]` (carries `popover="manual"`, opens in the top layer so it's never clipped by a table/overflow box) with `[role="menu"]` |

When writing custom admin templates, prefer the design tokens over hardcoded
colors so dark mode and theme overrides work automatically:

| Use                 | Class / token                                                                                                                     |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Page background     | `bg-admin-background`, `text-admin-foreground`                                                                                    |
| Cards / panels      | `bg-admin-card text-admin-card-foreground`                                                                                        |
| Subtle surfaces     | `bg-admin-muted`, `bg-admin-muted/40`                                                                                             |
| Hover surface       | `hover:bg-admin-accent hover:text-admin-accent-foreground`                                                                        |
| Borders             | `border-admin-border` (general), `border-admin-input` (form fields)                                                               |
| Muted text          | `text-admin-muted-foreground`                                                                                                     |
| Primary action      | `bg-admin-primary` / `text-admin-primary-foreground`                                                                              |
| Link                | `text-admin-link hover:text-admin-link-hover`                                                                                     |
| Status text         | `text-admin-success`, `text-admin-warning`, `text-admin-danger`, `text-admin-info`                                                |
| Status backgrounds  | `bg-admin-success/10`, `bg-admin-warning/10`, `bg-admin-danger/10`, `bg-admin-info/10` (translucent fills, used by status badges) |
| Status solid action | `bg-{success,warning,danger,info}` / `text-{name}-foreground`                                                                     |
| Focus ring          | `ring-admin-ring`                                                                                                                 |
| Header surface      | `bg-admin-header-bg`                                                                                                              |

The component CSS source lives in
[`plain/admin/styles/`](./styles/): `tokens.css` declares every design
token (light + dark) and the `@theme` bindings, `components/*.css` holds
one file per UI primitive, and `tailwind.css` (next to the package's
`__init__.py`) is the entry that `plain.tailwind` auto-imports into the
user's build.

### Header branding

The top-left corner of the admin header shows your app name and an "Admin" link by default. To replace it with your own logo or branding, create an `admin/header_branding.html` template:

```html
<!-- app/templates/admin/header_branding.html -->
<a href="/">
    <img src="{{ asset('img/logo.svg') }}" alt="My App" class="h-5">
</a>
```

The template completely replaces the default branding, so include whatever links and styling you want.

### User avatar

The admin header shows a user avatar next to the account dropdown. If your User model defines a `get_avatar_url()` method, the admin will use it to display an image. Otherwise, a generic person icon is shown.

```python
# app/users/models.py
import hashlib
from plain import postgres
from plain.postgres import types


@postgres.register_model
class User(postgres.Model):
    email: str = types.EmailField()

    def get_avatar_url(self) -> str:
        # Use Gravatar
        email_hash = hashlib.md5(self.email.lower().encode("utf-8")).hexdigest()
        return f"https://www.gravatar.com/avatar/{email_hash}?d=identicon"
```

The method can return any image URL — a Gravatar, an uploaded file, or a data URI SVG.

### User menu items

To add items to the user dropdown menu (e.g., a profile page), create an `admin/user_menu_items.html` template in your app:

```html
<!-- app/templates/admin/user_menu_items.html -->
<a
    href="{{ admin_url('profile') }}"
    class="flex items-center gap-2 w-full text-left px-4 py-2 text-sm text-admin-popover-foreground hover:bg-admin-accent hover:text-admin-accent-foreground rounded"
>
    Profile
</a>
```

The `admin_url()` function resolves a view URL by its `path` attribute. For example, a view with `path = "profile"` is resolved by `admin_url("profile")`.

The template is included in the user dropdown before the "App Settings" and "Log out" links. If the template doesn't exist, nothing extra is shown.

### Field value templates

The admin resolves a template for each field value in list and detail views. Templates are checked in priority order — the first one that exists wins:

1. **Field name** — `admin/values/{field_name}.html` (e.g., `admin/values/access_token.html`)
2. **Database field type** — `admin/values/{FieldClass}.html` (e.g., `admin/values/EncryptedTextField.html`)
3. **Python value type** — `admin/values/{type}.html` (e.g., `admin/values/bool.html`)
4. **Default** — `admin/values/default.html`

To customize how a field type renders, create a template matching the field class name. For example, `EncryptedTextField` and `EncryptedJSONField` are automatically masked with click-to-reveal using their built-in templates.

## Access control

By default, any user with `is_admin=True` can access all admin views. To restrict specific views from certain admin users, use `has_permission`.

### Per-view restriction

Override `has_permission` on a view class to control who can access it. Return `False` to deny access.

```python
@register_viewset
class SensitiveDataAdmin(AdminViewset):
    class ListView(AdminModelListView):
        model = SensitiveData
        nav_section = "Internal"

        @classmethod
        def has_permission(cls, user) -> bool:
            # Only superusers can access this view
            return user.is_superuser
```

When a user is denied access to a view, they get a 403 response and the view is hidden from their navigation.

### Global restriction

To control access across all admin views (including package-shipped ones you don't own), set `ADMIN_HAS_PERMISSION` in your settings to a callable that receives the view class and user:

```python
# app/settings.py
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from plain.admin.views import AdminView

    from app.users.models import User


def ADMIN_HAS_PERMISSION(view_cls: type[AdminView], user: User) -> bool:
    """Allow superusers to access all views, restrict others from package views."""
    if user.is_superuser:
        return True  # Allow access
    # Deny views from plain packages
    return not view_cls.__module__.startswith("plain.")
```

The callable should return `True` to allow access, `False` to deny it. Views can also override `has_permission` directly to implement their own logic instead of using the global setting.

## FAQs

#### How do I customize the admin templates?

Override any admin template by creating a file with the same path in your app's templates directory. For example, to customize the list view, create `app/templates/admin/list.html`.

For project-wide hooks (extra `<head>` scripts, swapping fonts, wrapping `content`), shadow `admin/base.html` and extend `admin/_base.html` from it. The thin `admin/base.html` exists as a user-overridable layer; `admin/_base.html` is the structural template — don't override it directly.

```jinja
{# app/templates/admin/base.html #}
{% extends "admin/_base.html" %}

{% block header_scripts %}
<script src="{{ asset('analytics.js') }}" defer nonce="{{ request.csp_nonce }}"></script>
{% endblock %}
```

#### How do I add a standalone admin page without a viewset?

Use `@register_view` instead of `@register_viewset`:

```python
from plain.admin.views import AdminView, register_view


@register_view
class ReportsView(AdminView):
    title = "Reports"
    path = "reports/"
    nav_section = "Analytics"
    template_name = "admin/reports.html"
```

#### How do I hide a view from the sidebar?

Set `nav_section = None` on the view class. The view will still be accessible via its URL.

#### How do I link to an object's admin page from my templates?

Use the `get_model_detail_url` function:

```python
from plain.admin.views import get_model_detail_url

url = get_model_detail_url(my_object)  # Returns None if no admin view exists
```

## Installation

Install the `plain.admin` package from [PyPI](https://pypi.org/project/plain.admin/):

```bash
uv add plain.admin
```

The admin uses a combination of other Plain packages, most of which you will already have installed. Ultimately, your settings will look something like this:

```python
# app/settings.py
INSTALLED_PACKAGES = [
    "plain.postgres",
    "plain.tailwind",
    "plain.auth",
    "plain.sessions",
    "plain.htmx",
    "plain.admin",
    "plain.elements",
    # other packages...
]

AUTH_LOGIN_URL = "login"

MIDDLEWARE = [
    "plain.sessions.middleware.SessionMiddleware",
    "plain.auth.middleware.AuthMiddleware",
    "plain.admin.AdminMiddleware",
]
```

Your User model is expected to have an `is_admin` field (or attribute) for checking who has permission to access the admin.

```python
# app/users/models.py
from plain import postgres
from plain.postgres import types


@postgres.register_model
class User(postgres.Model):
    is_admin: bool = types.BooleanField(default=False)
    # other fields...
```

To make the admin accessible, add the [`AdminRouter`](./urls.py#AdminRouter) to your root URLs.

```python
# app/urls.py
from plain.admin.urls import AdminRouter
from plain.urls import Router, include, path

from . import views


class AppRouter(Router):
    namespace = ""
    urls = (
        include("admin/", AdminRouter),
        path("login/", views.LoginView, name="login"),
        path("logout/", views.LogoutView, name="logout"),
        # other urls...
    )
```

Create your first admin viewset for your User model:

```python
# app/users/admin.py
from plain.admin.views import (
    AdminModelDetailView,
    AdminModelListView,
    AdminViewset,
    register_viewset,
)

from .models import User


@register_viewset
class UserAdmin(AdminViewset):
    class ListView(AdminModelListView):
        model = User
        nav_section = "Users"
        fields = ("id", "email", "is_admin", "created_at")
        search_fields = ("email",)

    class DetailView(AdminModelDetailView):
        model = User
```

Visit `/admin/` to see your admin interface.
