/** * Settings → API Keys → Connected apps. * * Registers external applications that sign users in with their Bambuddy * account ("Sign in with Bambuddy"). The client secret is shown exactly once, * at creation or rotation; the list only ever shows the client ID. */ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, Copy, Plus, RefreshCw, Trash2 } from 'lucide-react'; import { api, type ConnectedApp } from '../api/client'; import { useAuth } from '../contexts/AuthContext'; import { useToast } from '../contexts/ToastContext'; import { parseUTCDate } from '../utils/date'; const QUERY_KEY = ['connected-apps']; function formatDate(iso: string | null): string { if (!iso) return '—'; const d = parseUTCDate(iso); return d ? d.toLocaleString() : '—'; } async function copyToClipboard(value: string): Promise { // navigator.clipboard needs a secure context; LAN installs are often plain // http, so fall back to a hidden textarea. if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(value); return; } const ta = document.createElement('textarea'); ta.value = value; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); try { ta.select(); document.execCommand('copy'); } finally { document.body.removeChild(ta); } } function CopyField({ label, value }: { label: string; value: string }) { const { t } = useTranslation(); const { showToast } = useToast(); return (

{label}

{value}
); } function SecretModal({ app, onClose }: { app: ConnectedApp; onClose: () => void }) { const { t } = useTranslation(); return (

{t('connectedApps.secret.title', { name: app.name })}

{t('connectedApps.secret.warning')}

); } function ConfirmModal({ title, body, confirmLabel, onConfirm, onCancel, }: { title: string; body: string; confirmLabel: string; onConfirm: () => void; onCancel: () => void; }) { const { t } = useTranslation(); return (

{title}

{body}

); } export function ConnectedAppsSection() { const { t } = useTranslation(); const { authEnabled } = useAuth(); const { showToast } = useToast(); const queryClient = useQueryClient(); const [name, setName] = useState(''); const [redirectUri, setRedirectUri] = useState(''); const [shownSecret, setShownSecret] = useState(null); const [confirm, setConfirm] = useState<{ app: ConnectedApp; action: 'rotate' | 'delete' } | null>(null); const { data: apps = [], isLoading } = useQuery({ queryKey: QUERY_KEY, queryFn: api.listConnectedApps, enabled: authEnabled, }); const onError = (err: unknown) => showToast(err instanceof Error ? err.message : t('connectedApps.toast.failed'), 'error'); const refresh = () => queryClient.invalidateQueries({ queryKey: QUERY_KEY }); const create = useMutation({ mutationFn: () => api.createConnectedApp({ name: name.trim(), redirect_uri: redirectUri.trim() }), onSuccess: (app) => { setName(''); setRedirectUri(''); setShownSecret(app); showToast(t('connectedApps.toast.created')); refresh(); }, onError, }); const toggle = useMutation({ mutationFn: (app: ConnectedApp) => api.updateConnectedApp(app.id, { enabled: !app.enabled }), onSuccess: (app) => { showToast(app.enabled ? t('connectedApps.toast.enabled') : t('connectedApps.toast.disabled')); refresh(); }, onError, }); const rotate = useMutation({ mutationFn: (app: ConnectedApp) => api.rotateConnectedAppSecret(app.id), onSuccess: (app) => { setShownSecret(app); showToast(t('connectedApps.toast.rotated')); refresh(); }, onError, }); const remove = useMutation({ mutationFn: (app: ConnectedApp) => api.deleteConnectedApp(app.id), onSuccess: () => { showToast(t('connectedApps.toast.deleted')); refresh(); }, onError, }); if (!authEnabled) { return

{t('connectedApps.requiresAuth')}

; } const inputClass = 'px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none'; return (

{t('connectedApps.description')}

{ e.preventDefault(); if (name.trim() && redirectUri.trim()) create.mutate(); }} className="grid gap-3 md:grid-cols-[1fr_1.5fr_auto]" > setName(e.target.value)} placeholder={t('connectedApps.namePlaceholder')} aria-label={t('connectedApps.name')} className={inputClass} /> setRedirectUri(e.target.value)} placeholder="http://orders.local:8090/auth/callback" aria-label={t('connectedApps.callbackUrl')} className={inputClass} />

{t('connectedApps.callbackHint')}

{isLoading ? null : apps.length === 0 ? (

{t('connectedApps.empty')}

) : (
{apps.map((app) => ( ))}
{t('connectedApps.name')} {t('connectedApps.clientId')} {t('connectedApps.callbackUrl')} {t('connectedApps.lastUsed')}
{app.name} {!app.enabled && ( {t('connectedApps.disabledBadge')} )} {app.client_id} {app.redirect_uri} {formatDate(app.last_used_at)}
)} {shownSecret && setShownSecret(null)} />} {confirm && ( setConfirm(null)} onConfirm={() => { if (confirm.action === 'rotate') rotate.mutate(confirm.app); else remove.mutate(confirm.app); setConfirm(null); }} /> )}
); }