# Admin UI in your own app

Put the full Snakkbar inbox inside a customer's existing admin/back office. Their staff stay in
their own system, logged in with their own accounts; Snakkbar never shows a login.

```
Browser (your admin page)          Your backend                         Snakkbar
──────────────────────────         ─────────────                         ────────
mountAdmin(el, { token })  ──►  GET /snakkbar/operator-token
                                 (checks your own session)
                                 POST /api/v1/server/operator-tokens ──►  upserts operator,
                                   Authorization: Bearer sk_snakkbar_…     returns JWT (1 h)
                           ◄──   { token }
admin UI calls /api/v1/operator/* with the token, refreshes it before expiry
```

## 1. Create a secret API key
Admin → Innstillinger → API-nøkler → *Ny nøkkel* (or `POST /api/v1/operator/api-keys` as an admin).
The key (`sk_snakkbar_…`) is shown once. Store it as a server-side secret (e.g. `SNAKKBAR_SECRET_KEY`).
Never send it to a browser.

## 2. Add a token endpoint to your backend
It must only answer for your logged-in staff, and map each of your users to a stable `externalId`.

```ts
// Example: Next.js route handler — app/api/snakkbar/operator-token/route.ts
export async function POST() {
  const user = await requireStaffUser(); // your own auth; throw 401 if not staff
  const res = await fetch(`${process.env.SNAKKBAR_HOST}/api/v1/server/operator-tokens`, {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.SNAKKBAR_SECRET_KEY}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      externalId: user.id,              // stable id in your system
      name: user.displayName,           // shown to visitors
      email: user.email,                // optional
      avatarUrl: user.avatarUrl,        // optional, https URL
      role: user.isAdmin ? "admin" : "agent",
      ttlSeconds: 3600,                 // optional, max 86400
    }),
  });
  if (!res.ok) return new Response("Snakkbar token failed", { status: 502 });
  const { token, expiresAt } = await res.json();
  return Response.json({ token, expiresAt });
}
```

The first exchange for an `externalId` creates the operator; later exchanges update name, email,
avatar and role. Operators can still change their own greeting, language and avatar in the UI.
A token request for a removed operator is refused with `403` (see below).

## 2b. Keep the team in sync with your user system (recommended)
Token exchange alone creates staff the first time they open the inbox, but never removes anyone.
Let your user system be the source of truth and push changes to Snakkbar:

| When, in your system | Call |
| --- | --- |
| A user becomes staff (created, added to the staff group, re-enabled) | `PUT /api/v1/server/operators/:externalId` `{ name, email?, avatarUrl?, role?, invite? }` (`invite: true` mails a sign-in link to the hosted panel) |
| Name, e-mail, photo or role changes | the same `PUT` (idempotent) |
| A user stops being staff (deleted, removed from the group, suspended) | `DELETE /api/v1/server/operators/:externalId` |
| Nightly, to repair missed events | `GET /api/v1/server/operators`, then `PUT` / `DELETE` the differences |

`PUT` provisions staff before they ever log in, so colleagues can @-mention them and the assistant
can hand conversations to them. `DELETE` deactivates rather than erases: their tokens stop working
immediately, they leave the team and every open conversation (ownership passes to the next person
or back to the assistant), and their e-mail is cleared. Their name and photo stay on messages they
wrote. A later `PUT` with the same `externalId` brings them back with their history. Both calls are
safe to repeat; `DELETE` answers `204` for unknown ids.

**The hosted panel too.** Staff you provision can also answer in Snakkbar's hosted panel
(`<host>/panel`) and the mobile app, with no separate Snakkbar account to manage. Send their work
e-mail and `invite: true` on the `PUT` (or call `POST /api/v1/server/operators/:externalId/invite`):
they get a one-time sign-in link, no password. They can also ask for a link or a code at
`<host>/panel/login`. The first sign-in with that verified address links the panel login to the
**same operator** your tokens use: same id, history, departments and notification settings, and
the button in alert e-mails works for them. Your system stays in charge: `DELETE` ends panel access
at once, and changing the e-mail or reactivating someone unlinks the login until the address signs
in again. `role` is always yours; it cannot be changed in the panel. Details:
[api.md](api.md#panel-sign-in-for-provisioned-operators).

**Integration accounts.** If your backend needs an operator of its own (for example to manage
departments with an operator token), mark it `system: true` on the `PUT`: it is hidden from the
team, @-mentions, round-robin, alerts and the dashboard. Departments can also be managed directly
with the secret key: [`/api/v1/server/departments`](api.md#departments).

### Example: Craft CMS module
A module that treats members of the user group `kundeservice` (and Craft admins) as staff. Calls go
through Craft's queue, so a Snakkbar outage is retried instead of blocking the save.

```php
<?php
// modules/snakkbar/Module.php — register in config/app.php: 'modules' => ['snakkbar' => modules\snakkbar\Module::class], 'bootstrap' => ['snakkbar']
namespace modules\snakkbar;

use Craft;
use craft\elements\User;
use craft\events\ElementEvent;
use craft\events\UserEvent;
use craft\events\UserGroupsAssignEvent;
use craft\services\Elements;
use craft\services\Users;
use yii\base\Event;

class Module extends \yii\base\Module
{
    public function init(): void
    {
        parent::init();
        Craft::setAlias('@modules/snakkbar', __DIR__);
        $this->controllerNamespace = Craft::$app->request->isConsoleRequest ? 'modules\snakkbar\console' : 'modules\snakkbar\controllers';

        $sync = fn(?User $user) => $user && Craft::$app->queue->push(new SyncOperator(['userId' => $user->id, 'uid' => $user->uid]));
        foreach ([Elements::EVENT_AFTER_SAVE_ELEMENT, Elements::EVENT_AFTER_DELETE_ELEMENT, Elements::EVENT_AFTER_RESTORE_ELEMENT] as $name) {
            Event::on(Elements::class, $name, fn(ElementEvent $e) => $e->element instanceof User && $sync($e->element));
        }
        foreach ([Users::EVENT_AFTER_SUSPEND_USER, Users::EVENT_AFTER_UNSUSPEND_USER] as $name) {
            Event::on(Users::class, $name, fn(UserEvent $e) => $sync($e->user));
        }
        Event::on(Users::class, Users::EVENT_AFTER_ASSIGN_USER_TO_GROUPS, fn(UserGroupsAssignEvent $e) => $sync(User::find()->id($e->userId)->status(null)->trashed(null)->one()));
    }

    public static function isStaff(?User $user): bool
    {
        return $user && !$user->trashed && $user->getStatus() === User::STATUS_ACTIVE && ($user->admin || $user->isInGroup('kundeservice'));
    }

    public static function client(): \GuzzleHttp\Client
    {
        return Craft::createGuzzleClient([
            'base_uri' => rtrim(getenv('SNAKKBAR_HOST'), '/') . '/api/v1/server/',
            'headers' => ['Authorization' => 'Bearer ' . getenv('SNAKKBAR_SECRET_KEY')],
        ]);
    }

    public static function remove(string $uid): void
    {
        self::client()->delete('operators/' . rawurlencode($uid));
    }

    /** PUT for staff, DELETE for everyone else. */
    public static function push(User $user): void
    {
        if (!self::isStaff($user)) {
            self::remove($user->uid);
            return;
        }
        $body = ['name' => $user->getFriendlyName(), 'email' => $user->email, 'role' => $user->admin ? 'admin' : 'agent'];
        $photo = $user->getPhoto()?->getUrl();
        if ($photo && str_starts_with($photo, 'https://')) {
            $body['avatarUrl'] = $photo; // left out otherwise, so a photo set in Snakkbar survives
        }
        self::client()->put('operators/' . rawurlencode($user->uid), ['json' => $body]);
    }
}
```

```php
<?php
// modules/snakkbar/SyncOperator.php
namespace modules\snakkbar;

use craft\elements\User;
use craft\queue\BaseJob;

class SyncOperator extends BaseJob
{
    public int $userId;
    public string $uid;

    public function execute($queue): void
    {
        $user = User::find()->id($this->userId)->status(null)->trashed(null)->one();
        // Hard-deleted users are gone from Craft by now; remove them by uid.
        $user ? Module::push($user) : Module::remove($this->uid);
    }

    protected function defaultDescription(): string
    {
        return 'Snakkbar: synkroniser bruker';
    }
}
```

```php
<?php
// modules/snakkbar/console/SyncController.php — run nightly: php craft snakkbar/sync
namespace modules\snakkbar\console;

use craft\console\Controller;
use craft\elements\User;
use modules\snakkbar\Module;

class SyncController extends Controller
{
    public function actionIndex(): int
    {
        $staff = [];
        foreach (User::find()->status(null)->each() as $user) {
            if (Module::isStaff($user)) {
                Module::push($user);
                $staff[$user->uid] = true;
            }
        }
        $list = json_decode((string) Module::client()->get('operators')->getBody(), true);
        foreach ($list['operators'] as $operator) {
            if (!$operator['deactivatedAt'] && !isset($staff[$operator['externalId']])) {
                Module::remove($operator['externalId']);
            }
        }
        return 0;
    }
}
```

The token endpoint from step 2 must use the same rule (`Module::isStaff(Craft::$app->user->identity)`)
and the same `externalId` (`$user->uid`).

## 3. Mount the inbox

```html
<div id="snakkbar-admin" style="height: calc(100vh - 64px)"></div>
<script src="https://<host>/admin.js"></script>
<script>
  let cached = null;
  async function token() {
    if (cached && Date.parse(cached.expiresAt) - Date.now() > 60_000) return cached.token;
    cached = await (await fetch("/api/snakkbar/operator-token", { method: "POST" })).json();
    return cached.token;
  }
  const admin = Snakkbar.mountAdmin(document.getElementById("snakkbar-admin"), {
    host: "https://<host>",
    token,               // string or (async) function; called before every request
    language: "no",      // "en" for English UI
    theme: "auto",       // "light" | "dark" | "auto": until the person picks one themselves
    // onThemeChange: (theme) => …, // optional: told when they pick (e.g. to match your page)
    // look: "snakkbar", // optional: Snakkbar's own look (as in the hosted panel) instead of the
    //                   // neutral default in the tenant's brand colour
  });
  // admin.unmount() when leaving the page
</script>
```

The admin renders in a shadow root and fills its container; give the container a height. It
needs the page origin in the tenant's allowed origins, like the widget.

By default it is neutral and uses the tenant's brand colour, so it sits quietly in your own system.
`look: "snakkbar"` switches to Snakkbar's own look (sand and ink, Snakkbar pink, artwork from
`<host>/brand/`). That look uses the fonts in the CSS variables `--font-display` and `--font-body`
when your page defines them on or above the container (e.g. Bricolage Grotesque and Figtree), and
system fonts otherwise. In that look the navigation rail sits on the left and opens to icons with
labels from the Snakkbar logo at its top (remembered in the browser).

Staff can pick Lys / Mørk / Automatisk themselves (the profile menu at the bottom of the rail, or
Settings); the pick is kept in their browser's localStorage and wins over `theme` from then on.

### What staff get
Inbox with filters (Alle / Åpne / Mine), several conversations side by side (drag a conversation
or tab to the edge of a pane to split; up to 6 panes with 6 tabs each), join / take over / give
back / leave / close, typing indicators and read receipts, alerts (title blink, sound, desktop
notifications), «Spør etter deg» when a visitor asks for them, visitor details and *move visitor to
page*, translation in both directions, «Legg i kunnskap» on replies, knowledge base and suggestion
review, callbacks, own profile (name, photo, greeting, language) and — for admins — tenant
settings, assistant portrait and API keys.

Also «Oversikt» (dashboard: visitors and conversations now and over a period, response time to
requests for a person, busy hours, top pages) and «Besøkende» (live visitors: who is on the site
now, their page, device, location and navigation history, and «Se live» to follow one visitor's
cursor, clicks and scroll over the page they are on).

For «Se live» to show the real page, the tenant's **public site** must allow being framed by your
admin page's origin; see [Framing the customer's site](#framing-the-customers-site) below.
Otherwise it shows a schematic view.

### Only want part of it?
Use the SDK (`createOperatorClient`) and build your own screens; see [sdk.md](sdk.md). A common
split: stock widget for visitors, own inbox for staff, or the other way round.

## Build your own live view

A headless admin can show the same live view as «Se live». The parts:

1. **The feed.** `staff.visitors.watch(visitorId)` (renews every 10 s) makes the visitor's browser
   send `pointer` events on `staff.subscribe(...)`: `url`, `viewport`, `scroll`, `documentHeight`,
   and pointer `samples` / `clicks` as `[t, x, y]` in the visitor's viewport coordinates. See
   [realtime.md](realtime.md#live-visitors).
2. **The page.** An iframe named `snakkbar-mirror` loading the visitor's current URL
   (`currentPage.url` from `visitors.get()`, then the `url` of each frame). Snakkbar's widget (or
   `startTracking()`) on that page sees the name, renders nothing, tracks nothing, and runs the
   *mirror*: it answers your `postMessage`s and scrolls the page where you tell it.
3. **The overlay.** Your own cursor and click marks drawn on top of the iframe.

```html
<div class="live" style="position: relative; overflow: hidden">
  <iframe
    name="snakkbar-mirror"
    sandbox="allow-scripts allow-same-origin"
    referrerpolicy="no-referrer"
    style="position: absolute; top: 0; left: 0; border: 0; transform-origin: 0 0; pointer-events: none"
  ></iframe>
  <div class="cursor" style="position: absolute; pointer-events: none"></div>
</div>
```

```ts
import { isMirrorMessage, type MirrorMessage } from "@snakkbar/sdk";
// `staff` is your createOperatorClient(...) from sdk.md; `visitorId` the visitor to watch.

const frame = document.querySelector<HTMLIFrameElement>("iframe[name=snakkbar-mirror]")!;
const box = frame.parentElement!;
const cursor = box.querySelector<HTMLElement>(".cursor")!;
let mirrorReady = false;
let fallback: ReturnType<typeof setTimeout> | undefined;

// No `ready` from the mirror within a few seconds: the site refused framing (or has no widget).
function load(url: string) {
  mirrorReady = false;
  frame.src = url;
  clearTimeout(fallback);
  fallback = setTimeout(() => !mirrorReady && showSchematicView(), 6000);
}

window.addEventListener("message", (event) => {
  if (event.source !== frame.contentWindow || !isMirrorMessage(event.data)) return;
  if (event.data.type === "ready") { mirrorReady = true; clearTimeout(fallback); hideSchematicView(); }
});

const visitor = await staff.visitors.get(visitorId);
if (visitor.currentPage) load(visitor.currentPage.url);

const send = (message: MirrorMessage, url: string) =>
  frame.contentWindow?.postMessage(message, new URL(url).origin);

const stopWatching = staff.visitors.watch(visitorId);
const sub = staff.subscribe((event) => {
  if (event.type !== "pointer" || event.visitorId !== visitorId || !event.visible) return;
  if (event.url !== frame.src) load(event.url); // the visitor moved on
  if (event.viewport) {
    // Render the page at the visitor's viewport size, scaled to fit the box.
    const scale = box.clientWidth / event.viewport.width;
    frame.style.width = `${event.viewport.width}px`;
    frame.style.height = `${event.viewport.height}px`;
    frame.style.transform = `scale(${scale})`;
    box.style.height = `${event.viewport.height * scale}px`;
    const last = event.samples.at(-1);
    if (last) cursor.style.transform = `translate(${last[1] * scale}px, ${last[2] * scale}px)`;
    // event.clicks: draw a short ripple at each [t, x, y] (scaled the same way)
  }
  if (event.scroll) send({ snakkbar: "mirror", type: "scroll", x: event.scroll.x, y: event.scroll.y }, event.url);
});
// leaving: stopWatching(); sub.close();
```

For smooth motion, replay `samples` over the batch using their `t` offsets instead of jumping to
the last one.

### The `MirrorMessage` protocol
All messages are objects with `snakkbar: "mirror"` (check with `isMirrorMessage`):

| Direction | `type` | Fields | |
| --- | --- | --- | --- |
| Live view → page | `hello` | | Asks the mirror to send `ready` again (e.g. when your view mounts after the page loaded). |
| Live view → page | `scroll` | `x`, `y` | Scroll the page to the visitor's position (instant). |
| Page → live view | `ready` | `url`, `width`, `height`, `documentHeight` | The mirror runs. Sent on load, on `hello`, and after the page's URL changes. |

The mirror only accepts messages from its parent window, and only sends layout numbers and the
page's own URL. It runs only when the tenant has live visitors on and the page has the widget or
calls `startTracking()`.

### Framing the customer's site
The iframe loads the public page from the customer's site, so that site decides whether it may be
framed:

- It must allow the origin of the page that contains the iframe in its CSP, e.g.
  `Content-Security-Policy: frame-ancestors 'self' https://admin.example.no`. That is
  `https://<host>` for the hosted panel, and the origin of your own admin page for `mountAdmin`
  or your own live view. List both if staff use both.
- It must not send `X-Frame-Options: DENY`, or `SAMEORIGIN` when the admin is on another origin.
  Browsers that support `frame-ancestors` ignore `X-Frame-Options` when both are present, but older
  ones don't; drop it once `frame-ancestors` is in place.
- Cookies set with `SameSite=Lax`/`Strict` (the default in most browsers) are not sent to a
  cross-site iframe, so pages that need a login show their logged-out version.

**Sandbox.** `allow-scripts` is required (the mirror is a script) and `allow-same-origin` keeps the
page working (storage, its own requests) as it does for visitors. Leave out `allow-forms`,
`allow-popups` and `allow-top-navigation*`, so nothing in the page can submit, open windows or
navigate your admin, and set `pointer-events: none` so the operator can't click in the copy by
accident. If your admin is served from the same origin as the customer's site, the framed page has
full access to the admin page either way; that is the customer's own code, but keep it in mind.

**Fallback.** When the site refuses framing (or has no widget), the browser shows an error page in
the iframe and you can't tell from the outside. Treat "no `ready` within a few seconds" as refused
and switch to a schematic view: a page outline sized `viewport` × `documentHeight` with the scroll
position, cursor and clicks from the `pointer` events, and the URL and title.

The operator's browser loads the page with **its own** session, never the visitor's; see
[security.md](security.md#live-visitors).

## Hosted alternative
Staff can always use `<host>/panel` with Snakkbar accounts instead (created by Snakkbar), or with
the sign-in your backend invites them to (section 2b). Hosted and embedded operators can work side
by side in the same tenant, and one person can use both with the same operator.
