34 KiB
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
devicestable with arevoked_atcolumn + arevoke_device(p_device_id uuid)SECURITY DEFINER RPC. Publishdevicestosupabase_realtimeso every signed-in client gets postgres_changes events for its own rows. - Client: on each post-sign-in startup, "ensure" exactly one
devicesrow exists for this install (registered the first time, then re-used). Subscribe to owndevicesfor revoked_at flips → forcesignOut()(which already chains throughwipeLocalState). 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
RemoteRevokedScreenis rendered as a top-level overlay (insideAuthProvider) wheneverrevokedRemotelyis 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 includerevoked_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
listOwnDevicesto 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 +revokedRemotelystate -
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
revokedRemotelyto the AuthContext value + wire ensure-device effect
Edit apps/desktop/src/context/AuthContext.tsx:
-
Extend the
AuthContextValueinterface with:revokedRemotely: boolean; acknowledgeRevocation: () => void; -
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'; -
Add a
const [revokedRemotely, setRevokedRemotely] = useState(false);next to the otheruseStates inAuthProvider. -
Add
acknowledgeRevocationas auseCallbackthat just callssetRevokedRemotely(false). Include it in thevaluememo + the deps array. -
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]); -
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
signOutis defined (which is theuseCallbackat line ~251). If a hoisting/order issue makes that awkward, define asignOutRef = useRef<() => Promise<void>>()updated by an effect and callsignOutRef.current?.()inside the realtime callback — but try the direct approach first. -
Add
revokedRemotely+acknowledgeRevocationto thevalueobject + deps array of itsuseMemo. -
Do NOT reset
revokedRemotelyinsignOutitself — the flag is only ever set by the realtime callback, andacknowledgeRevocationis 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:
-
At the top with other imports, add:
import { DeviceListTab } from '../components/settings/DeviceListTab';(Match the path style of nearby
../components/...imports.) -
Around line 118–120, extend the
TabIdunion to include'devices':type TabId = | 'profile' | 'appearance' | 'privacy' | 'notifications' | 'voice' | 'screen-share' | 'soundboard' | 'security' | 'devices' | 'account'; -
In the
tabsarray around line 122–132, insert a new entry between'security'and'account':{ id: 'devices', label: t('app:settings.nav_devices', { defaultValue: 'Geräte' }), Icon: MonitorShareIcon },MonitorShareIconis already imported (line 128 uses it for screen-share). If that feels wrong stylistically, use an existing alternative such asLockIconor whichever icon set the file uses — but do NOT add a new icon import; pick from what's already imported. -
In the content panel (after the
activeTab === 'security'block at line ~344 and before theactiveTab === '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 insideAuthProvider -
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:
-
Add the import at the top with other component imports:
import { RemoteRevokedScreen } from './components/RemoteRevokedScreen'; -
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)
-
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
-
Placeholders: none — every step has concrete code.
-
Type consistency:
DeviceRecord.revokedAt: string | nullintroduced in T2; consumed by T5 (hook) and T6 (UI) consistentlyrevokeDevice(client, deviceId): Promise<void>defined T2, used T5 (via hook) and indirectly T6 (via hook'srevoke)getDeviceRowId(): string | nulldefined T4, used in T4 (AuthContext) + T6 (DeviceListTab)revokedRemotely+acknowledgeRevocationadded to AuthContext value in T4, consumed by T7
-
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 !== ownIdearly return. -
Memory-wipe interaction: the
chatapp.deviceRowId.v1key is intentionally NOT added toPRESERVE_LOCAL_STORAGE, so a memory-wipe gets a fresh device row on next sign-in. That matches the spirit of memory-wipe = fresh install.