# Phase 3 — Security & Devices: Session/Device List + Revoke

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Let the user see every install currently signed into their account, revoke any one remotely, and force-sign-out any device whose row is flipped to `revoked_at != null` (either by another install or — in the future — by an admin).

**Architecture:**
- Server: extend the existing telemetry-only `devices` table with a `revoked_at` column + a `revoke_device(p_device_id uuid)` SECURITY DEFINER RPC. Publish `devices` to `supabase_realtime` so every signed-in client gets postgres_changes events for its own rows.
- Client: on each post-sign-in startup, "ensure" exactly one `devices` row exists for this install (registered the first time, then re-used). Subscribe to own `devices` for revoked_at flips → force `signOut()` (which already chains through `wipeLocalState`). A new "Geräte" tab in Settings lists rows + offers a Revoke button per row (own row's button is disabled, label "Nutze Sign-out").
- A new full-screen `RemoteRevokedScreen` is rendered as a top-level overlay (inside `AuthProvider`) whenever `revokedRemotely` is true, so the user sees "Du wurdest remote abgemeldet" *before* the router bounces them to `/auth`.

**Tech Stack:** Postgres + RLS + RPC + Supabase Realtime (postgres_changes); React 18 + HashRouter; Electron preload IPC for OS hostname; existing `wipeLocalState` and `signOut` chain in `AuthContext`.

**Non-goals:**
- No "rename device" UI (out of scope for Phase 3 spec).
- No "log out all other devices" bulk action.
- No admin-side revoke; spec only requires user-driven revoke of own installs.

---

## Pre-flight

- [ ] **Verify current working directory and clean status**

Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status`
Expected: branch `main`, no uncommitted changes (except possibly `apps/desktop/.env.local` which is gitignored).

- [ ] **Confirm tooling is green before starting**

Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test --run`
Expected: all green. If anything is red, STOP and report — don't start on a broken baseline.

---

## Task 1: SQL migration — devices.revoked_at + revoke_device RPC + realtime publication

**Why:** The whole feature pivots on a server-side authoritative "is this install still allowed in" flag plus a way for any of the user's own installs to flip it.

**Files:**
- Create: `supabase/migrations/20260516000005_device_revocation.sql`

**Migration must be idempotent** — pre-Phase-2 we re-ran migrations against prod by hand, and pushing again must not error. Use `add column if not exists`, `create or replace function`, `do $$ ... if not exists ... $$` for publication adds.

- [ ] **Step 1: Write the SQL migration**

Create `supabase/migrations/20260516000005_device_revocation.sql`:

```sql
-- Phase 3: per-device revocation. Adds a revoked_at flag, an RPC that lets
-- the device's owner flip it, and publishes the devices table to realtime
-- so every signed-in install can react to its own row being revoked.

alter table public.devices
  add column if not exists revoked_at timestamptz null;

create index if not exists devices_user_revoked_idx
  on public.devices(user_id, revoked_at);

-- RLS: existing policy on devices already gates by user_id; an UPDATE done
-- via the RPC runs with SECURITY DEFINER so we don't widen the RLS surface.

create or replace function public.revoke_device(p_device_id uuid)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
  v_owner uuid;
begin
  if auth.uid() is null then
    raise exception 'not authenticated' using errcode = '28000';
  end if;

  select user_id into v_owner
    from public.devices
    where id = p_device_id;

  if v_owner is null then
    raise exception 'device not found' using errcode = 'P0002';
  end if;

  if v_owner <> auth.uid() then
    raise exception 'not authorized' using errcode = '42501';
  end if;

  update public.devices
    set revoked_at = now()
    where id = p_device_id
      and revoked_at is null;
end;
$$;

revoke all on function public.revoke_device(uuid) from public;
grant execute on function public.revoke_device(uuid) to authenticated;

-- Replica identity full so UPDATE events deliver the full new row (including
-- revoked_at) to subscribers; default REPLICA IDENTITY DEFAULT only sends
-- the primary key columns, which would force a refetch on every event.
alter table public.devices replica identity full;

-- Add devices to the supabase_realtime publication if not already present.
do $$
begin
  if not exists (
    select 1
      from pg_publication_tables
      where pubname = 'supabase_realtime'
        and schemaname = 'public'
        and tablename = 'devices'
  ) then
    execute 'alter publication supabase_realtime add table public.devices';
  end if;
end
$$;
```

- [ ] **Step 2: Commit the migration**

```bash
cd "D:\Programmieren\ChatApp-Electron\chat-app"
git add supabase/migrations/20260516000005_device_revocation.sql
git commit -m "feat(P3.T1): devices.revoked_at + revoke_device RPC + realtime publication"
```

Do NOT push to prod yet — `bash scripts/prod/push-migrations.sh <filter>` is only run when explicitly authorized by the user. This task is local-commit-only.

---

## Task 2: Shared wrapper — DeviceRecord.revokedAt + revokeDevice helper + listOwnDevices update

**Why:** The desktop renderer only ever talks to Supabase through `@chat-app/shared` wrappers. Adding `revokedAt` to `DeviceRecord` and a `revokeDevice` helper keeps the contract consistent across packages and gives us a typed RPC client.

**Files:**
- Modify: `packages/shared/src/auth/device.ts`
- Test: `packages/shared/src/auth/device.test.ts` (CREATE — file doesn't exist today)

- [ ] **Step 1: Write the failing test**

Create `packages/shared/src/auth/device.test.ts`:

```ts
import { describe, expect, it, vi } from 'vitest';

import { listOwnDevices, revokeDevice } from './device';

function makeClient(overrides: {
  user?: { id: string } | null;
  selectData?: Array<{ id: string; name: string; platform: string; last_seen_at: string; revoked_at: string | null }>;
  rpcImpl?: (fn: string, params: unknown) => Promise<{ data: unknown; error: unknown }>;
}): any {
  const builder: any = {
    select: vi.fn().mockReturnThis(),
    eq: vi.fn().mockReturnThis(),
    order: vi.fn().mockResolvedValue({ data: overrides.selectData ?? [], error: null }),
  };
  return {
    auth: { getUser: vi.fn().mockResolvedValue({ data: { user: overrides.user ?? { id: 'u-1' } } }) },
    from: vi.fn().mockReturnValue(builder),
    rpc: vi.fn().mockImplementation(overrides.rpcImpl ?? (async () => ({ data: null, error: null }))),
  };
}

describe('listOwnDevices', () => {
  it('maps revoked_at into revokedAt', async () => {
    const client = makeClient({
      selectData: [
        { id: 'd-1', name: 'Laptop', platform: 'desktop', last_seen_at: '2026-05-16T00:00:00Z', revoked_at: null },
        { id: 'd-2', name: 'Old phone', platform: 'mobile', last_seen_at: '2026-05-10T00:00:00Z', revoked_at: '2026-05-15T12:00:00Z' },
      ],
    });
    const out = await listOwnDevices(client);
    expect(out).toEqual([
      { id: 'd-1', name: 'Laptop', platform: 'desktop', lastSeenAt: '2026-05-16T00:00:00Z', revokedAt: null },
      { id: 'd-2', name: 'Old phone', platform: 'mobile', lastSeenAt: '2026-05-10T00:00:00Z', revokedAt: '2026-05-15T12:00:00Z' },
    ]);
  });
});

describe('revokeDevice', () => {
  it('invokes the revoke_device RPC with the device id', async () => {
    const rpc = vi.fn().mockResolvedValue({ data: null, error: null });
    const client = makeClient({ rpcImpl: rpc });
    await revokeDevice(client, 'd-42');
    expect(rpc).toHaveBeenCalledWith('revoke_device', { p_device_id: 'd-42' });
  });

  it('throws when the RPC returns an error', async () => {
    const client = makeClient({
      rpcImpl: async () => ({ data: null, error: { message: 'not authorized', code: '42501' } as any }),
    });
    await expect(revokeDevice(client, 'd-99')).rejects.toThrow(/not authorized/);
  });
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `pnpm --filter @chat-app/shared test --run device`
Expected: FAIL — `revokeDevice` not exported, `revokedAt` missing from mapped row.

- [ ] **Step 3: Implement revokedAt and revokeDevice in the wrapper**

Edit `packages/shared/src/auth/device.ts`:

- Extend `DeviceRecord`:
  ```ts
  export interface DeviceRecord {
    id: string;
    name: string;
    platform: DevicePlatform;
    lastSeenAt: string;
    revokedAt: string | null;
  }
  ```
- Update `registerDevice`'s select + return-mapper to include `revoked_at` / `revokedAt: null`:
  ```ts
  .select('id, name, platform, last_seen_at, revoked_at')
  ```
  ```ts
  return {
    id: data.id,
    name: data.name,
    platform: data.platform,
    lastSeenAt: data.last_seen_at,
    revokedAt: data.revoked_at,
  };
  ```
- Update `listOwnDevices` to select+map revoked_at:
  ```ts
  .select('id, name, platform, last_seen_at, revoked_at')
  ```
  ```ts
  return data.map((row) => ({
    id: row.id,
    name: row.name,
    platform: row.platform,
    lastSeenAt: row.last_seen_at,
    revokedAt: row.revoked_at,
  }));
  ```
- Add the new helper at the end of the file (above the re-exports):
  ```ts
  export async function revokeDevice(
    client: AppSupabaseClient,
    deviceId: string,
  ): Promise<void> {
    const { error } = await client.rpc('revoke_device', { p_device_id: deviceId });
    if (error) throw new Error(error.message);
  }
  ```

- [ ] **Step 4: Run the test to verify it passes**

Run: `pnpm --filter @chat-app/shared test --run device`
Expected: PASS — 3 tests in `device.test.ts` pass.

- [ ] **Step 5: Run full shared typecheck + test suite**

Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/shared test --run`
Expected: all green. If a downstream file uses `DeviceRecord` and breaks on the missing field, fix the call site to handle `revokedAt: null` (don't suppress with `as any`).

- [ ] **Step 6: Commit**

```bash
git add packages/shared/src/auth/device.ts packages/shared/src/auth/device.test.ts
git commit -m "feat(P3.T2): DeviceRecord.revokedAt + revokeDevice wrapper"
```

---

## Task 3: Preload IPC — `app:hostname` for the device-name default

**Why:** The first time we register a `devices` row for this install we need a human-readable name. Renderer has no Node access so we expose a tiny IPC that returns `os.hostname()`. If the call ever fails (e.g. web build), the caller falls back to a static "Desktop".

**Files:**
- Modify: `apps/desktop/electron/ipc-types.ts` — add channel constant
- Modify: `apps/desktop/electron/main.ts` — register handler
- Modify: `apps/desktop/electron/preload.ts` — expose method
- Modify: `apps/desktop/electron/preload-types.d.ts` — extend renderer typing

- [ ] **Step 1: Find the existing CHANNELS constant and add the new channel**

Run: `grep -n "CHANNELS =" apps/desktop/electron/ipc-types.ts`

Add a new entry next to the other `app:*` channels (or at the end if there are none):
```ts
export const CHANNELS = {
  // … existing …
  appHostname: 'app:hostname',
} as const;
```
(Pick a name that matches the existing convention in that file — if other entries use snake_case keys, match them.)

- [ ] **Step 2: Register the handler in main.ts**

Locate the place in `apps/desktop/electron/main.ts` where other `ipcMain.handle(...)` calls live (search for `ipcMain.handle`). Add:
```ts
import os from 'node:os';
// …
ipcMain.handle(CHANNELS.appHostname, () => {
  try {
    const h = os.hostname();
    return typeof h === 'string' && h.length > 0 ? h : null;
  } catch {
    return null;
  }
});
```

- [ ] **Step 3: Expose the method on the preload bridge**

Edit `apps/desktop/electron/preload.ts` — in the `contextBridge.exposeInMainWorld('electronAPI', { ... })` object, add:
```ts
getHostname: (): Promise<string | null> => ipcRenderer.invoke(CHANNELS.appHostname),
```
(Match the formatting/style of nearby entries.)

- [ ] **Step 4: Extend the renderer typing**

Edit `apps/desktop/electron/preload-types.d.ts` to add `getHostname?: () => Promise<string | null>` to the `ElectronAPI` interface (make it optional so the renderer code that consumes it must always `if (typeof window.electronAPI?.getHostname === 'function')`).

- [ ] **Step 5: Typecheck**

Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add apps/desktop/electron/ipc-types.ts apps/desktop/electron/main.ts apps/desktop/electron/preload.ts apps/desktop/electron/preload-types.d.ts
git commit -m "feat(P3.T3): app:hostname IPC for device-name default"
```

---

## Task 4: AuthContext — ensure device row + revocation realtime + revokedRemotely flag

**Why:** Every install needs (a) a `devices` row on the server so it shows up in the Geräte tab and (b) a live subscription that triggers a forced sign-out when its row gets revoked.

**Files:**
- Create: `apps/desktop/src/lib/deviceRowId.ts` — tiny localStorage helper
- Modify: `apps/desktop/src/context/AuthContext.tsx` — ensure-row effect + realtime subscription + `revokedRemotely` state

- [ ] **Step 1: Create the deviceRowId helper**

Create `apps/desktop/src/lib/deviceRowId.ts`:

```ts
// localStorage key for "the devices.id row that belongs to THIS install".
// Reset on memory-wipe (NOT preserved) — a wiped install is conceptually a
// fresh install, so registering a new row is correct.

const KEY = 'chatapp.deviceRowId.v1';

export function getDeviceRowId(): string | null {
  try {
    const v = window.localStorage.getItem(KEY);
    return v && v.length > 0 ? v : null;
  } catch {
    return null;
  }
}

export function setDeviceRowId(id: string): void {
  try {
    window.localStorage.setItem(KEY, id);
  } catch {
    /* quota */
  }
}

export function clearDeviceRowId(): void {
  try {
    window.localStorage.removeItem(KEY);
  } catch {
    /* no-op */
  }
}
```

- [ ] **Step 2: Add `revokedRemotely` to the AuthContext value + wire ensure-device effect**

Edit `apps/desktop/src/context/AuthContext.tsx`:

1. Extend the `AuthContextValue` interface with:
   ```ts
   revokedRemotely: boolean;
   acknowledgeRevocation: () => void;
   ```

2. Import the new helper + shared wrappers + supabase client (most of these are already imported; verify):
   ```ts
   import { listOwnDevices, registerDevice, touchDeviceLastSeen } from '@chat-app/shared/auth';
   import { clearDeviceRowId, getDeviceRowId, setDeviceRowId } from '../lib/deviceRowId';
   ```

3. Add a `const [revokedRemotely, setRevokedRemotely] = useState(false);` next to the other `useState`s in `AuthProvider`.

4. Add `acknowledgeRevocation` as a `useCallback` that just calls `setRevokedRemotely(false)`. Include it in the `value` memo + the deps array.

5. After the existing `void registerWebPush(installId);` effect, add an "ensure device row" effect:
   ```ts
   // Phase 3: ensure this install owns exactly one devices row. The row is
   // pure session-list telemetry — it does not carry any cryptographic
   // material since the per-user-key refactor. We re-use the row across
   // restarts via localStorage (chatapp.deviceRowId.v1); a memory-wipe is
   // intentionally treated as "new install".
   useEffect(() => {
     if (!session) return;
     let cancelled = false;
     void (async () => {
       try {
         const existing = getDeviceRowId();
         if (existing) {
           const rows = await listOwnDevices(supabase);
           const match = rows.find((r) => r.id === existing && r.revokedAt === null);
           if (match) {
             await touchDeviceLastSeen(supabase, existing).catch(() => {});
             return;
           }
           // Row gone / revoked: drop the stale id and fall through to
           // registering a fresh one.
           clearDeviceRowId();
         }
         if (cancelled) return;
         const hostname =
           (typeof window.electronAPI?.getHostname === 'function'
             ? await window.electronAPI.getHostname().catch(() => null)
             : null) ?? 'Desktop';
         const created = await registerDevice(supabase, {
           name: hostname.slice(0, 64),
           platform: 'desktop',
         });
         if (!cancelled) setDeviceRowId(created.id);
       } catch (err) {
         console.warn('ensure device row failed', err);
       }
     })();
     return () => {
       cancelled = true;
     };
   }, [session]);
   ```

6. Add a "revocation realtime" effect AFTER the ensure-device effect:
   ```ts
   // Phase 3: listen for own-device revocations. The same channel also fires
   // when *another* of the user's installs is revoked — we ignore those (we
   // only force-sign-out when OUR row's revoked_at flips). The UI's device
   // list refetches independently via its own subscription in useOwnDevices.
   useEffect(() => {
     if (!session) return;
     const userId = session.user.id;
     const channel = supabase
       .channel('devices:self:' + userId)
       .on(
         'postgres_changes',
         {
           event: 'UPDATE',
           schema: 'public',
           table: 'devices',
           filter: 'user_id=eq.' + userId,
         },
         (payload) => {
           const ownId = getDeviceRowId();
           const row = payload.new as { id?: string; revoked_at?: string | null } | null;
           if (!row || !ownId) return;
           if (row.id !== ownId) return;
           if (row.revoked_at) {
             setRevokedRemotely(true);
             // Force-sign-out chain. signOut already wipes localStorage.
             void signOut().catch((err) => {
               console.warn('forced signOut after revoke failed', err);
             });
           }
         },
       )
       .subscribe();
     return () => {
       void supabase.removeChannel(channel);
     };
   }, [session, signOut]);
   ```

   Place this AFTER `signOut` is defined (which is the `useCallback` at line ~251). If a hoisting/order issue makes that awkward, define a `signOutRef = useRef<() => Promise<void>>()` updated by an effect and call `signOutRef.current?.()` inside the realtime callback — but try the direct approach first.

7. Add `revokedRemotely` + `acknowledgeRevocation` to the `value` object + deps array of its `useMemo`.

8. Do NOT reset `revokedRemotely` in `signOut` itself — the flag is only ever set by the realtime callback, and `acknowledgeRevocation` is the explicit reset path.

- [ ] **Step 3: Typecheck**

Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS. If a complaint about the supabase channel filter syntax appears, double-check it matches existing realtime subscriptions in the codebase (search `postgres_changes` for an example).

- [ ] **Step 4: Commit**

```bash
git add apps/desktop/src/lib/deviceRowId.ts apps/desktop/src/context/AuthContext.tsx
git commit -m "feat(P3.T4): ensure device row + revoke-realtime + revokedRemotely flag"
```

---

## Task 5: `useOwnDevices` hook — initial list + realtime refresh

**Why:** The Geräte tab needs a live, mutable list. Pulling once on mount means a freshly-revoked row stays visible until manual refresh; subscribing keeps the list in sync with reality.

**Files:**
- Create: `apps/desktop/src/hooks/useOwnDevices.ts`

- [ ] **Step 1: Write the hook**

Create `apps/desktop/src/hooks/useOwnDevices.ts`:

```ts
import { useCallback, useEffect, useState } from 'react';

import { type DeviceRecord, listOwnDevices, revokeDevice } from '@chat-app/shared/auth';

import { useAuth } from '../context/AuthContext';
import { supabase } from '../lib/supabase';

interface State {
  devices: DeviceRecord[];
  loading: boolean;
  error: string | null;
}

export function useOwnDevices(): {
  devices: DeviceRecord[];
  loading: boolean;
  error: string | null;
  refresh: () => Promise<void>;
  revoke: (deviceId: string) => Promise<void>;
} {
  const { session } = useAuth();
  const userId = session?.user.id ?? null;
  const [state, setState] = useState<State>({ devices: [], loading: true, error: null });

  const refresh = useCallback(async () => {
    try {
      setState((s) => ({ ...s, loading: true, error: null }));
      const list = await listOwnDevices(supabase);
      setState({ devices: list, loading: false, error: null });
    } catch (err) {
      setState((s) => ({
        ...s,
        loading: false,
        error: err instanceof Error ? err.message : 'failed to load devices',
      }));
    }
  }, []);

  useEffect(() => {
    if (!userId) {
      setState({ devices: [], loading: false, error: null });
      return;
    }
    void refresh();
    const channel = supabase
      .channel('devices:list:' + userId)
      .on(
        'postgres_changes',
        { event: '*', schema: 'public', table: 'devices', filter: 'user_id=eq.' + userId },
        () => {
          void refresh();
        },
      )
      .subscribe();
    return () => {
      void supabase.removeChannel(channel);
    };
  }, [userId, refresh]);

  const revoke = useCallback(async (deviceId: string) => {
    await revokeDevice(supabase, deviceId);
    await refresh();
  }, [refresh]);

  return { devices: state.devices, loading: state.loading, error: state.error, refresh, revoke };
}
```

If `../lib/supabase` doesn't export the client under the name `supabase`, follow the convention used by neighboring hooks (e.g. `usePinnedMessages.ts`, `useMentionNotifications.ts`) for both the import and any path differences.

- [ ] **Step 2: Typecheck**

Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add apps/desktop/src/hooks/useOwnDevices.ts
git commit -m "feat(P3.T5): useOwnDevices hook with realtime refresh"
```

---

## Task 6: SettingsPage — new "Geräte" tab between Sicherheit and Konto

**Why:** This is the user-facing surface — a sortable list of installs with a Revoke button per row, the own install marked with a badge and a disabled button.

**Files:**
- Create: `apps/desktop/src/components/settings/DeviceListTab.tsx`
- Modify: `apps/desktop/src/pages/SettingsPage.tsx` (TabId union, tabs array, content panel)

If the directory `apps/desktop/src/components/settings/` does not already exist, create it. Check first with `ls apps/desktop/src/components/`.

- [ ] **Step 1: Build the tab component**

Create `apps/desktop/src/components/settings/DeviceListTab.tsx`:

```tsx
import { useState } from 'react';
import { useTranslation } from 'react-i18next';

import { useOwnDevices } from '../../hooks/useOwnDevices';
import { getDeviceRowId } from '../../lib/deviceRowId';
import { SpinnerIcon } from '../icons';

function formatLastSeen(iso: string, locale: string): string {
  try {
    const d = new Date(iso);
    return d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
  } catch {
    return iso;
  }
}

export function DeviceListTab() {
  const { t, i18n } = useTranslation();
  const { devices, loading, error, revoke } = useOwnDevices();
  const ownId = getDeviceRowId();
  const [busy, setBusy] = useState<string | null>(null);
  const [opError, setOpError] = useState<string | null>(null);

  const handleRevoke = async (id: string) => {
    setOpError(null);
    setBusy(id);
    try {
      await revoke(id);
    } catch (err) {
      setOpError(err instanceof Error ? err.message : 'revoke failed');
    } finally {
      setBusy(null);
    }
  };

  if (loading) {
    return (
      <div className="flex items-center justify-center py-10 text-fg-muted">
        <SpinnerIcon className="h-5 w-5" />
      </div>
    );
  }

  if (error) {
    return (
      <p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-600 dark:text-rose-300">
        {t('app:settings.devices.error', { defaultValue: 'Geräteliste konnte nicht geladen werden.' })}
      </p>
    );
  }

  if (devices.length === 0) {
    return (
      <p className="text-sm text-fg-muted">
        {t('app:settings.devices.empty', { defaultValue: 'Noch keine Geräte angemeldet.' })}
      </p>
    );
  }

  return (
    <div className="space-y-3">
      {opError && (
        <p className="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2 text-sm text-rose-600 dark:text-rose-300">
          {opError}
        </p>
      )}
      <ul className="space-y-2">
        {devices.map((d) => {
          const isOwn = d.id === ownId;
          const isRevoked = d.revokedAt !== null;
          return (
            <li
              key={d.id}
              className="flex items-center gap-3 rounded-lg border border-line bg-surface-2 px-4 py-3"
            >
              <div className="min-w-0 flex-1">
                <div className="flex items-center gap-2">
                  <span className="truncate text-sm font-medium text-fg">{d.name}</span>
                  {isOwn && (
                    <span className="rounded-md bg-accent/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
                      {t('app:settings.devices.this_device', { defaultValue: 'Dieses Gerät' })}
                    </span>
                  )}
                  {isRevoked && (
                    <span className="rounded-md bg-rose-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-rose-600 dark:text-rose-300">
                      {t('app:settings.devices.revoked', { defaultValue: 'Abgemeldet' })}
                    </span>
                  )}
                </div>
                <p className="mt-0.5 text-xs text-fg-muted">
                  {d.platform} · {t('app:settings.devices.last_seen', { defaultValue: 'zuletzt' })}{' '}
                  {formatLastSeen(d.lastSeenAt, i18n.language)}
                </p>
              </div>
              {isOwn ? (
                <button
                  type="button"
                  disabled
                  title={t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
                  className="cursor-not-allowed rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted opacity-60"
                >
                  {t('app:settings.devices.use_sign_out', { defaultValue: 'Nutze Sign-out' })}
                </button>
              ) : isRevoked ? (
                <span className="text-xs text-fg-muted">
                  {t('app:settings.devices.already_revoked', { defaultValue: '—' })}
                </span>
              ) : (
                <button
                  type="button"
                  onClick={() => void handleRevoke(d.id)}
                  disabled={busy === d.id}
                  className="cursor-pointer rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-1.5 text-xs font-semibold text-rose-600 transition hover:bg-rose-500/20 disabled:cursor-wait disabled:opacity-60 dark:text-rose-300"
                >
                  {busy === d.id
                    ? t('app:settings.devices.revoking', { defaultValue: 'Wird abgemeldet…' })
                    : t('app:settings.devices.revoke', { defaultValue: 'Abmelden' })}
                </button>
              )}
            </li>
          );
        })}
      </ul>
    </div>
  );
}
```

- [ ] **Step 2: Wire the tab into SettingsPage**

In `apps/desktop/src/pages/SettingsPage.tsx`:

1. At the top with other imports, add:
   ```ts
   import { DeviceListTab } from '../components/settings/DeviceListTab';
   ```
   (Match the path style of nearby `../components/...` imports.)

2. Around line 118–120, extend the `TabId` union to include `'devices'`:
   ```ts
   type TabId =
     | 'profile' | 'appearance' | 'privacy' | 'notifications'
     | 'voice' | 'screen-share' | 'soundboard' | 'security' | 'devices' | 'account';
   ```

3. In the `tabs` array around line 122–132, insert a new entry between `'security'` and `'account'`:
   ```ts
   { id: 'devices',       label: t('app:settings.nav_devices',       { defaultValue: 'Geräte' }),             Icon: MonitorShareIcon },
   ```
   `MonitorShareIcon` is already imported (line 128 uses it for screen-share). If that feels wrong stylistically, use an existing alternative such as `LockIcon` or whichever icon set the file uses — but do NOT add a new icon import; pick from what's already imported.

4. In the content panel (after the `activeTab === 'security'` block at line ~344 and before the `activeTab === 'account'` block at line ~355), add:
   ```tsx
   {activeTab === 'devices' && (
     <Section
       title={t('app:settings.section_devices', { defaultValue: 'Geräte' })}
       description={t('app:settings.section_devices_hint', {
         defaultValue: 'Übersicht aller Geräte, die mit deinem Konto angemeldet sind.',
       })}
     >
       <DeviceListTab />
     </Section>
   )}
   ```

- [ ] **Step 3: Typecheck**

Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add apps/desktop/src/components/settings/DeviceListTab.tsx apps/desktop/src/pages/SettingsPage.tsx
git commit -m "feat(P3.T6): Settings 'Geräte' tab with revoke + this-device badge"
```

---

## Task 7: RemoteRevokedScreen — overlay shown when revokedRemotely flips to true

**Why:** When the user (or another of their installs) revokes THIS install, we force `signOut()` which races against the realtime delivery + the route navigation. The user should see a clear "Du wurdest remote abgemeldet" screen — not just be silently bounced to /auth.

**Files:**
- Create: `apps/desktop/src/components/RemoteRevokedScreen.tsx`
- Modify: `apps/desktop/src/App.tsx` — render the overlay inside `AuthProvider`

- [ ] **Step 1: Build the overlay**

Create `apps/desktop/src/components/RemoteRevokedScreen.tsx`:

```tsx
import { useTranslation } from 'react-i18next';

import { useAuth } from '../context/AuthContext';

export function RemoteRevokedScreen() {
  const { t } = useTranslation();
  const { revokedRemotely, acknowledgeRevocation } = useAuth();

  if (!revokedRemotely) return null;

  return (
    <div
      role="alertdialog"
      aria-modal="true"
      aria-labelledby="remote-revoked-title"
      className="fixed inset-0 z-[1000] flex items-center justify-center bg-ink-950/95 p-6"
    >
      <div className="max-w-sm rounded-2xl border border-line bg-surface-2 p-6 text-center shadow-xl">
        <h2
          id="remote-revoked-title"
          className="mb-2 font-display text-xl font-semibold text-fg"
        >
          {t('app:auth.revoked_title', { defaultValue: 'Du wurdest remote abgemeldet' })}
        </h2>
        <p className="mb-5 text-sm text-fg-muted">
          {t('app:auth.revoked_body', {
            defaultValue:
              'Ein anderes deiner Geräte hat diesen Login beendet. Aus Sicherheitsgründen wurden alle lokalen Daten gelöscht.',
          })}
        </p>
        <button
          type="button"
          onClick={acknowledgeRevocation}
          className="inline-flex cursor-pointer items-center justify-center rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-contrast transition hover:bg-accent/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
        >
          {t('app:auth.revoked_acknowledge', { defaultValue: 'Verstanden' })}
        </button>
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Mount the overlay in App.tsx**

In `apps/desktop/src/App.tsx`:

1. Add the import at the top with other component imports:
   ```ts
   import { RemoteRevokedScreen } from './components/RemoteRevokedScreen';
   ```

2. Inside the `<HashRouter>` block, alongside `<UpdateToast />` and `<CrashToast />` (right before `</HashRouter>`), add:
   ```tsx
   <RemoteRevokedScreen />
   ```

- [ ] **Step 3: Typecheck**

Run: `pnpm --filter @chat-app/desktop typecheck`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add apps/desktop/src/components/RemoteRevokedScreen.tsx apps/desktop/src/App.tsx
git commit -m "feat(P3.T7): RemoteRevokedScreen overlay for own-device revocation"
```

---

## Final gate

- [ ] **Step 1: Run full typecheck across both packages**

Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck`
Expected: both PASS.

- [ ] **Step 2: Run full shared test suite**

Run: `pnpm --filter @chat-app/shared test --run`
Expected: PASS — at least the previous 30 tests + the 3 new device tests added in T2 = 33 tests passing.

- [ ] **Step 3: Verify no uncommitted changes**

Run: `git status`
Expected: clean working tree on `main` (`apps/desktop/.env.local` is gitignored and may show in `git status --ignored`, that's fine).

- [ ] **Step 4: Report to user**

Report: "Phase 3 code-complete on `main`. New migration `20260516000005_device_revocation.sql` is committed locally but NOT yet pushed to prod — say the word and I run `bash scripts/prod/push-migrations.sh device_revocation` (filter pattern matches the new file). After the prod push, smoke test in dev: Settings → Geräte should list this install with the 'Dieses Gerät' badge. To test revoke, sign into a second install (or use prod CLI), revoke this install from the other one → this client should pop the 'Du wurdest remote abgemeldet' overlay and bounce to /auth. **No release** — version stays 0.18.8 until all 5 phases done."

---

## Self-review checklist (resolved inline)

1. **Spec coverage:**
   - existing devices table → T1 reuses it
   - Geräte tab between Sicherheit and Konto → T6
   - name · platform · last_seen → T6 row layout
   - "Dieses Gerät" badge → T6 isOwn branch
   - revoked_at column → T1
   - revoke_device RPC validates user_id = auth.uid() → T1
   - realtime subscription on own devices → T4
   - forced signOut + Memory-Wipe on own revoke → T4 (signOut already chains wipeLocalState)
   - "Du wurdest remote abgemeldet" screen → T7
   - own device's revoke button disabled, label "Nutze Sign-out" → T6 isOwn branch

2. **Placeholders:** none — every step has concrete code.

3. **Type consistency:**
   - `DeviceRecord.revokedAt: string | null` introduced in T2; consumed by T5 (hook) and T6 (UI) consistently
   - `revokeDevice(client, deviceId): Promise<void>` defined T2, used T5 (via hook) and indirectly T6 (via hook's `revoke`)
   - `getDeviceRowId(): string | null` defined T4, used in T4 (AuthContext) + T6 (DeviceListTab)
   - `revokedRemotely` + `acknowledgeRevocation` added to AuthContext value in T4, consumed by T7

4. **One ambiguity surfaced + resolved:** the spec says Realtime → forced signOut. We also need the *revoking* device (when revoking ANOTHER device) to NOT receive any extra signOut for itself — handled by T4's `row.id !== ownId` early return.

5. **Memory-wipe interaction:** the `chatapp.deviceRowId.v1` key is intentionally NOT added to `PRESERVE_LOCAL_STORAGE`, so a memory-wipe gets a fresh device row on next sign-in. That matches the spirit of memory-wipe = fresh install.
