Skip to content

Commit bf17c6b

Browse files
committed
feat: add QR login dialog and setup wizard for channel configuration
- Implemented QrLoginDialog component for QR code-based login. - Created SetupWizard component to guide users through channel setup. - Added TokenSetup component for token-based channel configuration. - Introduced useChannels and usePairing hooks for managing channel states and pairing requests. - Developed PairingBell component to handle device pairing notifications. - Enhanced UI with Select component for better user interaction. - Defined channel types and metadata in types.ts for better structure and maintainability.
1 parent 3352633 commit bf17c6b

17 files changed

Lines changed: 1948 additions & 5 deletions

CHANGELOG.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,25 @@
1919
#### Time Format Preference
2020
- 12h/24h toggle stored in `localStorage` via `useSyncExternalStore`; click the status bar clock to switch; applied to dashboard session timestamps
2121

22+
#### Channels `/channels`
23+
- **Channel Status Grid** — card per channel with connection status dot (green/yellow/red), account pills, last error display, auto-refresh every 30s
24+
- **Channel Settings** — DM policy (pairing/allow/deny) and Group policy (allow/mention/deny) selectors via `config.patch`
25+
- **Token Setup** — inline token input for Telegram, Discord, Slack with show/hide toggle; saves via `config.patch` → gateway auto-reconnects
26+
- **WhatsApp/Signal QR Login** — modal with `web.login.start` → QR code display → `web.login.wait` (120s timeout) → success/error feedback
27+
- **Device Pairing Management** — pending requests (approve/reject) + paired devices table (rotate token, revoke token, remove device) via `device.pair.*` and `device.token.*` APIs
28+
- **DM Pairing Queue** — contacts awaiting approval per channel; approve adds to `allowFrom` via `config.patch`
29+
- **Pairing Bell** — global bell icon in status bar with pending request count badge; popover dropdown with approve/reject actions; polls `device.pair.list` every 15s (visibility-aware); bounce animation on new requests
30+
- **Channel Setup Wizard** — 3-step dialog (Choose → Configure → Done); "Add Channel" button on page header; shows all 5 supported channels (Telegram, Discord, Slack, WhatsApp, Signal) with Configured/Needs setup badge; token input + QR trigger; post-setup checklist per channel with docs links
31+
- **Enable/Disable Toggle** — per-channel power button with confirm dialog; `config.patch` → root + account-level `enabled`; toast "gateway restarting…"; disabled cards at 60% opacity + "Disabled" label
32+
- New route: `/channels` with sidebar navigation
33+
- New UI primitive: `Select` component (`radix-ui` based)
34+
2235
### Changed
23-
- Dashboard page refactored from monolithic component into feature-based structure: `components/`, `hooks/`, `types.ts`
24-
- `MetricTile` extracted as reusable component — replaces inline tile function
36+
- Dashboard refactored from monolithic component into feature-based structure: `components/`, `hooks/`, `types.ts`
37+
- `MetricTile` extracted as reusable component
2538
- `SessionsCard` and `PresenceCard` extracted as standalone components
26-
- Status bar clock: `<div>``<button>` with `type="button"`clickable to toggle 12h/24h format
27-
- Status bar time formatting now respects the stored 12h/24h preference
39+
- Status bar clock clickable to toggle 12h/24h format; respects stored preference
40+
- Pairing Bell added to global status bar — always visible
2841

2942
---
3043

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { LogOut, Power, Settings } from 'lucide-react'
2+
import { useState } from 'react'
3+
import { toast } from 'sonner'
4+
import { ConfirmDialog } from '@/components/confirm-dialog'
5+
import { Badge } from '@/components/ui/badge'
6+
import { Button } from '@/components/ui/button'
7+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
8+
import type { GatewayClient } from '@/lib/gateway/client'
9+
import type { ChannelAccountSnapshot, ConfigSnapshot } from '@/lib/gateway/types'
10+
import { createLogger } from '@/lib/logger'
11+
import { cn } from '@/lib/utils'
12+
import { useGatewayStore } from '@/stores/gateway-store'
13+
import { CHANNEL_ICONS, CHANNEL_SETUP_TYPE } from '../types'
14+
import { ChannelSettings } from './channel-settings'
15+
import { QrLoginDialog } from './qr-login-dialog'
16+
import { TokenSetup } from './token-setup'
17+
18+
const log = createLogger('channel-card')
19+
20+
type Props = {
21+
channelId: string
22+
label: string
23+
accounts: ChannelAccountSnapshot[]
24+
client: GatewayClient | null
25+
onRefresh: () => void
26+
}
27+
28+
function accountStatus(a: ChannelAccountSnapshot): 'connected' | 'partial' | 'offline' {
29+
if (a.connected) return 'connected'
30+
if (a.running) return 'partial'
31+
return 'offline'
32+
}
33+
34+
const STATUS_DOT: Record<string, string> = {
35+
connected: 'bg-success',
36+
partial: 'bg-warning',
37+
offline: 'bg-destructive',
38+
}
39+
40+
const STATUS_LABEL: Record<string, string> = {
41+
connected: 'Connected',
42+
partial: 'Starting',
43+
offline: 'Offline',
44+
}
45+
46+
export function ChannelCard({ channelId, label, accounts, client, onRefresh }: Props) {
47+
const config = useGatewayStore((s) => s.config)
48+
const [showSettings, setShowSettings] = useState(false)
49+
const [showLogout, setShowLogout] = useState(false)
50+
const [showToggle, setShowToggle] = useState(false)
51+
const [logoutBusy, setLogoutBusy] = useState(false)
52+
const [toggleBusy, setToggleBusy] = useState(false)
53+
54+
const icon = CHANNEL_ICONS[channelId] ?? '📡'
55+
const setupType = CHANNEL_SETUP_TYPE[channelId]
56+
const isConfigured = accounts.some((a) => a.configured)
57+
const isEnabled = accounts.some((a) => a.enabled !== false)
58+
const connectedCount = accounts.filter((a) => a.connected).length
59+
const bestStatus = accounts.some((a) => a.connected)
60+
? 'connected'
61+
: accounts.some((a) => a.running)
62+
? 'partial'
63+
: 'offline'
64+
65+
const handleToggleEnabled = async () => {
66+
if (!client?.connected || !config) return
67+
setToggleBusy(true)
68+
const next = !isEnabled
69+
try {
70+
const channelPatch: Record<string, unknown> = { enabled: next }
71+
for (const a of accounts) {
72+
if (a.accountId) {
73+
channelPatch.accounts = {
74+
...((channelPatch.accounts as Record<string, unknown>) ?? {}),
75+
[a.accountId]: { enabled: next },
76+
}
77+
}
78+
}
79+
const patch = { channels: { [channelId]: channelPatch } }
80+
await client.request('config.patch', {
81+
raw: JSON.stringify(patch),
82+
baseHash: config.hash,
83+
restartDelayMs: 2000,
84+
})
85+
setShowToggle(false)
86+
toast.success(`${label} ${next ? 'enabled' : 'disabled'} — gateway restarting…`, { duration: 4000 })
87+
const freshConfig = await client.request<ConfigSnapshot>('config.get', {})
88+
useGatewayStore.getState().setConfig(freshConfig)
89+
await new Promise((r) => setTimeout(r, 2500))
90+
onRefresh()
91+
} catch (err) {
92+
toast.error('Toggle failed')
93+
log.error('Enable/disable failed', err)
94+
} finally {
95+
setToggleBusy(false)
96+
}
97+
}
98+
99+
const handleLogout = async () => {
100+
if (!client?.connected) return
101+
setLogoutBusy(true)
102+
try {
103+
await client.request('channels.logout', { channel: channelId })
104+
toast.success(`${label} logged out`)
105+
onRefresh()
106+
} catch (err) {
107+
toast.error('Logout failed')
108+
log.error('Channel logout failed', err)
109+
} finally {
110+
setLogoutBusy(false)
111+
setShowLogout(false)
112+
}
113+
}
114+
115+
return (
116+
<>
117+
<Card className={cn('border-border/50 bg-card/50 backdrop-blur-sm', !isEnabled && 'opacity-60')}>
118+
<CardHeader className="flex flex-row items-center gap-3 px-4 pb-2 pt-4">
119+
<span className="text-xl">{icon}</span>
120+
<div className="min-w-0 flex-1">
121+
<CardTitle className="text-sm font-semibold">{label}</CardTitle>
122+
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
123+
<span className={cn('h-2 w-2 rounded-full', STATUS_DOT[bestStatus])} />
124+
<span>{STATUS_LABEL[bestStatus]}</span>
125+
{!isConfigured && <span>· Not configured</span>}
126+
{!isEnabled && isConfigured && <span>· Disabled</span>}
127+
{accounts.length > 1 && (
128+
<span>
129+
· {connectedCount}/{accounts.length} accounts
130+
</span>
131+
)}
132+
</div>
133+
</div>
134+
<div className="flex shrink-0 gap-1">
135+
{isConfigured && (
136+
<Button
137+
variant="ghost"
138+
size="icon"
139+
className={cn('h-7 w-7', isEnabled ? 'text-success' : 'text-muted-foreground')}
140+
title={isEnabled ? 'Disable' : 'Enable'}
141+
disabled={toggleBusy}
142+
onClick={() => setShowToggle(true)}
143+
>
144+
<Power className="h-3.5 w-3.5" />
145+
</Button>
146+
)}
147+
<Button
148+
variant="ghost"
149+
size="icon"
150+
className="h-7 w-7"
151+
title="Settings"
152+
onClick={() => setShowSettings((v) => !v)}
153+
>
154+
<Settings className="h-3.5 w-3.5" />
155+
</Button>
156+
{bestStatus === 'connected' && (
157+
<Button
158+
variant="ghost"
159+
size="icon"
160+
className="h-7 w-7 text-destructive"
161+
title="Logout"
162+
onClick={() => setShowLogout(true)}
163+
>
164+
<LogOut className="h-3.5 w-3.5" />
165+
</Button>
166+
)}
167+
</div>
168+
</CardHeader>
169+
<CardContent className="px-4 pb-4">
170+
<div className="flex flex-wrap gap-1">
171+
{accounts.map((a) => {
172+
const s = accountStatus(a)
173+
return (
174+
<Badge
175+
key={a.accountId}
176+
variant="outline"
177+
className={cn(
178+
'text-[10px]',
179+
s === 'connected' && 'border-success/20 bg-success/10 text-success',
180+
s === 'partial' && 'border-warning/20 bg-warning/10 text-warning',
181+
s === 'offline' && 'border-destructive/20 bg-destructive/10 text-destructive',
182+
)}
183+
>
184+
{a.name ?? a.accountId}
185+
</Badge>
186+
)
187+
})}
188+
</div>
189+
190+
{accounts.some((a) => a.lastError) && (
191+
<div className="mt-2 truncate text-[10px] text-destructive">
192+
{accounts.find((a) => a.lastError)?.lastError}
193+
</div>
194+
)}
195+
196+
{showSettings && (
197+
<div className="mt-3 space-y-3 border-t border-border/50 pt-3">
198+
<ChannelSettings channelId={channelId} client={client} onRefresh={onRefresh} />
199+
{setupType === 'token' && (
200+
<TokenSetup channelId={channelId} label={label} client={client} onRefresh={onRefresh} />
201+
)}
202+
{setupType === 'qr' && <QrLoginDialog label={label} client={client} onRefresh={onRefresh} />}
203+
</div>
204+
)}
205+
</CardContent>
206+
</Card>
207+
208+
<ConfirmDialog
209+
open={showToggle}
210+
onOpenChange={setShowToggle}
211+
title={`${isEnabled ? 'Disable' : 'Enable'} ${label}`}
212+
description={
213+
isEnabled
214+
? `This will disable ${label} and restart the gateway. The channel will stop receiving messages.`
215+
: `This will enable ${label} and restart the gateway. The channel will start receiving messages.`
216+
}
217+
actionLabel={isEnabled ? 'Disable' : 'Enable'}
218+
variant={isEnabled ? 'destructive' : 'default'}
219+
loading={toggleBusy}
220+
onConfirm={() => void handleToggleEnabled()}
221+
/>
222+
223+
<ConfirmDialog
224+
open={showLogout}
225+
onOpenChange={setShowLogout}
226+
title={`Logout ${label}`}
227+
description={`This will disconnect ${label} and clear its session. You may need to re-authenticate.`}
228+
actionLabel="Logout"
229+
variant="destructive"
230+
loading={logoutBusy}
231+
onConfirm={() => void handleLogout()}
232+
/>
233+
</>
234+
)
235+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { useState } from 'react'
2+
import { toast } from 'sonner'
3+
import { Label } from '@/components/ui/label'
4+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
5+
import type { GatewayClient } from '@/lib/gateway/client'
6+
import type { ConfigSnapshot } from '@/lib/gateway/types'
7+
import { createLogger } from '@/lib/logger'
8+
import { useGatewayStore } from '@/stores/gateway-store'
9+
10+
const log = createLogger('channel-settings')
11+
12+
const DM_POLICIES = ['pairing', 'allow', 'deny'] as const
13+
const GROUP_POLICIES = ['allow', 'mention', 'deny'] as const
14+
15+
type Props = {
16+
channelId: string
17+
client: GatewayClient | null
18+
onRefresh: () => void
19+
}
20+
21+
export function ChannelSettings({ channelId, client, onRefresh }: Props) {
22+
const config = useGatewayStore((s) => s.config)
23+
const [busy, setBusy] = useState(false)
24+
25+
const channelConfig = ((config?.config as Record<string, unknown>)?.channels as Record<string, unknown>)?.[
26+
channelId
27+
] as Record<string, unknown> | undefined
28+
29+
const currentDm = (channelConfig?.dmPolicy as string) ?? 'pairing'
30+
const currentGroup = (channelConfig?.groupPolicy as string) ?? 'allow'
31+
32+
const setPolicy = async (field: 'dmPolicy' | 'groupPolicy', value: string) => {
33+
if (!client?.connected || !config) return
34+
setBusy(true)
35+
try {
36+
const patch = { channels: { [channelId]: { [field]: value } } }
37+
await client.request('config.patch', {
38+
raw: JSON.stringify(patch),
39+
baseHash: config.hash,
40+
})
41+
const freshConfig = await client.request<ConfigSnapshot>('config.get', {})
42+
useGatewayStore.getState().setConfig(freshConfig)
43+
toast.success(`${field === 'dmPolicy' ? 'DM' : 'Group'} policy updated`)
44+
onRefresh()
45+
} catch (err) {
46+
toast.error('Policy update failed')
47+
log.error('Policy update failed', err)
48+
} finally {
49+
setBusy(false)
50+
}
51+
}
52+
53+
return (
54+
<div className="grid grid-cols-2 gap-3">
55+
<div className="space-y-1">
56+
<Label className="text-[10px] text-muted-foreground">DM Policy</Label>
57+
<Select value={currentDm} disabled={busy} onValueChange={(v) => void setPolicy('dmPolicy', v)}>
58+
<SelectTrigger className="h-7 text-xs">
59+
<SelectValue />
60+
</SelectTrigger>
61+
<SelectContent>
62+
{DM_POLICIES.map((p) => (
63+
<SelectItem key={p} value={p} className="text-xs">
64+
{p}
65+
</SelectItem>
66+
))}
67+
</SelectContent>
68+
</Select>
69+
</div>
70+
<div className="space-y-1">
71+
<Label className="text-[10px] text-muted-foreground">Group Policy</Label>
72+
<Select value={currentGroup} disabled={busy} onValueChange={(v) => void setPolicy('groupPolicy', v)}>
73+
<SelectTrigger className="h-7 text-xs">
74+
<SelectValue />
75+
</SelectTrigger>
76+
<SelectContent>
77+
{GROUP_POLICIES.map((p) => (
78+
<SelectItem key={p} value={p} className="text-xs">
79+
{p}
80+
</SelectItem>
81+
))}
82+
</SelectContent>
83+
</Select>
84+
</div>
85+
</div>
86+
)
87+
}

0 commit comments

Comments
 (0)