From ede0d00f5bdd26945407bec917dbef6169cad016 Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 18:45:46 +0200 Subject: [PATCH] docs(P3): Phase 3 implementation plan (Session/Device List + Revoke) --- .../plans/2026-05-16-phase3-security.md | 927 ++++++++++++++++++ 1 file changed, 927 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-16-phase3-security.md diff --git a/docs/superpowers/plans/2026-05-16-phase3-security.md b/docs/superpowers/plans/2026-05-16-phase3-security.md new file mode 100644 index 0000000..2a20b95 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-phase3-security.md @@ -0,0 +1,927 @@ +# 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 ` 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 { + 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 => 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` 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>()` 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; + revoke: (deviceId: string) => Promise; +} { + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [state, setState] = useState({ 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(null); + const [opError, setOpError] = useState(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 ( +
+ +
+ ); + } + + if (error) { + return ( +

+ {t('app:settings.devices.error', { defaultValue: 'Geräteliste konnte nicht geladen werden.' })} +

+ ); + } + + if (devices.length === 0) { + return ( +

+ {t('app:settings.devices.empty', { defaultValue: 'Noch keine Geräte angemeldet.' })} +

+ ); + } + + return ( +
+ {opError && ( +

+ {opError} +

+ )} +
    + {devices.map((d) => { + const isOwn = d.id === ownId; + const isRevoked = d.revokedAt !== null; + return ( +
  • +
    +
    + {d.name} + {isOwn && ( + + {t('app:settings.devices.this_device', { defaultValue: 'Dieses Gerät' })} + + )} + {isRevoked && ( + + {t('app:settings.devices.revoked', { defaultValue: 'Abgemeldet' })} + + )} +
    +

    + {d.platform} · {t('app:settings.devices.last_seen', { defaultValue: 'zuletzt' })}{' '} + {formatLastSeen(d.lastSeenAt, i18n.language)} +

    +
    + {isOwn ? ( + + ) : isRevoked ? ( + + {t('app:settings.devices.already_revoked', { defaultValue: '—' })} + + ) : ( + + )} +
  • + ); + })} +
+
+ ); +} +``` + +- [ ] **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' && ( +
+ +
+ )} + ``` + +- [ ] **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 ( +
+
+

+ {t('app:auth.revoked_title', { defaultValue: 'Du wurdest remote abgemeldet' })} +

+

+ {t('app:auth.revoked_body', { + defaultValue: + 'Ein anderes deiner Geräte hat diesen Login beendet. Aus Sicherheitsgründen wurden alle lokalen Daten gelöscht.', + })} +

+ +
+
+ ); +} +``` + +- [ ] **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 `` block, alongside `` and `` (right before ``), add: + ```tsx + + ``` + +- [ ] **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` 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.