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

928 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 118120, 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 122132, 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.