# Admin (/apps/admin)

An app can add four things to the admin: its own **pages**, a **home card**, **panels** on detail pages, and a
**hosted page** you serve yourself. The first three are native workers; the last is an iframe.

## Pages and navigation [#pages-and-navigation]

A worker with the target `admin.page` becomes a page once it is listed in `adminPages` in the
[manifest](/apps/manifest#adminpages), up to 8 of them. The admin shows them under **Apps** in its navigation, at
`/apps/<installation id>/<page id>`. Without the list, the page exists in the release but nothing links to it.

`useNavigate()` moves between pages, with back and forward working as usual: `{ kind: 'app_page', pageId }` opens
another page of your app, and `{ kind: 'host', target }` opens an admin screen, where `target` is `app_home`,
`products`, `collections`, `orders`, `customers`, `product`, `collection`, `order` or `customer`.

The admin page of the complete example reads its settings, opens the product picker, shows toasts and calls an
action:



```tsx
export function AdminPage(): ReactNode {
  const copy = copyFor(useLocale());
  const settings = useSettings();
  const navigate = useNavigate();
  const toast = useToast();
  const pick = useResourcePicker();
  const saveFeatured = useAction('save_featured');
  const heading = String(settings.heading ?? copy.heading);
  const [picked, setPicked] = useState('');

  return (
    <Card className='offer-panel rounded-theme bg-surface text-foreground p-4'>
      <Stack>
        <Heading>{heading}</Heading>
        <Text>{copy.adminIntro}</Text>
        <TextField label={copy.featuredLabel} value={picked} onChange={setPicked} />
        <Inline>
          <Button
            label={copy.pickProducts}
            onPress={async () => {
              const ids = await pick('product', true);
              if (ids === null) return;
              setPicked(ids.join(','));
              await toast('success', copy.pickedToast);
            }}
          />
          <Button
            label={copy.save}
            onPress={async () => {
              const productIds = picked
                .split(',')
                .map((value) => value.trim())
                .filter((value) => value.length > 0);
              await saveFeatured({
                productIds,
                expectedVersion: 0,
                idempotencyKey: crypto.randomUUID(),
              });
              await toast('success', copy.savedToast);
            }}
          />
          <Button
            label={copy.openPage}
            onPress={() => navigate({ kind: 'app_page', pageId: RECOMMENDATIONS_PAGE })}
          />
        </Inline>
      </Stack>
    </Card>
  );
}
```

- **`useSettings()`** returns the worker's settings for this installation. See [Worker settings](#worker-settings).
- **`useResourcePicker()`** opens the admin's own product or collection picker:
  `pick('product' | 'collection', multiple)` returns the chosen ids, up to 50, or `null` when the merchant
  cancels. The call gives up after 15 seconds.
- **`useToast()`** shows an admin toast: `toast('info' | 'success' | 'warning' | 'critical', message)`, with a
  message of at most 200 characters.
- **`useAction('save_featured')`** calls your server. A worker cannot fetch your server or any other address;
  Poltura sends a signed `POST` to `/actions/save_featured` on `backendOrigin` instead, and your server checks it:



```ts
const raw = await readBody(req);
const output = await actions.handle(raw, requestHeaders(req));
send(res, 200, JSON.stringify(output), 'application/json; charset=utf-8');
```

See [Signed actions](/apps/auth#signed-actions) for the server side.

## Home cards [#home-cards]

A worker with the target `admin.home` draws a card on the admin home. It uses the same components and settings as
a page, and has no selected product or order.

## Panels on detail pages [#panels-on-detail-pages]

Workers with the targets `admin.product`, `admin.collection`, `admin.order` and `admin.customer` appear on those
detail pages. `useSelectedEntity()` gives the panel the record the merchant has open, and only that one: the hook
takes no id. Reading an order needs `orders.read` and reading a customer `customers.read`.



```tsx
export function AdminProduct(): ReactNode {
  const copy = copyFor(useLocale());
  const selected = useSelectedEntity();
  const navigate = useNavigate();
  if (selected.status !== 'ready' || selected.entity.kind !== 'product') {
    return <Text>{copy.unavailable}</Text>;
  }
  return (
    <Card className='rounded-theme bg-surface text-foreground p-4'>
      <Stack>
        <Heading>{selected.entity.product.title}</Heading>
        <Button
          label={copy.openSettings}
          onPress={() => navigate({ kind: 'app_page', pageId: RECOMMENDATIONS_PAGE })}
        />
      </Stack>
    </Card>
  );
}
```

`useSelectedEntity()` starts as `loading` and settles on `ready` or `unavailable`; show something useful for
each. The [native widgets example](/apps/examples) has an order panel and a product panel.

## Worker settings [#worker-settings]

The admin generates a settings screen for each worker from the `settings` of its `defineBlock` definition (field
types `text`, `textarea`, `number`, `range`, `checkbox`, `select`, `radio`, `color` and `url`). When the merchant
saves, `useSettings()` returns the new values. A worker without a `defineBlock` definition has no settings. The
manifest's own `settings` list is not shown anywhere yet (see [Manifest](/apps/manifest#app-level-settings)).

## Hosted iframe pages [#hosted-iframe-pages]

A hosted page is a page on your own server, shown in an iframe in the admin. Use it when you need your own HTML,
CSS and JavaScript. Declare it with `adminFrame` in the [manifest](/apps/manifest#adminframe): its exact HTTPS
address and the bridge actions it may use, from `toast`, `set_loading`, `navigate_host`, `pick_resource`,
`resize`, `get_session_token` and `get_appearance`.

The iframe is sandboxed with `allow-scripts allow-forms allow-same-origin`: the page cannot open pop-ups or
navigate the admin. In the page, `connectAppFrame` from `@poltura/app-sdk/frame` connects to the admin with the
same action list. To call your server, request a session token with `requestAppSession` and send it along; the
installation id the admin passes to the page during the connection is for display only, not proof. The whole
exchange is on [Authentication](/apps/auth#session-tokens-for-hosted-pages). The complete example's server checks
the token like this:



```ts
if (sessions === null) {
  send(res, 503, 'Session verification is not configured.');
  return;
}
const header = requestHeaders(req).get('authorization') ?? '';
const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
const assertion = await sessions.verify(token);
send(
  res,
  200,
  JSON.stringify({ installationId: assertion.installationId, storeId: assertion.storeId }),
  'application/json; charset=utf-8',
);
```

To draw with Poltura's components in a hosted page, import `@poltura/app-ui/dom`, request `get_appearance` and
wrap the page in `PolturaProvider`. That entry point is for hosted admin pages only; it adds no isolation of its
own.

Merchants open a hosted page from **Connected apps** (`/connected-apps`); it lives at
`/connected-apps/<installation id>/page`.

## Next [#next]

- [Storefront](/apps/storefront): blocks, header and footer zones, and storefront pages.
- [Authentication](/apps/auth): tokens, session tokens and signed actions.
