@actuate-media/cms-admin 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/__tests__/views/ai-settings.render.test.d.ts +2 -0
  2. package/dist/__tests__/views/ai-settings.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/views/ai-settings.render.test.js +182 -0
  4. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -0
  5. package/dist/__tests__/views/seo-settings.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/seo-settings.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/seo-settings.render.test.js +152 -0
  8. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -0
  9. package/dist/__tests__/views/settings-save-footer.render.test.js +16 -9
  10. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -1
  11. package/dist/actuate-admin.css +1 -1
  12. package/dist/components/MediaPickerModal.d.ts +15 -1
  13. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  14. package/dist/components/MediaPickerModal.js +17 -1
  15. package/dist/components/MediaPickerModal.js.map +1 -1
  16. package/dist/views/Settings.d.ts.map +1 -1
  17. package/dist/views/Settings.js +3 -47
  18. package/dist/views/Settings.js.map +1 -1
  19. package/dist/views/settings/AISettingsTab.d.ts +6 -0
  20. package/dist/views/settings/AISettingsTab.d.ts.map +1 -0
  21. package/dist/views/settings/AISettingsTab.js +138 -0
  22. package/dist/views/settings/AISettingsTab.js.map +1 -0
  23. package/dist/views/settings/AiFeaturesCard.d.ts +12 -0
  24. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -0
  25. package/dist/views/settings/AiFeaturesCard.js +14 -0
  26. package/dist/views/settings/AiFeaturesCard.js.map +1 -0
  27. package/dist/views/settings/CapabilityBadges.d.ts +5 -0
  28. package/dist/views/settings/CapabilityBadges.d.ts.map +1 -0
  29. package/dist/views/settings/CapabilityBadges.js +26 -0
  30. package/dist/views/settings/CapabilityBadges.js.map +1 -0
  31. package/dist/views/settings/ModelSelector.d.ts +12 -0
  32. package/dist/views/settings/ModelSelector.d.ts.map +1 -0
  33. package/dist/views/settings/ModelSelector.js +29 -0
  34. package/dist/views/settings/ModelSelector.js.map +1 -0
  35. package/dist/views/settings/MonthlyUsageCard.d.ts +14 -0
  36. package/dist/views/settings/MonthlyUsageCard.d.ts.map +1 -0
  37. package/dist/views/settings/MonthlyUsageCard.js +30 -0
  38. package/dist/views/settings/MonthlyUsageCard.js.map +1 -0
  39. package/dist/views/settings/ProviderConnectionCard.d.ts +13 -0
  40. package/dist/views/settings/ProviderConnectionCard.d.ts.map +1 -0
  41. package/dist/views/settings/ProviderConnectionCard.js +48 -0
  42. package/dist/views/settings/ProviderConnectionCard.js.map +1 -0
  43. package/dist/views/settings/SeoSettingsTab.d.ts +7 -0
  44. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -0
  45. package/dist/views/settings/SeoSettingsTab.js +126 -0
  46. package/dist/views/settings/SeoSettingsTab.js.map +1 -0
  47. package/dist/views/settings/useAiSettings.d.ts +143 -0
  48. package/dist/views/settings/useAiSettings.d.ts.map +1 -0
  49. package/dist/views/settings/useAiSettings.js +278 -0
  50. package/dist/views/settings/useAiSettings.js.map +1 -0
  51. package/dist/views/settings/useSeoSettings.d.ts +79 -0
  52. package/dist/views/settings/useSeoSettings.d.ts.map +1 -0
  53. package/dist/views/settings/useSeoSettings.js +278 -0
  54. package/dist/views/settings/useSeoSettings.js.map +1 -0
  55. package/package.json +2 -2
  56. package/src/__tests__/views/ai-settings.render.test.tsx +225 -0
  57. package/src/__tests__/views/seo-settings.render.test.tsx +219 -0
  58. package/src/__tests__/views/settings-save-footer.render.test.tsx +16 -9
  59. package/src/components/MediaPickerModal.tsx +39 -2
  60. package/src/views/Settings.tsx +4 -141
  61. package/src/views/settings/AISettingsTab.tsx +411 -0
  62. package/src/views/settings/AiFeaturesCard.tsx +49 -0
  63. package/src/views/settings/CapabilityBadges.tsx +66 -0
  64. package/src/views/settings/ModelSelector.tsx +115 -0
  65. package/src/views/settings/MonthlyUsageCard.tsx +118 -0
  66. package/src/views/settings/ProviderConnectionCard.tsx +245 -0
  67. package/src/views/settings/SeoSettingsTab.tsx +658 -0
  68. package/src/views/settings/useAiSettings.ts +448 -0
  69. package/src/views/settings/useSeoSettings.ts +374 -0
@@ -0,0 +1,115 @@
1
+ 'use client'
2
+
3
+ import { AlertTriangle, Loader2, RefreshCw } from 'lucide-react'
4
+ import { useId } from 'react'
5
+ import { CapabilityBadges } from './CapabilityBadges.js'
6
+ import type { AiModelView } from './useAiSettings.js'
7
+
8
+ const INPUT_CLASS =
9
+ 'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
10
+ const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
11
+
12
+ function priceLabel(model: AiModelView): string | null {
13
+ const p = model.pricing
14
+ if (!p) return null
15
+ if (typeof p.inputPerMillion === 'number' && typeof p.outputPerMillion === 'number') {
16
+ return `$${p.inputPerMillion}/$${p.outputPerMillion} per 1M tokens`
17
+ }
18
+ if (typeof p.inputPerMillion === 'number') return `$${p.inputPerMillion} per 1M tokens`
19
+ if (typeof p.image === 'number') return `$${p.image} per image`
20
+ return null
21
+ }
22
+
23
+ export function ModelSelector({
24
+ models,
25
+ value,
26
+ onChange,
27
+ onRefresh,
28
+ refreshing,
29
+ disabled,
30
+ noModelsHint,
31
+ }: {
32
+ models: AiModelView[]
33
+ value: string
34
+ onChange: (id: string) => void
35
+ onRefresh: () => void
36
+ refreshing: boolean
37
+ disabled: boolean
38
+ /** Shown when there are no models to choose from. */
39
+ noModelsHint?: string
40
+ }) {
41
+ const selectId = useId()
42
+ const selected = models.find((m) => m.id === value || m.providerModelId === value)
43
+ // The configured model is no longer in the catalog — keep the choice, warn.
44
+ const selectedUnavailable = Boolean(value) && !selected
45
+ const price = selected ? priceLabel(selected) : null
46
+
47
+ return (
48
+ <div>
49
+ <label htmlFor={selectId} className={LABEL_CLASS}>
50
+ Default model
51
+ </label>
52
+ <div className="flex items-center gap-2">
53
+ <select
54
+ id={selectId}
55
+ value={value}
56
+ disabled={disabled}
57
+ onChange={(e) => onChange(e.target.value)}
58
+ className={INPUT_CLASS}
59
+ >
60
+ <option value="">Select a model…</option>
61
+ {selectedUnavailable && <option value={value}>{value} (unavailable)</option>}
62
+ {models.map((m) => (
63
+ <option key={m.id} value={m.id} disabled={m.status === 'unavailable'}>
64
+ {m.displayName}
65
+ {m.status === 'deprecated' ? ' (deprecated)' : ''}
66
+ {m.status === 'unavailable' ? ' (unavailable)' : ''}
67
+ </option>
68
+ ))}
69
+ </select>
70
+ <button
71
+ type="button"
72
+ onClick={onRefresh}
73
+ disabled={disabled || refreshing}
74
+ aria-label="Refresh models"
75
+ className="border-border text-foreground hover:bg-accent inline-flex shrink-0 items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
76
+ >
77
+ {refreshing ? (
78
+ <Loader2 size={16} className="animate-spin" aria-hidden="true" />
79
+ ) : (
80
+ <RefreshCw size={16} aria-hidden="true" />
81
+ )}
82
+ <span className="sr-only sm:not-sr-only">Refresh models</span>
83
+ </button>
84
+ </div>
85
+
86
+ {models.length === 0 && (
87
+ <p className="text-muted-foreground mt-1 text-sm">
88
+ {noModelsHint ?? 'No models available yet. Connect a provider and refresh.'}
89
+ </p>
90
+ )}
91
+
92
+ {selectedUnavailable && (
93
+ <p className="text-warning mt-2 flex items-start gap-1.5 text-sm" role="status">
94
+ <AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
95
+ The selected model is no longer offered by the provider. Pick another or refresh the list.
96
+ </p>
97
+ )}
98
+
99
+ {selected && (
100
+ <div className="mt-2 space-y-1.5">
101
+ <CapabilityBadges capabilities={selected.capabilities} />
102
+ <div className="text-muted-foreground flex flex-wrap gap-x-4 gap-y-1 text-sm">
103
+ {typeof selected.contextWindow === 'number' && (
104
+ <span>{selected.contextWindow.toLocaleString()} token context</span>
105
+ )}
106
+ {price && <span>{price}</span>}
107
+ {selected.status === 'deprecated' && (
108
+ <span className="text-warning">Deprecated by provider</span>
109
+ )}
110
+ </div>
111
+ </div>
112
+ )}
113
+ </div>
114
+ )
115
+ }
@@ -0,0 +1,118 @@
1
+ 'use client'
2
+
3
+ import { useId } from 'react'
4
+ import type { AiUsageView } from './useAiSettings.js'
5
+
6
+ const INPUT_CLASS =
7
+ 'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
8
+ const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
9
+
10
+ function formatTokens(n: number): string {
11
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`
12
+ if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`
13
+ return String(n)
14
+ }
15
+
16
+ /**
17
+ * Monthly token usage meter + budget limit input. Usage is REAL (aggregated
18
+ * from audit-log token totals) — it shows 0 until AI features have run, never a
19
+ * fabricated number.
20
+ */
21
+ export function MonthlyUsageCard({
22
+ usage,
23
+ monthlyTokenLimit,
24
+ onChangeLimit,
25
+ budgetError,
26
+ canEdit,
27
+ }: {
28
+ usage: AiUsageView | null
29
+ monthlyTokenLimit: string
30
+ onChangeLimit: (value: string) => void
31
+ budgetError: string | null
32
+ canEdit: boolean
33
+ }) {
34
+ const headingId = useId()
35
+ const limitId = useId()
36
+
37
+ const used = usage?.totalTokens ?? 0
38
+ const limit = monthlyTokenLimit === '' ? undefined : Number(monthlyTokenLimit)
39
+ const pct = limit && limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : null
40
+ const over = pct !== null && used > (limit ?? 0)
41
+
42
+ const periodLabel = usage?.periodStart
43
+ ? new Date(usage.periodStart).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
44
+ : 'This month'
45
+
46
+ return (
47
+ <section aria-labelledby={headingId} className="border-border bg-card rounded-lg border p-4">
48
+ <h3 id={headingId} className="text-foreground mb-1 text-base font-medium">
49
+ Monthly Usage
50
+ </h3>
51
+ <p className="text-muted-foreground mb-4 text-sm">
52
+ Token usage for {periodLabel}. Aggregated from completed AI runs.
53
+ </p>
54
+
55
+ <div className="space-y-4">
56
+ <div>
57
+ <div className="mb-1 flex items-center justify-between text-sm">
58
+ <span className="text-foreground font-medium">{formatTokens(used)} tokens used</span>
59
+ {limit && limit > 0 && (
60
+ <span className="text-muted-foreground">of {formatTokens(limit)}</span>
61
+ )}
62
+ </div>
63
+ {pct !== null ? (
64
+ <div
65
+ className="bg-muted h-2 w-full overflow-hidden rounded-full"
66
+ role="progressbar"
67
+ aria-valuenow={pct}
68
+ aria-valuemin={0}
69
+ aria-valuemax={100}
70
+ aria-valuetext={`${pct}% of monthly token budget used`}
71
+ >
72
+ <div
73
+ className={`h-full rounded-full transition-all ${over ? 'bg-destructive' : 'bg-brand'}`}
74
+ style={{ width: `${pct}%` }}
75
+ />
76
+ </div>
77
+ ) : (
78
+ <p className="text-muted-foreground text-sm">
79
+ No monthly limit set. Usage is tracked but not capped.
80
+ </p>
81
+ )}
82
+ {over && (
83
+ <p className="text-destructive mt-1 text-sm" role="status">
84
+ Usage has exceeded the monthly limit.
85
+ </p>
86
+ )}
87
+ </div>
88
+
89
+ <div>
90
+ <label htmlFor={limitId} className={LABEL_CLASS}>
91
+ Monthly token limit
92
+ </label>
93
+ <input
94
+ id={limitId}
95
+ type="text"
96
+ inputMode="numeric"
97
+ value={monthlyTokenLimit}
98
+ disabled={!canEdit}
99
+ placeholder="No limit"
100
+ aria-invalid={Boolean(budgetError)}
101
+ aria-describedby={budgetError ? `${limitId}-error` : undefined}
102
+ onChange={(e) => onChangeLimit(e.target.value)}
103
+ className={INPUT_CLASS}
104
+ />
105
+ {budgetError ? (
106
+ <p id={`${limitId}-error`} className="text-destructive mt-1 text-sm">
107
+ {budgetError}
108
+ </p>
109
+ ) : (
110
+ <p className="text-muted-foreground mt-1 text-sm">
111
+ Leave blank for no limit. Enforcement and alerts arrive with usage metering.
112
+ </p>
113
+ )}
114
+ </div>
115
+ </div>
116
+ </section>
117
+ )
118
+ }
@@ -0,0 +1,245 @@
1
+ 'use client'
2
+
3
+ import {
4
+ AlertTriangle,
5
+ CheckCircle2,
6
+ KeyRound,
7
+ Loader2,
8
+ Lock,
9
+ Plug,
10
+ RefreshCw,
11
+ Trash2,
12
+ } from 'lucide-react'
13
+ import { useId, useState } from 'react'
14
+ import { ConfirmDangerousSettingDialog } from './components.js'
15
+ import type { AiConnectionStatus, AiProviderView } from './useAiSettings.js'
16
+
17
+ const INPUT_CLASS =
18
+ 'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
19
+ const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
20
+
21
+ const STATUS_META: Record<
22
+ AiConnectionStatus,
23
+ { label: string; tone: 'ok' | 'warn' | 'error' | 'idle' }
24
+ > = {
25
+ connected: { label: 'Connected', tone: 'ok' },
26
+ invalid_key: { label: 'Invalid key', tone: 'error' },
27
+ rate_limited: { label: 'Rate limited', tone: 'warn' },
28
+ provider_down: { label: 'Provider unavailable', tone: 'warn' },
29
+ needs_setup: { label: 'Needs API key', tone: 'idle' },
30
+ unknown: { label: 'Not tested', tone: 'idle' },
31
+ }
32
+
33
+ function StatusPill({ status }: { status: AiConnectionStatus }) {
34
+ const meta = STATUS_META[status]
35
+ const toneClass =
36
+ meta.tone === 'ok'
37
+ ? 'border-success/30 bg-success/10 text-success'
38
+ : meta.tone === 'error'
39
+ ? 'border-destructive/30 bg-destructive/10 text-destructive'
40
+ : meta.tone === 'warn'
41
+ ? 'border-warning/30 bg-warning/10 text-warning'
42
+ : 'border-border bg-muted text-muted-foreground'
43
+ return (
44
+ <span
45
+ className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${toneClass}`}
46
+ >
47
+ {meta.tone === 'ok' ? (
48
+ <CheckCircle2 size={12} aria-hidden="true" />
49
+ ) : meta.tone === 'error' ? (
50
+ <AlertTriangle size={12} aria-hidden="true" />
51
+ ) : (
52
+ <Plug size={12} aria-hidden="true" />
53
+ )}
54
+ {meta.label}
55
+ </span>
56
+ )
57
+ }
58
+
59
+ export interface ProviderConnectionCardProps {
60
+ provider: AiProviderView
61
+ canEdit: boolean
62
+ busy: boolean
63
+ onTest: () => void
64
+ onSyncModels: () => void
65
+ onUpdateKey: (apiKey: string | null) => Promise<void>
66
+ onUpdateBaseUrl: (baseUrl: string) => Promise<void>
67
+ onDisconnect: () => void
68
+ }
69
+
70
+ export function ProviderConnectionCard({
71
+ provider,
72
+ canEdit,
73
+ busy,
74
+ onTest,
75
+ onSyncModels,
76
+ onUpdateKey,
77
+ onUpdateBaseUrl,
78
+ onDisconnect,
79
+ }: ProviderConnectionCardProps) {
80
+ const keyId = useId()
81
+ const baseUrlId = useId()
82
+ const [keyInput, setKeyInput] = useState('')
83
+ const [baseUrlInput, setBaseUrlInput] = useState(provider.baseUrl ?? '')
84
+ const [confirmKey, setConfirmKey] = useState(false)
85
+ const [confirmDisconnect, setConfirmDisconnect] = useState(false)
86
+
87
+ const envManaged = provider.keyManagedExternally
88
+ const keyEditable = canEdit && !envManaged
89
+
90
+ async function applyKey() {
91
+ setConfirmKey(false)
92
+ await onUpdateKey(keyInput.trim() === '' ? null : keyInput.trim())
93
+ setKeyInput('')
94
+ }
95
+
96
+ return (
97
+ <div className="border-border bg-card rounded-lg border p-4">
98
+ <div className="mb-3 flex items-start justify-between gap-3">
99
+ <div>
100
+ <div className="flex items-center gap-2">
101
+ <span className="text-foreground text-base font-medium">{provider.displayName}</span>
102
+ <StatusPill status={provider.status} />
103
+ </div>
104
+ <p className="text-muted-foreground mt-0.5 text-sm capitalize">{provider.provider}</p>
105
+ </div>
106
+ <div className="flex items-center gap-2">
107
+ <button
108
+ type="button"
109
+ onClick={onTest}
110
+ disabled={!canEdit || busy}
111
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
112
+ >
113
+ {busy ? (
114
+ <Loader2 size={14} className="animate-spin" aria-hidden="true" />
115
+ ) : (
116
+ <Plug size={14} aria-hidden="true" />
117
+ )}
118
+ Test connection
119
+ </button>
120
+ <button
121
+ type="button"
122
+ onClick={onSyncModels}
123
+ disabled={!canEdit || busy}
124
+ aria-label={`Refresh models for ${provider.displayName}`}
125
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
126
+ >
127
+ <RefreshCw size={14} aria-hidden="true" />
128
+ Refresh models
129
+ </button>
130
+ <button
131
+ type="button"
132
+ onClick={() => setConfirmDisconnect(true)}
133
+ disabled={!canEdit || busy}
134
+ aria-label={`Disconnect ${provider.displayName}`}
135
+ className="text-muted-foreground hover:text-destructive inline-flex items-center rounded-md p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
136
+ >
137
+ <Trash2 size={16} aria-hidden="true" />
138
+ </button>
139
+ </div>
140
+ </div>
141
+
142
+ <div className="space-y-3">
143
+ {/* API key */}
144
+ <div>
145
+ <label htmlFor={keyId} className={LABEL_CLASS}>
146
+ <KeyRound size={13} aria-hidden="true" />
147
+ API key
148
+ {envManaged && (
149
+ <span className="text-muted-foreground border-border bg-muted inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs">
150
+ <Lock size={11} aria-hidden="true" />
151
+ Managed by environment
152
+ </span>
153
+ )}
154
+ </label>
155
+ {envManaged ? (
156
+ <p className="text-muted-foreground text-sm">
157
+ This key is set via an environment variable and can&apos;t be edited here.
158
+ {provider.apiKeyLast4 ? ` Ends in ••••${provider.apiKeyLast4}.` : ''}
159
+ </p>
160
+ ) : (
161
+ <div className="flex items-center gap-2">
162
+ <input
163
+ id={keyId}
164
+ type="password"
165
+ autoComplete="off"
166
+ spellCheck={false}
167
+ value={keyInput}
168
+ disabled={!keyEditable || busy}
169
+ placeholder={
170
+ provider.hasKey
171
+ ? `Key set ••••${provider.apiKeyLast4 ?? ''} — enter a new key to replace`
172
+ : 'Paste API key'
173
+ }
174
+ onChange={(e) => setKeyInput(e.target.value)}
175
+ className={INPUT_CLASS}
176
+ />
177
+ <button
178
+ type="button"
179
+ onClick={() => setConfirmKey(true)}
180
+ disabled={!keyEditable || busy || keyInput.trim() === ''}
181
+ className="bg-brand text-brand-foreground inline-flex shrink-0 items-center rounded-md px-3 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
182
+ >
183
+ Save key
184
+ </button>
185
+ </div>
186
+ )}
187
+ </div>
188
+
189
+ {/* Custom base URL */}
190
+ {provider.provider === 'custom' && (
191
+ <div>
192
+ <label htmlFor={baseUrlId} className={LABEL_CLASS}>
193
+ Base URL
194
+ </label>
195
+ <div className="flex items-center gap-2">
196
+ <input
197
+ id={baseUrlId}
198
+ type="url"
199
+ value={baseUrlInput}
200
+ disabled={!canEdit || busy}
201
+ placeholder="https://api.example.com/v1"
202
+ onChange={(e) => setBaseUrlInput(e.target.value)}
203
+ className={INPUT_CLASS}
204
+ />
205
+ <button
206
+ type="button"
207
+ onClick={() => onUpdateBaseUrl(baseUrlInput.trim())}
208
+ disabled={!canEdit || busy || baseUrlInput.trim() === (provider.baseUrl ?? '')}
209
+ className="border-border text-foreground hover:bg-accent inline-flex shrink-0 items-center rounded-md border px-3 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
210
+ >
211
+ Save
212
+ </button>
213
+ </div>
214
+ </div>
215
+ )}
216
+
217
+ {provider.lastTestedAt && (
218
+ <p className="text-muted-foreground text-sm">
219
+ Last tested {new Date(provider.lastTestedAt).toLocaleString()}.
220
+ </p>
221
+ )}
222
+ </div>
223
+
224
+ <ConfirmDangerousSettingDialog
225
+ open={confirmKey}
226
+ title="Replace the stored API key?"
227
+ description="The new key is encrypted and replaces the existing one. Existing AI features will use it immediately."
228
+ confirmLabel="Save key"
229
+ onConfirm={applyKey}
230
+ onCancel={() => setConfirmKey(false)}
231
+ />
232
+ <ConfirmDangerousSettingDialog
233
+ open={confirmDisconnect}
234
+ title={`Disconnect ${provider.displayName}?`}
235
+ description="This removes the stored key and model catalog for this provider. AI features that rely on it will stop working until you reconnect."
236
+ confirmLabel="Disconnect"
237
+ onConfirm={() => {
238
+ setConfirmDisconnect(false)
239
+ onDisconnect()
240
+ }}
241
+ onCancel={() => setConfirmDisconnect(false)}
242
+ />
243
+ </div>
244
+ )
245
+ }