feat(desktop): add QR scan login for channels

This commit is contained in:
zhayujie
2026-06-21 17:27:18 +08:00
parent 214dcaf141
commit e1e29b32e9
3 changed files with 523 additions and 104 deletions

View File

@@ -0,0 +1,222 @@
import React, { useEffect, useRef, useState } from 'react'
import { Loader2, Check, RotateCcw } from 'lucide-react'
import { t } from '../i18n'
import apiClient from '../api/client'
import { Modal } from '../pages/settings/primitives'
type Provider = 'weixin' | 'feishu'
type Phase = 'loading' | 'waiting' | 'scanned' | 'success' | 'error'
interface QrLoginModalProps {
provider: Provider
onClose: () => void
// Fired once the channel is connected so the page can refresh.
onConnected: () => void
}
const POLL_INTERVAL = 2000
// Shared QR-login / QR-register modal for WeChat and Feishu. Mirrors the web
// console flow: fetch a QR, poll status, then connect the channel on success.
const QrLoginModal: React.FC<QrLoginModalProps> = ({ provider, onClose, onConnected }) => {
const [phase, setPhase] = useState<Phase>('loading')
const [qr, setQr] = useState('')
const [openLink, setOpenLink] = useState('')
const [errMsg, setErrMsg] = useState('')
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const aliveRef = useRef(true)
const stopPoll = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
const fail = (msg: string) => {
if (!aliveRef.current) return
setPhase('error')
setErrMsg(msg)
}
// ---- WeChat: GET qr, POST poll {scaned|confirmed|expired} -----------------
const pollWeixin = () => {
timerRef.current = setTimeout(async () => {
if (!aliveRef.current) return
try {
const data = await apiClient.weixinQrAction('poll')
if (!aliveRef.current) return
if (data.status !== 'success') return pollWeixin()
const s = data.qr_status as string
if (s === 'confirmed') {
setPhase('success')
await apiClient.channelAction('connect', 'weixin', {})
if (aliveRef.current) onConnected()
} else if (s === 'expired' && (data.qr_image || data.qrcode_url)) {
setQr((data.qr_image as string) || (data.qrcode_url as string))
setPhase('waiting')
pollWeixin()
} else if (s === 'scaned') {
setPhase('scanned')
pollWeixin()
} else {
pollWeixin()
}
} catch {
pollWeixin()
}
}, POLL_INTERVAL)
}
const startWeixin = async () => {
setPhase('loading')
try {
const data = await apiClient.getWeixinQr()
if (!aliveRef.current) return
if (data.status !== 'success') return fail(data.message || t('weixin_scan_fail'))
setQr(data.qr_image || data.qrcode_url || '')
setPhase('waiting')
pollWeixin()
} catch {
fail(t('weixin_scan_fail'))
}
}
// ---- Feishu: GET qr, POST poll {done|expired|denied|error} ----------------
const pollFeishu = () => {
timerRef.current = setTimeout(async () => {
if (!aliveRef.current) return
try {
const data = await apiClient.feishuRegisterPoll()
if (!aliveRef.current) return
if (data.status !== 'success') return fail((data.message as string) || t('feishu_scan_fail'))
const rs = data.register_status as string
if (rs === 'done') {
setPhase('success')
await apiClient.channelAction('connect', 'feishu', {
feishu_app_id: data.app_id,
feishu_app_secret: data.app_secret,
})
if (aliveRef.current) onConnected()
} else if (rs === 'expired') {
fail(t('feishu_scan_expired'))
} else if (rs === 'denied') {
fail(t('feishu_scan_denied'))
} else if (rs === 'error') {
fail((data.message as string) || t('feishu_scan_fail'))
} else {
pollFeishu()
}
} catch {
pollFeishu()
}
}, POLL_INTERVAL)
}
const startFeishu = async () => {
setPhase('loading')
try {
const data = await apiClient.getFeishuRegister()
if (!aliveRef.current) return
if (data.status !== 'success') return fail(data.message || t('feishu_scan_fail'))
setQr(data.qr_image || data.qrcode_url || '')
setOpenLink(data.qrcode_url || '')
setPhase('waiting')
pollFeishu()
} catch {
fail(t('feishu_scan_fail'))
}
}
const start = () => {
stopPoll()
if (provider === 'weixin') void startWeixin()
else void startFeishu()
}
useEffect(() => {
aliveRef.current = true
start()
return () => {
aliveRef.current = false
stopPoll()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider])
const title = provider === 'weixin' ? t('weixin_scan_title') : t('feishu_scan_title')
const desc = provider === 'weixin' ? t('weixin_scan_desc') : t('feishu_scan_desc')
const tip = provider === 'weixin' ? t('weixin_qr_tip') : t('feishu_scan_tip')
const statusText = (): string => {
if (provider === 'weixin') {
if (phase === 'scanned') return t('weixin_scan_scanned')
return t('weixin_scan_waiting')
}
return t('feishu_scan_waiting')
}
return (
<Modal open title={title} onClose={onClose}>
<div className="flex flex-col items-center py-2">
{phase === 'loading' && (
<div className="flex items-center text-content-tertiary py-10">
<Loader2 size={18} className="animate-spin mr-2" />
{provider === 'weixin' ? t('weixin_scan_loading') : t('feishu_scan_loading')}
</div>
)}
{(phase === 'waiting' || phase === 'scanned') && (
<>
<p className="text-sm text-content-secondary mb-4 text-center">{desc}</p>
<div className="bg-white p-3 rounded-card border border-subtle mb-3">
{qr ? (
<img src={qr} alt="QR" className="w-48 h-48" style={{ imageRendering: 'pixelated' }} />
) : (
<div className="w-48 h-48 flex items-center justify-center text-content-tertiary text-xs">QR</div>
)}
</div>
<p className={`text-xs mb-1 ${phase === 'scanned' ? 'text-accent' : 'text-warning'}`}>{statusText()}</p>
<p className="text-xs text-content-tertiary">{tip}</p>
{openLink && (
<a
href={openLink}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-info hover:underline mt-2"
>
{t('feishu_scan_open_link')}
</a>
)}
</>
)}
{phase === 'success' && (
<div className="flex flex-col items-center py-8">
<div className="w-12 h-12 rounded-full bg-accent-soft flex items-center justify-center mb-3">
<Check size={22} className="text-accent" />
</div>
<p className="text-sm font-medium text-accent">
{provider === 'weixin' ? t('weixin_scan_success') : t('feishu_scan_success')}
</p>
</div>
)}
{phase === 'error' && (
<div className="flex flex-col items-center py-8">
<p className="text-sm text-danger text-center mb-3">{errMsg}</p>
<button
onClick={start}
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn border border-strong text-sm text-content-secondary hover:bg-inset cursor-pointer transition-colors"
>
<RotateCcw size={13} />
{t('feishu_scan_retry')}
</button>
</div>
)}
</div>
</Modal>
)
}
export default QrLoginModal

View File

@@ -184,11 +184,42 @@ const translations: Record<string, Record<string, string>> = {
memory_next: '下一页',
channels_title: '通道管理',
channels_desc: '查看和管理消息通道',
channels_add: '连接',
channels_add: '添加通道',
channels_connected: '已连接',
channels_disconnected: '未连接',
channels_connect: '连接',
channels_disconnect: '断开',
channels_save: '保存',
channels_loading: '加载通道中...',
channels_connected_section: '已连接',
channels_available_section: '可添加',
channels_empty_connected: '暂无已连接的通道',
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
channels_save_ok: '已保存',
channels_save_error: '保存失败',
channels_connect_error: '连接失败',
channels_scan_login: '扫码登录',
channels_scan_register: '扫码注册',
weixin_scan_title: '微信扫码登录',
weixin_scan_desc: '使用微信扫描下方二维码登录',
weixin_scan_loading: '正在获取二维码...',
weixin_scan_waiting: '等待扫码',
weixin_scan_scanned: '已扫描,请在手机上确认',
weixin_scan_success: '登录成功',
weixin_scan_expired: '二维码已过期,正在刷新...',
weixin_scan_fail: '获取二维码失败',
weixin_qr_tip: '请使用登录的微信扫码',
feishu_scan_title: '飞书扫码注册',
feishu_scan_desc: '扫码后将自动创建并连接飞书机器人',
feishu_scan_loading: '正在生成二维码...',
feishu_scan_waiting: '请使用飞书扫码授权',
feishu_scan_tip: '扫码后在飞书中确认授权',
feishu_scan_open_link: '打开授权链接',
feishu_scan_success: '注册成功',
feishu_scan_expired: '二维码已过期',
feishu_scan_denied: '授权被拒绝',
feishu_scan_fail: '注册失败',
feishu_scan_retry: '重试',
tasks_title: '定时任务',
tasks_desc: '查看和管理定时任务',
tasks_active: '运行中',
@@ -417,11 +448,42 @@ const translations: Record<string, Record<string, string>> = {
memory_next: 'Next',
channels_title: 'Channels',
channels_desc: 'View and manage messaging channels',
channels_add: 'Connect',
channels_add: 'Add channel',
channels_connected: 'Connected',
channels_disconnected: 'Disconnected',
channels_connect: 'Connect',
channels_disconnect: 'Disconnect',
channels_save: 'Save',
channels_loading: 'Loading channels...',
channels_connected_section: 'Connected',
channels_available_section: 'Available',
channels_empty_connected: 'No connected channels yet',
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
channels_save_ok: 'Saved',
channels_save_error: 'Failed to save',
channels_connect_error: 'Failed to connect',
channels_scan_login: 'QR login',
channels_scan_register: 'QR register',
weixin_scan_title: 'WeChat QR Login',
weixin_scan_desc: 'Scan the QR code below with WeChat to log in',
weixin_scan_loading: 'Fetching QR code...',
weixin_scan_waiting: 'Waiting for scan',
weixin_scan_scanned: 'Scanned, please confirm on your phone',
weixin_scan_success: 'Logged in',
weixin_scan_expired: 'QR code expired, refreshing...',
weixin_scan_fail: 'Failed to fetch QR code',
weixin_qr_tip: 'Scan with the WeChat account to log in',
feishu_scan_title: 'Feishu QR Register',
feishu_scan_desc: 'Scanning will create and connect a Feishu bot automatically',
feishu_scan_loading: 'Generating QR code...',
feishu_scan_waiting: 'Scan with Feishu to authorize',
feishu_scan_tip: 'Confirm the authorization in Feishu after scanning',
feishu_scan_open_link: 'Open authorization link',
feishu_scan_success: 'Registered',
feishu_scan_expired: 'QR code expired',
feishu_scan_denied: 'Authorization denied',
feishu_scan_fail: 'Registration failed',
feishu_scan_retry: 'Retry',
tasks_title: 'Scheduled Tasks',
tasks_desc: 'View and manage scheduled tasks',
tasks_active: 'Active',

View File

@@ -1,22 +1,24 @@
import React, { useState, useEffect } from 'react'
import { t } from '../i18n'
import React, { useEffect, useMemo, useState } from 'react'
import { Loader2, Plug, QrCode } from 'lucide-react'
import { t, localizedLabel } from '../i18n'
import apiClient from '../api/client'
import type { ChannelInfo } from '../types'
import type { ChannelInfo, ChannelField } from '../types'
import { Toggle, Btn } from './settings/primitives'
import QrLoginModal from '../components/QrLoginModal'
// Channels that connect via QR scanning rather than credential fields.
const QR_PROVIDERS: Record<string, 'weixin' | 'feishu'> = { weixin: 'weixin', feishu: 'feishu' }
interface ChannelsPageProps {
baseUrl: string
}
// A masked secret looks like "abcd****wxyz"; the backend skips such values.
const MASK_RE = /\*{2,}/
const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
const [channels, setChannels] = useState<ChannelInfo[]>([])
const [loading, setLoading] = useState(true)
const [configValues, setConfigValues] = useState<Record<string, Record<string, string>>>({})
const [actionLoading, setActionLoading] = useState<string | null>(null)
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
loadChannels()
}, [baseUrl])
const loadChannels = async () => {
try {
@@ -25,112 +27,245 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
setChannels(data || [])
} catch (err) {
console.error('Failed to load channels:', err)
setChannels([])
} finally {
setLoading(false)
}
}
const handleFieldChange = (ch: string, key: string, val: string) => {
setConfigValues((prev) => ({ ...prev, [ch]: { ...prev[ch], [key]: val } }))
}
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
void loadChannels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseUrl])
const handleAction = async (channel: ChannelInfo, action: 'save' | 'connect' | 'disconnect') => {
setActionLoading(`${channel.name}-${action}`)
try {
await apiClient.channelAction(action, channel.name, configValues[channel.name])
await loadChannels()
} catch (err) {
console.error(`Action failed:`, err)
} finally {
setActionLoading(null)
}
}
const { connected, available } = useMemo(() => {
const connected = channels.filter((c) => c.active)
const available = channels.filter((c) => !c.active)
return { connected, available }
}, [channels])
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('channels_title')}</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('channels_desc')}</p>
</div>
</div>
<div className="flex-1 flex flex-col min-h-0">
<div className="px-6 pt-5 pb-3 flex-shrink-0">
<h2 className="text-xl font-bold text-content">{t('channels_title')}</h2>
<p className="text-xs text-content-tertiary mt-1">{t('channels_desc')}</p>
</div>
{loading ? (
<div className="flex items-center justify-center py-20 text-slate-400">
<i className="fas fa-spinner fa-spin mr-2" />Loading...
</div>
) : (
<div className="grid gap-4">
{channels.map((ch) => (
<div key={ch.name} className="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-blue-50 dark:bg-blue-900/30 flex items-center justify-center">
<i className={`fas ${ch.icon || 'fa-tower-broadcast'} text-blue-500 text-sm`} />
</div>
<span className="font-semibold text-slate-800 dark:text-slate-100">{ch.label?.zh || ch.label?.en || ch.name}</span>
</div>
<span className={`text-xs px-2.5 py-1 rounded-full ${
ch.active
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400'
: 'bg-slate-100 dark:bg-white/10 text-slate-500 dark:text-slate-400'
}`}>
{ch.active ? t('channels_connected') : t('channels_disconnected')}
</span>
</div>
{ch.fields && ch.fields.length > 0 && (
<div className="space-y-3 mb-4">
{ch.fields.map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1">
{field.label || field.key}
</label>
<input
type={field.type === 'secret' ? 'password' : 'text'}
value={configValues[ch.name]?.[field.key] || ''}
onChange={(e) => handleFieldChange(ch.name, field.key, e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 transition-colors"
/>
</div>
))}
</div>
<div className="flex-1 overflow-y-auto border-t border-default">
<div className="max-w-3xl mx-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center py-20 text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('channels_loading')}
</div>
) : (
<div className="space-y-6">
<Section title={t('channels_connected_section')}>
{connected.length === 0 ? (
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
) : (
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
)}
</Section>
<div className="flex items-center gap-2">
<button
onClick={() => handleAction(ch, 'save')}
disabled={actionLoading === `${ch.name}-save`}
className="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-300 text-sm hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors"
>
<i className="fas fa-save text-xs mr-1.5" />{t('config_save')}
</button>
{ch.active ? (
<button
onClick={() => handleAction(ch, 'disconnect')}
disabled={actionLoading === `${ch.name}-disconnect`}
className="px-3 py-1.5 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm hover:bg-red-100 dark:hover:bg-red-900/30 cursor-pointer transition-colors"
>
<i className="fas fa-unlink text-xs mr-1.5" />{t('channels_disconnect')}
</button>
) : (
<button
onClick={() => handleAction(ch, 'connect')}
disabled={actionLoading === `${ch.name}-connect`}
className="px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm cursor-pointer transition-colors"
>
<i className="fas fa-link text-xs mr-1.5" />{t('channels_connect')}
</button>
)}
</div>
</div>
))}
</div>
)}
{available.length > 0 && (
<Section title={t('channels_available_section')}>
{available.map((ch) => (
<ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />
))}
</Section>
)}
</div>
)}
</div>
</div>
</div>
)
}
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-content-tertiary mb-2">{title}</h3>
<div className="space-y-3">{children}</div>
</div>
)
const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = ({ channel, onChanged }) => {
// Channels with no fields connect purely via QR (e.g. weixin).
const isQrLogin = channel.fields.length === 0
// QR provider supported by the desktop scan modal (weixin / feishu).
const qrProvider = QR_PROVIDERS[channel.name]
const [showQr, setShowQr] = useState(false)
const [expanded, setExpanded] = useState(false)
const [values, setValues] = useState<Record<string, string>>(() =>
Object.fromEntries(channel.fields.map((f) => [f.key, f.value != null ? String(f.value) : '']))
)
// Track which secret fields still hold the server-provided mask.
const [masked, setMasked] = useState<Record<string, boolean>>(() =>
Object.fromEntries(
channel.fields.map((f) => [f.key, f.type === 'secret' && !!f.value && MASK_RE.test(String(f.value))])
)
)
const [busy, setBusy] = useState(false)
const [status, setStatus] = useState('')
const setField = (key: string, val: string) => setValues((p) => ({ ...p, [key]: val }))
// Only send fields the user actually changed; masked secrets are skipped so
// the backend keeps the stored value (mirrors the web console behavior).
const buildConfig = (): Record<string, unknown> => {
const cfg: Record<string, unknown> = {}
channel.fields.forEach((f) => {
const v = values[f.key]
if (f.type === 'secret' && masked[f.key]) return
if (v === '' || v == null) return
cfg[f.key] = f.type === 'number' ? Number(v) : v
})
return cfg
}
const run = async (action: 'save' | 'connect' | 'disconnect') => {
setBusy(true)
setStatus('')
try {
const cfg = action === 'disconnect' ? undefined : buildConfig()
const res = await apiClient.channelAction(action, channel.name, cfg)
if (res.status === 'success') {
if (action === 'save') {
setStatus(t('channels_save_ok'))
setTimeout(() => setStatus(''), 1600)
}
onChanged()
} else {
setStatus((res.message as string) || t(action === 'connect' ? 'channels_connect_error' : 'channels_save_error'))
}
} catch {
setStatus(t(action === 'connect' ? 'channels_connect_error' : 'channels_save_error'))
} finally {
setBusy(false)
}
}
return (
<div className="rounded-card border border-default bg-surface p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-inset flex items-center justify-center flex-shrink-0">
{isQrLogin ? <QrCode size={16} className="text-content-secondary" /> : <Plug size={16} className="text-content-secondary" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-content">{localizedLabel(channel.label)}</span>
<span className={`w-2 h-2 rounded-full ${channel.active ? 'bg-accent' : 'bg-content-tertiary'}`} />
</div>
<p className="text-xs text-content-tertiary font-mono mt-0.5">{channel.name}</p>
</div>
{channel.active ? (
<Btn variant="danger" onClick={() => run('disconnect')} disabled={busy}>
{t('channels_disconnect')}
</Btn>
) : qrProvider ? (
<Btn variant="primary" onClick={() => setShowQr(true)}>
{qrProvider === 'weixin' ? t('channels_scan_login') : t('channels_scan_register')}
</Btn>
) : isQrLogin ? null : (
<Btn variant="ghost" onClick={() => setExpanded((v) => !v)}>
{t('channels_add')}
</Btn>
)}
</div>
{/* QR-login channels with no desktop support fall back to the web console. */}
{isQrLogin && !channel.active && !qrProvider && (
<p className="text-xs text-content-tertiary mt-3 pl-12">{t('channels_qr_hint')}</p>
)}
{/* Field-bearing QR channels (feishu) can also be configured manually. */}
{!isQrLogin && qrProvider && !channel.active && !expanded && (
<button
onClick={() => setExpanded(true)}
className="text-xs text-content-tertiary hover:text-content-secondary mt-3 pl-12 cursor-pointer transition-colors"
>
{t('channels_add')}
</button>
)}
{/* Field editor: always for connected channels with fields, on-demand for available ones. */}
{!isQrLogin && (channel.active || expanded) && (
<div className="mt-4 space-y-3">
{channel.fields.map((f) => (
<FieldRow
key={f.key}
field={f}
value={values[f.key] ?? ''}
onChange={(v) => setField(f.key, v)}
onFocusSecret={() => {
if (f.type === 'secret' && masked[f.key]) {
setField(f.key, '')
setMasked((p) => ({ ...p, [f.key]: false }))
}
}}
/>
))}
<div className="flex items-center justify-end gap-3 pt-1">
<span className={`text-xs transition-opacity ${status ? 'opacity-100' : 'opacity-0'} ${status === t('channels_save_ok') ? 'text-accent' : 'text-danger'}`}>
{status || '\u00a0'}
</span>
{channel.active ? (
<Btn variant="primary" onClick={() => run('save')} disabled={busy}>
{t('channels_save')}
</Btn>
) : (
<Btn variant="primary" onClick={() => run('connect')} disabled={busy}>
{t('channels_connect')}
</Btn>
)}
</div>
</div>
)}
{showQr && qrProvider && (
<QrLoginModal
provider={qrProvider}
onClose={() => setShowQr(false)}
onConnected={() => {
setShowQr(false)
onChanged()
}}
/>
)}
</div>
)
}
const FieldRow: React.FC<{
field: ChannelField
value: string
onChange: (v: string) => void
onFocusSecret: () => void
}> = ({ field, value, onChange, onFocusSecret }) => {
if (field.type === 'bool') {
return (
<div className="flex items-center justify-between">
<span className="text-sm text-content-secondary">{field.label}</span>
<Toggle checked={value === 'true' || value === '1'} onChange={(v) => onChange(v ? 'true' : 'false')} />
</div>
)
}
return (
<div>
<label className="block text-sm text-content-secondary mb-1.5">{field.label}</label>
<input
type={field.type === 'number' ? 'number' : 'text'}
value={value}
placeholder={field.label}
onChange={(e) => onChange(e.target.value)}
onFocus={onFocusSecret}
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent font-mono transition-colors"
/>
</div>
)
}
export default ChannelsPage