oxios 1.12.0

Oxios Agent OS — Agent Operating System powered by oxi-sdk
/**
 * Secrets management section (RFC-028 SP-2c).
 *
 * Lists all known secrets with masked status, allows setting and deleting
 * values via the `/api/secrets` backend. Secrets are stored in
 * `~/.oxi/auth.json`, never in `config.toml` plaintext.
 */
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { CheckCircle2, Eye, EyeOff, KeyRound, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { api } from '@/lib/api-client'
import { SectionCard } from './section-card'

interface SecretInfo {
  key: string
  has_value: boolean
  source: string
  preview: string
}

const SECRET_LABELS: Record<string, { labelKey: string; descKey: string }> = {
  telegram_bot_token: {
    labelKey: 'settings.secretTelegramToken',
    descKey: 'settings.secretTelegramTokenDesc',
  },
  email_smtp_password: {
    labelKey: 'settings.secretEmailPassword',
    descKey: 'settings.secretEmailPasswordDesc',
  },
  oxios_api_key: {
    labelKey: 'settings.secretOxiosApiKey',
    descKey: 'settings.secretOxiosApiKeyDesc',
  },
  clawhub_api_key: {
    labelKey: 'settings.secretClawhubApiKey',
    descKey: 'settings.secretClawhubApiKeyDesc',
  },
  anthropic: {
    labelKey: 'settings.secretAnthropicKey',
    descKey: 'settings.secretAnthropicKeyDesc',
  },
  openai: {
    labelKey: 'settings.secretOpenaiKey',
    descKey: 'settings.secretOpenaiKeyDesc',
  },
  google: {
    labelKey: 'settings.secretGoogleKey',
    descKey: 'settings.secretGoogleKeyDesc',
  },
}

function sourceBadgeClass(source: string): string {
  switch (source) {
    case 'env':
      return 'border-info/30 text-info'
    case 'auth_store':
      return 'border-success/30 text-success'
    case 'config':
      return 'border-warning/30 text-warning'
    default:
      return 'border-border text-muted-foreground'
  }
}

function sourceLabel(source: string, t: ReturnType<typeof useTranslation>['t']): string {
  switch (source) {
    case 'env':
      return t('settings.secretSourceEnv', 'Env Var')
    case 'auth_store':
      return t('settings.secretSourceStore', 'Auth Store')
    case 'config':
      return t('settings.secretSourceConfig', 'Config')
    default:
      return t('settings.secretSourceNone', 'Not Set')
  }
}
export function SecretsSectionCard() {
  const { t } = useTranslation()
  const queryClient = useQueryClient()
  const [editValues, setEditValues] = useState<Record<string, string>>({})
  const [visibleKeys, setVisibleKeys] = useState<Set<string>>(new Set())

  const { data: secrets, isLoading } = useQuery<SecretInfo[]>({
    queryKey: ['secrets'],
    queryFn: () => api.get<SecretInfo[]>('/api/secrets'),
  })

  const saveMutation = useMutation({
    mutationFn: ({ key, value }: { key: string; value: string }) =>
      api.put(`/api/secrets/${encodeURIComponent(key)}`, { value }),
    onSuccess: (_data, vars) => {
      queryClient.invalidateQueries({ queryKey: ['secrets'] })
      setEditValues((prev) => {
        const next = { ...prev }
        delete next[vars.key]
        return next
      })
      toast.success(t('settings.secretSaved', 'Secret saved'))
    },
    onError: () => toast.error(t('settings.secretSaveFailed', 'Failed to save secret')),
  })

  const deleteMutation = useMutation({
    mutationFn: (key: string) => api.delete(`/api/secrets/${encodeURIComponent(key)}`),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['secrets'] })
      toast.success(t('settings.secretDeleted', 'Secret deleted'))
    },
    onError: () => toast.error(t('settings.secretDeleteFailed', 'Failed to delete secret')),
  })

  const toggleVisible = (key: string) => {
    setVisibleKeys((prev) => {
      const next = new Set(prev)
      if (next.has(key)) next.delete(key)
      else next.add(key)
      return next
    })
  }

  return (
    <SectionCard
      title={t('settings.sectionSecrets', 'Secrets')}
      description={t('settings.secretsDescription', 'Manage API keys and credentials securely')}
      icon={<KeyRound className="h-3.5 w-3.5" />}
      sectionId="secrets"
      fieldCount={Object.keys(SECRET_LABELS).length}
      modified={false}
    >
      <div className="space-y-4">
        {isLoading && <p className="text-sm text-muted-foreground">Loading…</p>}
        {secrets?.map((secret) => {
          const labels = SECRET_LABELS[secret.key]
          if (!labels) return null
          const isEditing = editValues[secret.key] !== undefined
          const isVisible = visibleKeys.has(secret.key)

          return (
            <div key={secret.key} className="space-y-1.5">
              <div className="flex items-center justify-between">
                <div className="flex items-center gap-2">
                  <label className="text-sm font-medium">{t(labels.labelKey, secret.key)}</label>
                  {secret.has_value && (
                    <Badge
                      variant="outline"
                      className={`text-2xs ${sourceBadgeClass(secret.source)}`}
                    >
                      {sourceLabel(secret.source, t)}
                    </Badge>
                  )}
                  {secret.has_value && secret.preview && (
                    <span className="text-xs font-mono text-muted-foreground">
                      {secret.preview}
                    </span>
                  )}
                </div>
                {secret.has_value && !isEditing && (
                  <Button
                    variant="ghost"
                    size="sm"
                    className="h-7 text-xs"
                    onClick={() => {
                      if (window.confirm(t('settings.secretDeleteConfirm', 'Delete this secret?')))
                        deleteMutation.mutate(secret.key)
                    }}
                  >
                    <Trash2 className="h-3 w-3 mr-1" />
                    {t('common.delete', 'Delete')}
                  </Button>
                )}
              </div>

              {isEditing ? (
                <div className="flex gap-2">
                  <Input
                    type={isVisible ? 'text' : 'password'}
                    value={editValues[secret.key] ?? ''}
                    onChange={(e) =>
                      setEditValues((prev) => ({ ...prev, [secret.key]: e.target.value }))
                    }
                    placeholder={t('settings.secretEnterValue', 'Enter value…')}
                    className="flex-1"
                  />
                  <Button
                    variant="ghost"
                    size="icon"
                    className="h-9 w-9"
                    onClick={() => toggleVisible(secret.key)}
                  >
                    {isVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
                  </Button>
                  <Button
                    size="sm"
                    onClick={() =>
                      saveMutation.mutate({ key: secret.key, value: editValues[secret.key] ?? '' })
                    }
                    disabled={!editValues[secret.key] || saveMutation.isPending}
                  >
                    <CheckCircle2 className="h-3.5 w-3.5 mr-1" />
                    {t('common.save', 'Save')}
                  </Button>
                </div>
              ) : (
                <div className="flex gap-2">
                  <Button
                    variant="outline"
                    size="sm"
                    onClick={() => setEditValues((prev) => ({ ...prev, [secret.key]: '' }))}
                  >
                    {secret.has_value
                      ? t('settings.secretUpdate', 'Update')
                      : t('settings.secretSet', 'Set Value')}
                  </Button>
                </div>
              )}
              <p className="text-xs text-muted-foreground">{t(labels.descKey, '')}</p>
            </div>
          )
        })}
      </div>
    </SectionCard>
  )
}