Files
dchain/client-app/app/(app)/devices.tsx
vsecoder 8940b97cc6 feat(client): Devices screen + revoke self-wipe (v2.2.0-alpha3 wip)
Part of PR #3. Pairing flow still to come.

Devices screen — app/(app)/devices.tsx:
  * Lists every active device from /api/devices/{self}.
  * "THIS DEVICE" badge on our own row, Unlink button on every other.
  * Unlink confirms + submits UNLINK_DEVICE tx, optimistic local removal.
  * Pull-to-refresh; empty state when balance is too low for auto-link.
  * Placeholder row for "Link new device" — wired in next commit.

Settings → Devices entry row: added under a new "Devices" section.

Self-wipe on revoke — lib/storage.ts + app/(app)/_layout.tsx:
  * New AsyncStorage marker `dchain_device_registered` tracks whether
    this install ever made it into the on-chain registry.
  * wipeAllLocalState() zeroes secure-store key + contacts + settings +
    chats cache + marker. Safe-idempotent.
  * Bootstrap effect in app layout splits three branches by
    (our_pub in chain's active list × marker_set):
      - in list      → mark registered, done.
      - not in list + was registered → REVOKED → wipe + redirect to auth.
      - not in list + never registered → first boot, LINK_DEVICE.
  * Network errors never trigger wipe — only an explicit "pub missing
    from chain response" decides it. Belt-and-suspenders against a
    misbehaving node spuriously dropping records.

Next: pairing flow so a second device (desktop, tablet, new phone)
can come online, show a 6-digit code, receive master priv via a
one-shot relay envelope encrypted to its fresh device X25519 pub,
then self-link.
2026-04-22 16:28:16 +03:00

259 lines
9.6 KiB
TypeScript

/**
* Devices screen — Settings → Linked devices.
*
* Multi-device registry (v2.2.0). Lists every X25519 device published
* on-chain under this identity's master Ed25519 key. Operators can:
* - see added-at timestamps
* - rename this device (local alias for now; rename via LINK_DEVICE
* with same pub + new name is a v2.3 polish)
* - revoke a remote device via UNLINK_DEVICE (requires fee)
* - pair a new device (Phase 3 — separate modal, stub for now)
*
* This device is NEVER listed with an Unlink button — revoking yourself
* is a footgun (you'd wipe your own state on next launch). Export/import
* your key first, then revoke from the new device.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
View, Text, ScrollView, Pressable, ActivityIndicator, Alert, RefreshControl,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import {
fetchDevices, buildUnlinkDeviceTx, submitTx, humanizeTxError,
type DeviceInfo,
} from '@/lib/api';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { safeBack } from '@/lib/utils';
function shortPub(p: string, n = 8): string {
if (!p) return '—';
return p.length <= n * 2 + 1 ? p : `${p.slice(0, n)}${p.slice(-n)}`;
}
function formatDate(unixSec: number): string {
return new Date(unixSec * 1000).toLocaleString();
}
export default function DevicesScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const [devices, setDevices] = useState<DeviceInfo[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [unlinking, setUnlinking] = useState<string | null>(null); // pub being revoked
const load = useCallback(async (isRefresh = false) => {
if (!keyFile) return;
if (isRefresh) setRefreshing(true);
else setLoading(true);
try {
const list = await fetchDevices(keyFile.pub_key);
setDevices(list);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [keyFile]);
useEffect(() => { load(false); }, [load]);
const onUnlink = useCallback((dev: DeviceInfo) => {
if (!keyFile) return;
Alert.alert(
'Unlink device?',
`"${dev.device_name}" will stop receiving messages sent to you. ` +
`This costs a small network fee. The revoked device wipes its ` +
`local state the next time it checks in.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Unlink',
style: 'destructive',
onPress: async () => {
setUnlinking(dev.x25519_pub_key);
try {
const tx = buildUnlinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: dev.x25519_pub_key,
privKey: keyFile.priv_key,
});
await submitTx(tx);
// Optimistic — drop from local list immediately; next load
// reconciles. Chain tx takes ~1 block to commit.
setDevices(prev => prev.filter(d => d.x25519_pub_key !== dev.x25519_pub_key));
} catch (e: any) {
Alert.alert('Unlink failed', humanizeTxError(e));
} finally {
setUnlinking(null);
}
},
},
],
);
}, [keyFile]);
const meX25519 = keyFile?.x25519_pub ?? '';
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Devices"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => load(true)}
tintColor="#1d9bf0"
/>
}
>
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 14 }}>
Every linked device has its own encryption key. Messages sent to you
are delivered to all active devices.
</Text>
{loading ? (
<View style={{ paddingTop: 60, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : devices.length === 0 ? (
<View style={{
paddingTop: 60, alignItems: 'center', paddingHorizontal: 24,
}}>
<Ionicons name="phone-portrait-outline" size={36} color="#3a3a3a" />
<Text style={{
color: '#ffffff', fontSize: 15, fontWeight: '700',
marginTop: 10,
}}>
No devices registered yet
</Text>
<Text style={{
color: '#8b8b8b', fontSize: 13, textAlign: 'center',
marginTop: 6, lineHeight: 19,
}}>
This device auto-registers when the next network-fee is available.
Top up your balance and pull to refresh.
</Text>
</View>
) : (
<View style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}>
{devices.map((d, i) => {
const isMe = d.x25519_pub_key === meX25519;
const busy = unlinking === d.x25519_pub_key;
return (
<View key={d.x25519_pub_key}>
{i > 0 && <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />}
<View style={{
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 14, paddingVertical: 14, gap: 12,
}}>
<Ionicons
name={isMe ? 'phone-portrait' : 'phone-portrait-outline'}
size={22}
color={isMe ? '#1d9bf0' : '#d0d0d0'}
/>
<View style={{ flex: 1, minWidth: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}
numberOfLines={1}
>
{d.device_name || 'Unnamed device'}
</Text>
{isMe && (
<View style={{
paddingHorizontal: 6, paddingVertical: 1,
borderRadius: 6, backgroundColor: '#0d2540',
}}>
<Text style={{ color: '#1d9bf0', fontSize: 10, fontWeight: '700' }}>
THIS DEVICE
</Text>
</View>
)}
</View>
<Text
style={{
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
marginTop: 3,
}}
numberOfLines={1}
>
{shortPub(d.x25519_pub_key)}
</Text>
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
Linked {formatDate(d.added_at)}
</Text>
</View>
{!isMe && (
<Pressable
onPress={() => onUnlink(d)}
disabled={busy}
style={({ pressed }) => ({
paddingHorizontal: 12, paddingVertical: 7,
borderRadius: 999,
borderWidth: 1, borderColor: '#3a2020',
backgroundColor: pressed ? '#2a1414' : 'transparent',
opacity: busy ? 0.5 : 1,
})}
>
{busy ? (
<ActivityIndicator size="small" color="#ff6b6b" />
) : (
<Text style={{ color: '#ff6b6b', fontSize: 12, fontWeight: '700' }}>
Unlink
</Text>
)}
</Pressable>
)}
</View>
</View>
);
})}
</View>
)}
{/* Pair new device — stub for v2.2.0-alpha3 pairing flow. Disabled
until the QR protocol lands; left visible so operators know
where the entry point will live. */}
<View style={{ marginTop: 18 }}>
<Pressable
disabled
style={{
paddingVertical: 13, paddingHorizontal: 16,
borderRadius: 14,
borderWidth: 1, borderColor: '#1f1f1f',
flexDirection: 'row', alignItems: 'center', gap: 10,
opacity: 0.5,
}}
>
<Ionicons name="qr-code-outline" size={18} color="#8b8b8b" />
<View style={{ flex: 1 }}>
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
Link new device
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
Pairing flow coming in v2.2.0-alpha3
</Text>
</View>
</Pressable>
</View>
</ScrollView>
</View>
);
}