# タブと端末の同期：ログアウト、権限、カート

> すべての場所から一度にログアウトし、取り消した権限をすぐに反映し、カートや設定をユーザーが開いているすべてのタブと端末でそろえます。

Source: https://hibira.io/ja/use-cases/multi-tab-sync

ユーザーはアプリを複数のタブや端末で開いたままにしていて、それぞれは再読み込みするまで変化を知りません。共用パソコンでログアウトしても、スマートフォンのタブにはまだそのアカウントが表示されています。管理者が同僚の編集権限を外しても、同僚は再読み込みするまで編集を続けられます。スマートフォンでカートに商品を入れても、ノートパソコンのタブではカートは空のままです。Hibira なら、サーバーが開いているアプリのすべてに一度で知らせます。

## ユーザーが得るもの

- 「すべての端末からログアウト」で、開いているセッションが本当にすぐ閉じる。
- ロールの変更や権限の取り消しが再読み込みなしで反映される。
- どのタブ、どの端末でもカート、設定、未読数が同じ。

## 使う機能

- [リアルタイムのアプリ内通知](https://hibira.io/ja/use-cases/in-app-notifications)と同じ**ユーザー認証**（`pusher.signin()`）。サインインした各接続がどのユーザーのものか分かります。
- **`sendToUser`**。1 回の呼び出しで、そのユーザーのすべてのタブと端末に届きます。
- **`terminateUserConnections`**。そのユーザーとしてサインインしているすべての接続を閉じます。

```text
flow: サーバーからの 1 回の呼び出しが、ユーザーが開いているアプリのすべてに届く
== あるユーザーの状態が変わったとき
サーバー|ログアウト、新しいロール、カート --`sendToUser()`--> *Hibira --WebSocket--> すべてのタブと端末|そのユーザーのもの
== すべての端末からログアウト
サーバー|セッションを取り消し済み --すべて切断--> *Hibira --閉じる--> すべての接続|そのユーザーとしてサインイン中
```

## ステップ 1：すべての端末からログアウトする

まずデータベースでセッションを取り消し、開いているアプリすべてに知らせてから、接続を閉じます：

```js
app.post('/account/sign-out-everywhere', requireLogin, async (req, res) => {
  const id = String(req.user.id);
  await db.sessions.revokeAll(id);
  await pusher.sendToUser(id, 'signed-out', {});
  await pusher.terminateUserConnections(id);
  res.sendStatus(204);
});
```

閉じられた接続は再びサインインできません。ユーザー認証エンドポイントが、取り消されたセッションをもう受け付けないためです。

## ステップ 2：権限の変更をすぐ反映する

管理者が誰かのロールを変えたら、合図を送ります。ページはサーバーから権限を取り直し、判断の根拠は常にサーバーです：

```js
await db.members.update(member.id, { role: 'viewer' });
await pusher.sendToUser(String(member.userId), 'permissions-changed', {});
```

## ステップ 3：どこでも同じカートにする

カートが変わるたびに、ヘッダーに表示する概要を送ります：

```js
app.post('/api/cart/items', requireLogin, async (req, res) => {
  const cart = await db.carts.add(req.user.id, req.body);
  await pusher.sendToUser(String(req.user.id), 'cart', { count: cart.count, total: cart.total });
  res.json(cart);
});
```

## ステップ 4：すべてのタブで反応する

```js
pusher.user.bind('signed-out', () => location.assign('/login'));
pusher.user.bind('permissions-changed', async () => {
  applyPermissions(await fetch('/api/me/permissions').then((r) => r.json()));
});
pusher.user.bind('cart', ({ count, total }) => cartBadge.set(count, total));
```

## ヒント

- セキュリティ上、イベントそのものは信用しません。「もう一度確認して」という意味だけです。API は引き続きリクエストごとにセッションとロールを確認します。
- `BroadcastChannel` は同じブラウザーのタブ同士を同期しますが、ほかの端末には届かず、管理者やサーバーによる変更も受け取れません。
- ゲストのカートは、カートごとのプライベートチャンネル（`private-cart-<id>`）をカートの Cookie で認可して使えます。
- カート全体ではなく件数と合計を送ります。イベントのサイズはプランによって異なります。[料金](https://hibira.io/ja/pricing)をご覧ください。
