mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
feat: network interface selector for QR pairing and UI polish
- Add network interface selector so users can choose LAN or overlay network (Tailscale, ZeroTier) address for QR code pairing - Fall back to OS-assigned port when preferred port is in use - Filter paired devices list to only show actually connected devices - Poll device list after QR generation until phone connects - Mark device lastSeenAt on first WebSocket connection - Make CustomKeyModal toggle and button monochrome (remove blue accent) - Make clear filters text and group options monochrome Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -85,8 +85,8 @@ const SORT_OPTIONS: PickerOption<SortMode>[] = [
|
||||
|
||||
const GROUP_OPTIONS: PickerOption<GroupMode>[] = [
|
||||
{ value: 'none', label: 'No Grouping' },
|
||||
{ value: 'repo', label: 'By Repository' },
|
||||
{ value: 'prStatus', label: 'By PR Status', subtitle: 'Linked PR vs none' }
|
||||
{ value: 'repo', label: 'Repository' },
|
||||
{ value: 'prStatus', label: 'PR Status' }
|
||||
]
|
||||
|
||||
function getWorktreeStatus(w: Worktree): 'working' | 'active' | 'permission' | 'done' | 'inactive' {
|
||||
@@ -1213,7 +1213,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
clearFiltersText: {
|
||||
fontSize: 13,
|
||||
color: colors.accentBlue
|
||||
color: colors.textSecondary
|
||||
},
|
||||
filterSectionLabel: {
|
||||
fontSize: 11,
|
||||
|
||||
@@ -193,7 +193,8 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
|
||||
<Switch
|
||||
value={macroEnter}
|
||||
onValueChange={setMacroEnter}
|
||||
trackColor={{ false: colors.bgRaised, true: colors.accentBlue }}
|
||||
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
<Pressable
|
||||
@@ -321,7 +322,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.textPrimary
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.accentBlue,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button,
|
||||
alignItems: 'center'
|
||||
@@ -330,7 +331,7 @@ const styles = StyleSheet.create({
|
||||
opacity: 0.5
|
||||
},
|
||||
saveButtonText: {
|
||||
color: '#fff',
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
|
||||
+41
-16
@@ -4,22 +4,34 @@ import QRCode from 'qrcode'
|
||||
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing'
|
||||
|
||||
export type NetworkInterface = {
|
||||
name: string
|
||||
address: string
|
||||
}
|
||||
|
||||
// Why: the WebSocket transport advertises 0.0.0.0 as its endpoint, which isn't
|
||||
// connectable from a mobile device. We resolve the first non-internal IPv4
|
||||
// address so the QR code contains a reachable LAN IP.
|
||||
function getLanAddress(): string | null {
|
||||
// connectable from a mobile device. We enumerate all non-internal IPv4
|
||||
// addresses so the user can choose which one to advertise in the QR code
|
||||
// (e.g. LAN vs Tailscale).
|
||||
function getNetworkInterfaces(): NetworkInterface[] {
|
||||
const result: NetworkInterface[] = []
|
||||
const interfaces = networkInterfaces()
|
||||
for (const addrs of Object.values(interfaces)) {
|
||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||
if (!addrs) {
|
||||
continue
|
||||
}
|
||||
for (const addr of addrs) {
|
||||
if (addr.family === 'IPv4' && !addr.internal) {
|
||||
return addr.address
|
||||
result.push({ name, address: addr.address })
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
return result
|
||||
}
|
||||
|
||||
function getLanAddress(): string | null {
|
||||
const ifaces = getNetworkInterfaces()
|
||||
return ifaces.length > 0 ? ifaces[0]!.address : null
|
||||
}
|
||||
|
||||
// Why: the mobile IPC handlers provide the renderer with QR code pairing data,
|
||||
@@ -27,18 +39,25 @@ function getLanAddress(): string | null {
|
||||
// OrcaRuntimeRpcServer because it owns the device registry and TLS state.
|
||||
|
||||
export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
|
||||
ipcMain.handle('mobile:getPairingQR', async () => {
|
||||
ipcMain.handle('mobile:listNetworkInterfaces', (): { interfaces: NetworkInterface[] } => ({
|
||||
interfaces: getNetworkInterfaces()
|
||||
}))
|
||||
|
||||
ipcMain.handle('mobile:getPairingQR', async (_event, args?: { address?: string }) => {
|
||||
const rawEndpoint = rpcServer.getWebSocketEndpoint()
|
||||
const registry = rpcServer.getDeviceRegistry()
|
||||
if (!rawEndpoint || !registry) {
|
||||
return { available: false as const }
|
||||
}
|
||||
|
||||
const lanIp = getLanAddress()
|
||||
if (!lanIp) {
|
||||
// Why: allow the caller to specify which network interface address to
|
||||
// embed in the QR code. This supports overlay networks (Tailscale,
|
||||
// ZeroTier) where the default LAN IP isn't reachable from the phone.
|
||||
const ip = args?.address ?? getLanAddress()
|
||||
if (!ip) {
|
||||
return { available: false as const }
|
||||
}
|
||||
const endpoint = rawEndpoint.replace('0.0.0.0', lanIp)
|
||||
const endpoint = rawEndpoint.replace('0.0.0.0', ip)
|
||||
|
||||
const device = registry.addDevice(`Mobile ${new Date().toLocaleDateString()}`)
|
||||
|
||||
@@ -73,13 +92,19 @@ export function registerMobileHandlers(rpcServer: OrcaRuntimeRpcServer): void {
|
||||
if (!registry) {
|
||||
return { devices: [] }
|
||||
}
|
||||
// Why: devices with lastSeenAt === 0 were created during QR generation
|
||||
// but never actually scanned/connected. Showing them as "paired" is
|
||||
// misleading, so we filter them out.
|
||||
return {
|
||||
devices: registry.listDevices().map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
name: d.name,
|
||||
pairedAt: d.pairedAt,
|
||||
lastSeenAt: d.lastSeenAt
|
||||
}))
|
||||
devices: registry
|
||||
.listDevices()
|
||||
.filter((d) => d.lastSeenAt > 0)
|
||||
.map((d) => ({
|
||||
deviceId: d.deviceId,
|
||||
name: d.name,
|
||||
pairedAt: d.pairedAt,
|
||||
lastSeenAt: d.lastSeenAt
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -229,4 +229,28 @@ describe('WebSocketTransport', () => {
|
||||
const { transport } = await createTransport()
|
||||
await transport.stop()
|
||||
})
|
||||
|
||||
it('falls back to OS-assigned port when preferred port is in use', async () => {
|
||||
const { transport: first, port } = await createTransport()
|
||||
await first.start()
|
||||
|
||||
// Why: second transport requests the same port, which is now occupied.
|
||||
// It should silently fall back to an OS-assigned port instead of throwing.
|
||||
const tls = makeTls()
|
||||
const second = new WebSocketTransport({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
tlsCert: tls.cert,
|
||||
tlsKey: tls.key
|
||||
})
|
||||
transports.push(second)
|
||||
|
||||
await second.start()
|
||||
|
||||
expect(second.resolvedPort).not.toBe(port)
|
||||
expect(second.resolvedPort).toBeGreaterThan(0)
|
||||
|
||||
const ws = await connectWs(second.resolvedPort)
|
||||
ws.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,13 +70,43 @@ export class WebSocketTransport implements RpcTransport {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: React Native's built-in WebSocket cannot disable TLS verification
|
||||
// for self-signed certificates. Until we implement certificate pinning on
|
||||
// the mobile side, use plain ws:// which still has per-device token auth.
|
||||
const httpServer =
|
||||
this.tlsCert && this.tlsKey
|
||||
? createHttpsServer({ cert: this.tlsCert, key: this.tlsKey })
|
||||
: createHttpServer()
|
||||
// Why: when the preferred port is occupied (e.g. another Orca instance is
|
||||
// already running), fall back to an OS-assigned port so mobile pairing
|
||||
// still works. The QR code reads resolvedPort after start, so it will
|
||||
// advertise the correct port regardless.
|
||||
let port = this.port
|
||||
try {
|
||||
await this.tryListen(port)
|
||||
} catch (error: unknown) {
|
||||
if (isEAddressInUse(error) && port !== 0) {
|
||||
console.warn(`[ws-transport] Port ${port} is in use, falling back to OS-assigned port`)
|
||||
port = 0
|
||||
await this.tryListen(port)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createHttpServer(): HttpServer | HttpsServer {
|
||||
return this.tlsCert && this.tlsKey
|
||||
? createHttpsServer({ cert: this.tlsCert, key: this.tlsKey })
|
||||
: createHttpServer()
|
||||
}
|
||||
|
||||
// Why: the WebSocketServer is attached only after listen succeeds. If we
|
||||
// attached it before, the WSS would re-emit the EADDRINUSE error from the
|
||||
// httpServer as an uncatchable exception, preventing the fallback from working.
|
||||
private async tryListen(port: number): Promise<void> {
|
||||
const httpServer = this.createHttpServer()
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.once('error', reject)
|
||||
httpServer.listen(port, this.host, () => {
|
||||
httpServer.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
server: httpServer,
|
||||
@@ -91,14 +121,6 @@ export class WebSocketTransport implements RpcTransport {
|
||||
this.handleConnection(ws)
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.once('error', reject)
|
||||
httpServer.listen(this.port, this.host, () => {
|
||||
httpServer.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
this.httpServer = httpServer
|
||||
this.wss = wss
|
||||
}
|
||||
@@ -165,3 +187,7 @@ export class WebSocketTransport implements RpcTransport {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isEAddressInUse(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'EADDRINUSE'
|
||||
}
|
||||
|
||||
@@ -142,6 +142,13 @@ export class OrcaRuntimeRpcServer {
|
||||
onReady: (ch) => {
|
||||
if (ch.deviceToken) {
|
||||
wsTransport.setClientId(ws, ch.deviceToken)
|
||||
// Why: mark the device as actually connected so it appears
|
||||
// in the "Paired Devices" list. Devices that were only
|
||||
// generated as QR codes but never scanned stay hidden.
|
||||
const device = this.deviceRegistry?.validateToken(ch.deviceToken)
|
||||
if (device) {
|
||||
this.deviceRegistry?.updateLastSeen(device.deviceId)
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (code, reason) => {
|
||||
|
||||
@@ -952,7 +952,12 @@ export type PreloadApi = {
|
||||
) => () => void
|
||||
}
|
||||
mobile: {
|
||||
getPairingQR: () => Promise<
|
||||
listNetworkInterfaces: () => Promise<{
|
||||
interfaces: { name: string; address: string }[]
|
||||
}>
|
||||
getPairingQR: (args?: {
|
||||
address?: string
|
||||
}) => Promise<
|
||||
| { available: false }
|
||||
| { available: true; qrDataUrl: string; endpoint: string; deviceId: string }
|
||||
>
|
||||
|
||||
@@ -1792,10 +1792,16 @@ const api = {
|
||||
},
|
||||
|
||||
mobile: {
|
||||
getPairingQR: (): Promise<
|
||||
listNetworkInterfaces: (): Promise<{
|
||||
interfaces: { name: string; address: string }[]
|
||||
}> => ipcRenderer.invoke('mobile:listNetworkInterfaces'),
|
||||
|
||||
getPairingQR: (args?: {
|
||||
address?: string
|
||||
}): Promise<
|
||||
| { available: false }
|
||||
| { available: true; qrDataUrl: string; endpoint: string; deviceId: string }
|
||||
> => ipcRenderer.invoke('mobile:getPairingQR'),
|
||||
> => ipcRenderer.invoke('mobile:getPairingQR', args),
|
||||
|
||||
listDevices: (): Promise<{
|
||||
devices: { deviceId: string; name: string; pairedAt: number; lastSeenAt: number }[]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Maximize2, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { Maximize2, RefreshCw, Trash2, Wifi } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
|
||||
export const MOBILE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
@@ -15,6 +16,11 @@ export const MOBILE_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
title: 'Connected Devices',
|
||||
description: 'Manage paired mobile devices.',
|
||||
keywords: ['mobile', 'devices', 'revoke', 'paired', 'connected']
|
||||
},
|
||||
{
|
||||
title: 'Network Interface',
|
||||
description: 'Choose which network address to use for mobile pairing.',
|
||||
keywords: ['network', 'interface', 'tailscale', 'vpn', 'overlay', 'ip', 'address', 'wifi']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -25,12 +31,19 @@ type PairedDevice = {
|
||||
lastSeenAt: number
|
||||
}
|
||||
|
||||
type NetworkInterface = {
|
||||
name: string
|
||||
address: string
|
||||
}
|
||||
|
||||
export function MobilePane(): React.JSX.Element {
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
|
||||
const [endpoint, setEndpoint] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [devices, setDevices] = useState<PairedDevice[]>([])
|
||||
const [qrEnlarged, setQrEnlarged] = useState(false)
|
||||
const [networkInterfaces, setNetworkInterfaces] = useState<NetworkInterface[]>([])
|
||||
const [selectedAddress, setSelectedAddress] = useState<string | undefined>(undefined)
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try {
|
||||
@@ -41,10 +54,24 @@ export function MobilePane(): React.JSX.Element {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadNetworkInterfaces = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.api.mobile.listNetworkInterfaces()
|
||||
setNetworkInterfaces(result.interfaces)
|
||||
if (result.interfaces.length > 0 && !selectedAddress) {
|
||||
setSelectedAddress(result.interfaces[0]!.address)
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}, [selectedAddress])
|
||||
|
||||
const generateQR = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.api.mobile.getPairingQR()
|
||||
const result = await window.api.mobile.getPairingQR(
|
||||
selectedAddress ? { address: selectedAddress } : undefined
|
||||
)
|
||||
if (result.available) {
|
||||
setQrDataUrl(result.qrDataUrl)
|
||||
setEndpoint(result.endpoint)
|
||||
@@ -57,11 +84,31 @@ export function MobilePane(): React.JSX.Element {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loadDevices])
|
||||
}, [loadDevices, selectedAddress])
|
||||
|
||||
useEffect(() => {
|
||||
void loadDevices()
|
||||
}, [loadDevices])
|
||||
void loadNetworkInterfaces()
|
||||
}, [loadDevices, loadNetworkInterfaces])
|
||||
|
||||
// Why: after generating a QR code the device only appears once the phone
|
||||
// actually connects (lastSeenAt > 0). Poll until a new device shows up.
|
||||
const [deviceCountAtQr, setDeviceCountAtQr] = useState<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (!qrDataUrl) {
|
||||
setDeviceCountAtQr(null)
|
||||
return
|
||||
}
|
||||
setDeviceCountAtQr(devices.length)
|
||||
}, [qrDataUrl]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (deviceCountAtQr === null || devices.length > deviceCountAtQr) {
|
||||
return
|
||||
}
|
||||
const interval = setInterval(() => void loadDevices(), 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [deviceCountAtQr, devices.length, loadDevices])
|
||||
|
||||
async function revokeDevice(deviceId: string) {
|
||||
try {
|
||||
@@ -73,125 +120,108 @@ export function MobilePane(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
function formatInterfaceLabel(iface: NetworkInterface): string {
|
||||
return `${iface.address} (${iface.name})`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{qrDataUrl ? (
|
||||
<div className="flex items-start gap-8">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQrEnlarged(true)}
|
||||
className="group relative cursor-pointer rounded-lg border border-border/60 bg-white p-3"
|
||||
>
|
||||
<img src={qrDataUrl} alt="QR Code for mobile pairing" className="size-48" />
|
||||
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => void generateQR()}
|
||||
disabled={loading}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
Regenerate
|
||||
</Button>
|
||||
</div>
|
||||
{endpoint && (
|
||||
<span className="text-muted-foreground font-mono text-xs">{endpoint}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pt-0.5">
|
||||
<h3 className="mb-2 text-sm font-medium">Paired Devices</h3>
|
||||
{devices.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No devices paired yet. Scan the QR code with the Orca mobile app.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{devices.map((device) => (
|
||||
<div
|
||||
key={device.deviceId}
|
||||
className="flex items-center justify-between rounded-lg border border-border/60 px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{device.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Paired {new Date(device.pairedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void revokeDevice(device.deviceId)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-4 text-xs">
|
||||
Each QR code creates a unique device token. Revoking a device disconnects it
|
||||
immediately.
|
||||
</p>
|
||||
</div>
|
||||
{/* Network interface selector + generate */}
|
||||
<div className="rounded-lg border border-border/60 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Wifi className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Network Interface</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border border-border/60 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Pair a new device</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Generate a QR code and scan it with the Orca mobile app to connect.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => void generateQR()}
|
||||
disabled={loading}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
Generate QR Code
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
Choose which network address to advertise in the QR code. Use your LAN address for
|
||||
same-network pairing, or an overlay network address (Tailscale, ZeroTier) for
|
||||
cross-network access.
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={selectedAddress} onValueChange={setSelectedAddress}>
|
||||
<SelectTrigger size="sm" className="min-w-[220px]">
|
||||
<SelectValue placeholder="No interfaces found" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{networkInterfaces.map((iface) => (
|
||||
<SelectItem key={`${iface.name}-${iface.address}`} value={iface.address}>
|
||||
{formatInterfaceLabel(iface)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={() => void generateQR()}
|
||||
disabled={loading || !selectedAddress}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw className={`size-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
{qrDataUrl ? 'Regenerate' : 'Generate QR Code'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">Paired Devices</h3>
|
||||
{devices.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">No devices paired yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{devices.map((device) => (
|
||||
<div
|
||||
key={device.deviceId}
|
||||
className="flex items-center justify-between rounded-lg border border-border/60 px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{device.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Paired {new Date(device.pairedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void revokeDevice(device.deviceId)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* QR code display */}
|
||||
{qrDataUrl && (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border border-border/60 py-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQrEnlarged(true)}
|
||||
className="group relative cursor-pointer rounded-lg border border-border/60 bg-white p-3"
|
||||
>
|
||||
<img src={qrDataUrl} alt="QR Code for mobile pairing" className="size-48" />
|
||||
<Maximize2 className="absolute top-1.5 right-1.5 size-3 text-black/30 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
{endpoint && <span className="text-muted-foreground font-mono text-xs">{endpoint}</span>}
|
||||
<p className="text-muted-foreground max-w-xs text-center text-xs">
|
||||
Scan this code with the Orca mobile app. Each code creates a unique device token.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paired devices */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">Paired Devices</h3>
|
||||
{devices.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{qrDataUrl
|
||||
? 'No devices paired yet. Scan the QR code with the Orca mobile app.'
|
||||
: 'No devices paired yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{devices.map((device) => (
|
||||
<div
|
||||
key={device.deviceId}
|
||||
className="flex items-center justify-between rounded-lg border border-border/60 px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{device.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Paired {new Date(device.pairedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void revokeDevice(device.deviceId)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{devices.length > 0 && (
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
Revoking a device disconnects it immediately.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enlarged QR dialog */}
|
||||
<Dialog open={qrEnlarged} onOpenChange={setQrEnlarged}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
|
||||
Reference in New Issue
Block a user