Files
ChatApp/docs/superpowers/plans/2026-05-16-phase3-security.md
T

34 KiB
Raw Blame History

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:

-- 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
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:

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:

    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:

    .select('id, name, platform, last_seen_at, revoked_at')
    
    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:

    .select('id, name, platform, last_seen_at, revoked_at')
    
    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):

    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
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):

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:

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:

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
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:

// 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:

    revokedRemotely: boolean;
    acknowledgeRevocation: () => void;
    
  2. Import the new helper + shared wrappers + supabase client (most of these are already imported; verify):

    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 useStates 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:

    // 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:

    // 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
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:

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
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:

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:

    import { DeviceListTab } from '../components/settings/DeviceListTab';
    

    (Match the path style of nearby ../components/... imports.)

  2. Around line 118120, extend the TabId union to include 'devices':

    type TabId =
      | 'profile' | 'appearance' | 'privacy' | 'notifications'
      | 'voice' | 'screen-share' | 'soundboard' | 'security' | 'devices' | 'account';
    
  3. In the tabs array around line 122132, insert a new entry between 'security' and 'account':

    { 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:

    {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
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:

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:

    import { RemoteRevokedScreen } from './components/RemoteRevokedScreen';
    
  2. Inside the <HashRouter> block, alongside <UpdateToast /> and <CrashToast /> (right before </HashRouter>), add:

    <RemoteRevokedScreen />
    
  • Step 3: Typecheck

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

  • Step 4: Commit
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.