@flame0510/project-aether 1.1.9

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 (290) hide show
  1. package/README.md +150 -0
  2. package/agent-templates/README.md +37 -0
  3. package/agent-templates/argus/AGENTS.md +52 -0
  4. package/agent-templates/argus/IDENTITY.md +7 -0
  5. package/agent-templates/argus/MEMORY.md +9 -0
  6. package/agent-templates/argus/SOUL.md +24 -0
  7. package/agent-templates/argus/TOOLS.md +8 -0
  8. package/agent-templates/atlas/AGENTS.md +47 -0
  9. package/agent-templates/atlas/HEARTBEAT.md +10 -0
  10. package/agent-templates/atlas/IDENTITY.md +6 -0
  11. package/agent-templates/atlas/MEMORY.md +9 -0
  12. package/agent-templates/atlas/SOUL.md +21 -0
  13. package/agent-templates/atlas/TOOLS.md +4 -0
  14. package/agent-templates/base-image/.trigger +0 -0
  15. package/agent-templates/base-image/Dockerfile +90 -0
  16. package/agent-templates/base-image/entrypoint.sh +37 -0
  17. package/agent-templates/custom/AGENTS.md +29 -0
  18. package/agent-templates/custom/BOOTSTRAP.md +56 -0
  19. package/agent-templates/custom/MEMORY.md +8 -0
  20. package/agent-templates/prometheus/AGENTS.md +37 -0
  21. package/agent-templates/prometheus/IDENTITY.md +6 -0
  22. package/agent-templates/prometheus/MEMORY.md +9 -0
  23. package/agent-templates/prometheus/SOUL.md +21 -0
  24. package/agent-templates/prometheus/TOOLS.md +4 -0
  25. package/app/agents/ChannelManager.tsx +465 -0
  26. package/app/agents/ImageDownloadBanner.tsx +159 -0
  27. package/app/agents/PageClient.tsx +1285 -0
  28. package/app/agents/create/PageClient.tsx +437 -0
  29. package/app/agents/create/page.tsx +126 -0
  30. package/app/agents/loading.tsx +14 -0
  31. package/app/agents/page.tsx +5 -0
  32. package/app/api/agents/[id]/backup/route.ts +169 -0
  33. package/app/api/agents/[id]/channels/pairing/route.ts +69 -0
  34. package/app/api/agents/[id]/channels/route.ts +14 -0
  35. package/app/api/agents/[id]/channels/telegram/route.ts +50 -0
  36. package/app/api/agents/[id]/lifecycle/route.ts +94 -0
  37. package/app/api/agents/[id]/recreate/route.ts +199 -0
  38. package/app/api/agents/[id]/restart/route.ts +33 -0
  39. package/app/api/agents/[id]/restore/route.ts +81 -0
  40. package/app/api/agents/[id]/route.ts +280 -0
  41. package/app/api/agents/channels-summary/route.ts +39 -0
  42. package/app/api/agents/config-json/route.ts +58 -0
  43. package/app/api/agents/create/route.ts +488 -0
  44. package/app/api/agents/download-image/route.ts +32 -0
  45. package/app/api/agents/image-status/route.ts +126 -0
  46. package/app/api/agents/route.ts +213 -0
  47. package/app/api/agents/token/route.ts +94 -0
  48. package/app/api/agents-active/route.ts +190 -0
  49. package/app/api/agents-config/route.ts +629 -0
  50. package/app/api/alerts/smoke/route.ts +26 -0
  51. package/app/api/aliases/add/route.ts +28 -0
  52. package/app/api/aliases/remove/route.ts +27 -0
  53. package/app/api/assistant/route.ts +204 -0
  54. package/app/api/auth/check/route.ts +38 -0
  55. package/app/api/auth/login/route.ts +85 -0
  56. package/app/api/auth/logout/route.ts +7 -0
  57. package/app/api/auth/status/route.ts +33 -0
  58. package/app/api/config/env/route.ts +132 -0
  59. package/app/api/config/restart/route.ts +26 -0
  60. package/app/api/containers/route.ts +75 -0
  61. package/app/api/cost-override/route.ts +46 -0
  62. package/app/api/costs/route.ts +84 -0
  63. package/app/api/credentials/[id]/reveal/route.ts +21 -0
  64. package/app/api/credentials/[id]/route.ts +52 -0
  65. package/app/api/credentials/[id]/sync/route.ts +58 -0
  66. package/app/api/credentials/detect/route.ts +246 -0
  67. package/app/api/credentials/route.ts +33 -0
  68. package/app/api/crons/[id]/route.ts +69 -0
  69. package/app/api/crons/route.ts +12 -0
  70. package/app/api/crons/runs/route.ts +31 -0
  71. package/app/api/debug/route.ts +11 -0
  72. package/app/api/envcheck/route.ts +18 -0
  73. package/app/api/events/route.ts +24 -0
  74. package/app/api/gateway/agent/route.ts +154 -0
  75. package/app/api/gateway/provider/balance/route.ts +38 -0
  76. package/app/api/gateway/provider/keys.ts +38 -0
  77. package/app/api/gateway/provider/oauth/route.ts +304 -0
  78. package/app/api/gateway/provider/route.ts +209 -0
  79. package/app/api/gateway/route.ts +223 -0
  80. package/app/api/gateway/sync.ts +189 -0
  81. package/app/api/lineage/route.ts +54 -0
  82. package/app/api/memory-context/route.ts +15 -0
  83. package/app/api/metrics/route.ts +35 -0
  84. package/app/api/models/route.ts +77 -0
  85. package/app/api/plugins/route.js +37 -0
  86. package/app/api/provider/upstream.ts +142 -0
  87. package/app/api/provider/v1/chat/completions/route.ts +146 -0
  88. package/app/api/provider/v1/models/route.ts +167 -0
  89. package/app/api/provider-usage/route.ts +629 -0
  90. package/app/api/session/route.ts +39 -0
  91. package/app/api/sessions/[id]/route.ts +38 -0
  92. package/app/api/sessions/route.ts +23 -0
  93. package/app/api/setup/agent-image/route.ts +102 -0
  94. package/app/api/setup/password/route.ts +111 -0
  95. package/app/api/setup/restart/route.ts +76 -0
  96. package/app/api/skills/delete/route.js +105 -0
  97. package/app/api/skills/promote/route.js +129 -0
  98. package/app/api/skills/route.js +203 -0
  99. package/app/api/skills/save/route.js +142 -0
  100. package/app/api/stats/route.ts +34 -0
  101. package/app/api/stats-since/route.ts +20 -0
  102. package/app/api/stream/route.ts +70 -0
  103. package/app/api/system-health/route.ts +233 -0
  104. package/app/api/tool-calls/route.ts +24 -0
  105. package/app/api/tools-config/route.ts +163 -0
  106. package/app/api/update-check/route.ts +127 -0
  107. package/app/api/version/route.ts +25 -0
  108. package/app/api/wizard/complete/route.ts +32 -0
  109. package/app/api/wizard/reset/route.ts +28 -0
  110. package/app/api/wizard/status/route.ts +28 -0
  111. package/app/api/workspace/route.ts +392 -0
  112. package/app/api/workspace/stream/route.ts +136 -0
  113. package/app/components/AppShell.tsx +33 -0
  114. package/app/components/AuthGuard.tsx +91 -0
  115. package/app/components/CostBreakdown.tsx +37 -0
  116. package/app/components/DashboardHeader.tsx +73 -0
  117. package/app/components/DashboardLayout.tsx +76 -0
  118. package/app/components/DashboardToolbar.tsx +61 -0
  119. package/app/components/FloatingActions.tsx +33 -0
  120. package/app/components/Icons.tsx +78 -0
  121. package/app/components/LineageGraphPage.tsx +142 -0
  122. package/app/components/LiveFeed.tsx +48 -0
  123. package/app/components/MobileBottomNav.tsx +84 -0
  124. package/app/components/ModelPickerModal.tsx +94 -0
  125. package/app/components/PasswordInput.tsx +86 -0
  126. package/app/components/PulseChat.tsx +362 -0
  127. package/app/components/Rev4aLoader.tsx +48 -0
  128. package/app/components/SessionDrawer.tsx +263 -0
  129. package/app/components/SessionTopology.tsx +289 -0
  130. package/app/components/Sidebar.tsx +294 -0
  131. package/app/components/Skeleton.tsx +133 -0
  132. package/app/components/SystemCockpit.tsx +169 -0
  133. package/app/components/VersionBanner.tsx +105 -0
  134. package/app/components/WizardButton.tsx +30 -0
  135. package/app/components/ui/Badge.tsx +30 -0
  136. package/app/components/ui/Button.tsx +59 -0
  137. package/app/components/ui/ConfirmModal.tsx +46 -0
  138. package/app/components/ui/FilterBar.tsx +33 -0
  139. package/app/components/ui/Input.tsx +25 -0
  140. package/app/components/ui/ItemList.tsx +83 -0
  141. package/app/components/ui/LoadingSpinner.tsx +30 -0
  142. package/app/components/ui/Metric.tsx +22 -0
  143. package/app/components/ui/Modal.tsx +42 -0
  144. package/app/components/ui/Page.tsx +18 -0
  145. package/app/components/ui/Pill.tsx +6 -0
  146. package/app/components/ui/PropertyList.tsx +24 -0
  147. package/app/components/ui/Select.tsx +26 -0
  148. package/app/components/ui/StatusCard.tsx +32 -0
  149. package/app/components/ui/Surface.tsx +14 -0
  150. package/app/components/ui/Tabs.tsx +55 -0
  151. package/app/components/ui/TemplateOption.tsx +59 -0
  152. package/app/components/ui/Toast.tsx +44 -0
  153. package/app/components/ui/index.ts +19 -0
  154. package/app/components/ui/tokens.ts +18 -0
  155. package/app/config/PageClient.tsx +293 -0
  156. package/app/config/loading.tsx +10 -0
  157. package/app/config/page.tsx +7 -0
  158. package/app/containers/ContainersClient.tsx +129 -0
  159. package/app/containers/loading.tsx +21 -0
  160. package/app/containers/page.tsx +5 -0
  161. package/app/containers/terminal/[id]/TerminalClient.tsx +313 -0
  162. package/app/containers/terminal/[id]/page.tsx +6 -0
  163. package/app/credentials/PageClient.tsx +598 -0
  164. package/app/credentials/loading.tsx +10 -0
  165. package/app/credentials/page.tsx +7 -0
  166. package/app/crons/PageClient.tsx +538 -0
  167. package/app/crons/loading.tsx +10 -0
  168. package/app/crons/page.tsx +5 -0
  169. package/app/design-system.ts +57 -0
  170. package/app/gateway/PageClient.tsx +1048 -0
  171. package/app/gateway/loading.tsx +24 -0
  172. package/app/gateway/page.tsx +5 -0
  173. package/app/globals.css +1474 -0
  174. package/app/layout.tsx +45 -0
  175. package/app/lib/models-context.tsx +41 -0
  176. package/app/lineage/loading.tsx +10 -0
  177. package/app/lineage/page.tsx +13 -0
  178. package/app/loading.tsx +16 -0
  179. package/app/login/page.tsx +160 -0
  180. package/app/memory/MemoryContextPageClient.tsx +196 -0
  181. package/app/memory/loading.tsx +10 -0
  182. package/app/memory/page.tsx +8 -0
  183. package/app/page.tsx +31 -0
  184. package/app/plugins/PageClient.tsx +296 -0
  185. package/app/plugins/loading.tsx +10 -0
  186. package/app/plugins/page.tsx +5 -0
  187. package/app/setup/PageClient.tsx +227 -0
  188. package/app/setup/loading.tsx +21 -0
  189. package/app/setup/page.tsx +7 -0
  190. package/app/skills/PageClient.tsx +499 -0
  191. package/app/skills/loading.tsx +10 -0
  192. package/app/skills/page.tsx +5 -0
  193. package/app/tools/ToolsPageClient.tsx +308 -0
  194. package/app/tools/loading.tsx +10 -0
  195. package/app/tools/page.tsx +62 -0
  196. package/app/tools/tools-catalog.ts +141 -0
  197. package/app/wizard/PageClient.tsx +485 -0
  198. package/app/wizard/icons.tsx +89 -0
  199. package/app/wizard/loading.tsx +24 -0
  200. package/app/wizard/page.tsx +7 -0
  201. package/app/wizard/storage.ts +33 -0
  202. package/app/wizard/useWizard.ts +101 -0
  203. package/app/workspace/WorkspaceClient.tsx +633 -0
  204. package/app/workspace/loading.tsx +36 -0
  205. package/app/workspace/page.tsx +8 -0
  206. package/bin/docker-entrypoint.sh +21 -0
  207. package/bin/postinstall.js +20 -0
  208. package/bin/rev4a.js +696 -0
  209. package/daemon.js +783 -0
  210. package/docs/ARCHITECTURE.md +557 -0
  211. package/docs/CONTAINER-TERMINAL.md +280 -0
  212. package/docs/DESIGN-SYSTEM.md +99 -0
  213. package/docs/FRONTEND-ARCHITECTURE.md +119 -0
  214. package/docs/REV4A.md +504 -0
  215. package/docs/dev/API-REFERENCE.md +1835 -0
  216. package/docs/dev/DATABASE.md +224 -0
  217. package/docs/dev/GATEWAY.md +300 -0
  218. package/docs/dev/PROVIDERS.md +229 -0
  219. package/docs/dev/SESSION-MAINTENANCE-PLAN.md +607 -0
  220. package/docs/dev/WORKSPACE.md +199 -0
  221. package/docs/rag/DATA-FRESHNESS.md +109 -0
  222. package/docs/rag/GLOSSARY.md +125 -0
  223. package/docs/rag/REV4A-OVERVIEW.md +135 -0
  224. package/docs/rag/WHAT-I-CAN-ANSWER.md +124 -0
  225. package/lib/agent-setup.ts +130 -0
  226. package/lib/alerts.ts +115 -0
  227. package/lib/apiFetch.ts +7 -0
  228. package/lib/auth.ts +28 -0
  229. package/lib/billing.ts +100 -0
  230. package/lib/buildAgentImage.ts +346 -0
  231. package/lib/channelManager.ts +386 -0
  232. package/lib/container.ts +60 -0
  233. package/lib/credentials/db.ts +86 -0
  234. package/lib/credentials/delivery.ts +448 -0
  235. package/lib/credentials/detect.ts +149 -0
  236. package/lib/credentials/hash.ts +9 -0
  237. package/lib/credentials/providers.ts +110 -0
  238. package/lib/credentials/vault.ts +195 -0
  239. package/lib/db-bootstrap.d.ts +3 -0
  240. package/lib/db-bootstrap.mjs +144 -0
  241. package/lib/db.ts +25 -0
  242. package/lib/docker-utils.ts +60 -0
  243. package/lib/hooks/useDashboard.ts +264 -0
  244. package/lib/hooks/useRev4aTimezone.ts +28 -0
  245. package/lib/hooks/useScreenshot.ts +30 -0
  246. package/lib/memory-context.ts +385 -0
  247. package/lib/model-catalogue.ts +131 -0
  248. package/lib/model-pricing.ts +63 -0
  249. package/lib/openclaw-cron.ts +350 -0
  250. package/lib/patterns/ApiAdapter.ts +73 -0
  251. package/lib/patterns/ApiClient.ts +65 -0
  252. package/lib/patterns/EventBus.ts +113 -0
  253. package/lib/patterns/FilterStrategy.ts +79 -0
  254. package/lib/patterns/SessionFactory.ts +117 -0
  255. package/lib/patterns/sessionPresentation.ts +117 -0
  256. package/lib/provider-balance.ts +163 -0
  257. package/lib/requireAuth.tsx +38 -0
  258. package/lib/rev4a-auth.d.ts +3 -0
  259. package/lib/rev4a-auth.js +36 -0
  260. package/lib/rev4a-paths.ts +145 -0
  261. package/lib/timezone.ts +46 -0
  262. package/lib/types/index.ts +96 -0
  263. package/lib/utils/format.ts +82 -0
  264. package/lineage.js +56 -0
  265. package/model-pricing.json +482 -0
  266. package/models.config.json +844 -0
  267. package/next-env.d.ts +6 -0
  268. package/next.config.mjs +7 -0
  269. package/package.json +82 -0
  270. package/public/apple-touch-icon.png +0 -0
  271. package/public/favicon-64.png +0 -0
  272. package/public/favicon.svg +58 -0
  273. package/public/icon-192-maskable.png +0 -0
  274. package/public/icon-192.png +0 -0
  275. package/public/icon-512-maskable.png +0 -0
  276. package/public/icon-512.png +0 -0
  277. package/public/manifest.json +38 -0
  278. package/public/rev4a-logo.png +0 -0
  279. package/public/rev4a-logo.svg +58 -0
  280. package/rev4a-rules/AGENTS.md +37 -0
  281. package/scripts/add-auth.cjs +185 -0
  282. package/scripts/backup.sh +66 -0
  283. package/scripts/check-providers.mjs +143 -0
  284. package/scripts/docker/traefik/docker-compose.yml +32 -0
  285. package/scripts/docker/traefik/dynamic/.gitkeep +18 -0
  286. package/scripts/docker/traefik/traefik.yml +37 -0
  287. package/scripts/remove-auth-profile.js +56 -0
  288. package/scripts/restore.sh +82 -0
  289. package/terminal-ws-server.js +139 -0
  290. package/tsconfig.json +42 -0
@@ -0,0 +1,598 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { Page, PageHeader, Button, Modal, Input, Pill } from '@/app/components/ui';
5
+ import type { CredentialProfile } from '@/lib/credentials/vault';
6
+ import type { CredentialProviderDef } from '@/lib/credentials/providers';
7
+
8
+ interface ApiData {
9
+ profiles: CredentialProfile[];
10
+ providers: CredentialProviderDef[];
11
+ }
12
+
13
+ type FieldValues = Record<string, string>;
14
+
15
+ const CATEGORY_LABEL: Record<string, string> = {
16
+ vcs: 'Version Control',
17
+ pm: 'Project Management',
18
+ cloud: 'Cloud',
19
+ db: 'Database',
20
+ };
21
+
22
+ const PROVIDER_COLOR: Record<string, string> = {
23
+ 'github-pat': '#24292e',
24
+ trello: '#0052cc',
25
+ vercel: '#000000',
26
+ supabase: '#3ecf8e',
27
+ notion: '#050505',
28
+ };
29
+
30
+ function Checkbox({ checked, onChange, label }: { checked: boolean; onChange: () => void; label: string }) {
31
+ return (
32
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 12, padding: '4px 0' }}>
33
+ <input type="checkbox" checked={checked} onChange={onChange} style={{ accentColor: 'var(--violet)' }} />
34
+ <span style={{ fontFamily: 'var(--font-mono)', color: 'var(--text)' }}>{label}</span>
35
+ </label>
36
+ );
37
+ }
38
+
39
+ function formatRelative(ts: number | null): string {
40
+ if (!ts) return 'never';
41
+ const diff = Math.floor((Date.now() - ts) / 1000);
42
+ if (diff < 60) return 'just now';
43
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
44
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
45
+ return `${Math.floor(diff / 86400)}d ago`;
46
+ }
47
+
48
+ export default function CredentialsPageClient() {
49
+ const [data, setData] = useState<ApiData | null>(null);
50
+ const [loading, setLoading] = useState(true);
51
+ const [error, setError] = useState<string | null>(null);
52
+
53
+ const [detectData, setDetectData] = useState<{
54
+ container: string;
55
+ agentId: string;
56
+ displayName?: string;
57
+ githubLoggedIn: boolean;
58
+ githubUser: string;
59
+ githubCliInstalled: boolean;
60
+ vercelLoggedIn: boolean;
61
+ vercelUser: string;
62
+ vercelTeam: string;
63
+ vercelCliInstalled: boolean;
64
+ supabaseLoggedIn: boolean;
65
+ supabaseLinked: boolean;
66
+ supabaseProjectRef: string;
67
+ supabaseCliInstalled: boolean;
68
+ trelloLoggedIn: boolean;
69
+ trelloCliInstalled: boolean;
70
+ notionLoggedIn: boolean;
71
+ matchedProfiles?: { profileId: string; providerId: string; label: string }[];
72
+ }[]>([]);
73
+
74
+ const [configureProviderId, setConfigureProviderId] = useState<string | null>(null);
75
+ const [editProfile, setEditProfile] = useState<CredentialProfile | null>(null);
76
+ const [deleteId, setDeleteId] = useState<string | null>(null);
77
+ const [toast, setToast] = useState<string | null>(null);
78
+
79
+ const load = useCallback(async (signal?: AbortSignal) => {
80
+ try {
81
+ const [credsRes, detectRes] = await Promise.all([
82
+ fetch('/api/credentials', { signal }),
83
+ fetch('/api/credentials/detect', { signal }),
84
+ ]);
85
+ if (!credsRes.ok) throw new Error(`HTTP ${credsRes.status}`);
86
+ const json = await credsRes.json() as ApiData;
87
+ const detectJson = await detectRes.json() as { agents?: {
88
+ container: string;
89
+ agentId: string;
90
+ displayName?: string;
91
+ githubLoggedIn: boolean;
92
+ githubUser: string;
93
+ githubCliInstalled: boolean;
94
+ vercelLoggedIn: boolean;
95
+ vercelUser: string;
96
+ vercelTeam: string;
97
+ vercelCliInstalled: boolean;
98
+ supabaseLoggedIn: boolean;
99
+ supabaseLinked: boolean;
100
+ supabaseProjectRef: string;
101
+ supabaseCliInstalled: boolean;
102
+ trelloLoggedIn: boolean;
103
+ trelloCliInstalled: boolean;
104
+ notionLoggedIn: boolean;
105
+ matchedProfiles?: { profileId: string; providerId: string; label: string }[];
106
+ }[] };
107
+ setData(json);
108
+ setDetectData(detectJson.agents ?? []);
109
+ setError(null);
110
+ } catch (e: unknown) {
111
+ if ((e as Error)?.name === 'AbortError') return;
112
+ setError((e as Error).message);
113
+ } finally {
114
+ if (!signal?.aborted) setLoading(false);
115
+ }
116
+ }, []);
117
+
118
+ useEffect(() => {
119
+ const controller = new AbortController();
120
+ void load(controller.signal);
121
+ return () => controller.abort();
122
+ }, [load]);
123
+
124
+ const refreshDetectOnly = async () => {
125
+ try {
126
+ const res = await fetch('/api/credentials/detect');
127
+ const json = await res.json() as { agents?: unknown[] };
128
+ setDetectData((json.agents ?? []) as typeof detectData);
129
+ } catch { /* best-effort */ }
130
+ };
131
+
132
+ const showToast = (msg: string) => {
133
+ setToast(msg);
134
+ setTimeout(() => setToast(null), 3000);
135
+ };
136
+
137
+ const [syncingContainer, setSyncingContainer] = useState<string | null>(null);
138
+
139
+ const handleSyncOne = async (profileId: string, container: string, label?: string) => {
140
+ setSyncingContainer(container);
141
+ try {
142
+ const res = await fetch(`/api/credentials/${profileId}/sync`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify({ containers: [container] }),
146
+ });
147
+ const json = await res.json() as { results?: { container: string; status: string }[]; error?: string };
148
+ if (json.error) throw new Error(json.error);
149
+ showToast(json.results?.[0]?.status === 'ok' ? `Synced to ${label || container}` : 'Sync error');
150
+ await refreshDetectOnly();
151
+ } catch (e: unknown) {
152
+ showToast((e as Error).message);
153
+ } finally {
154
+ setSyncingContainer(null);
155
+ }
156
+ };
157
+
158
+ const handleDeSyncOne = async (profileId: string, container: string, label?: string) => {
159
+ setSyncingContainer(container);
160
+ try {
161
+ const res = await fetch(`/api/credentials/${profileId}/sync`, {
162
+ method: 'DELETE',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify({ container }),
165
+ });
166
+ const json = await res.json() as { ok?: boolean; error?: string };
167
+ if (json.error) throw new Error(json.error);
168
+ showToast(`Removed from ${label || container}`);
169
+ await refreshDetectOnly();
170
+ } catch (e: unknown) {
171
+ showToast((e as Error).message);
172
+ } finally {
173
+ setSyncingContainer(null);
174
+ }
175
+ };
176
+
177
+ const handleDelete = async (id: string) => {
178
+ try {
179
+ await fetch(`/api/credentials/${id}`, { method: 'DELETE' });
180
+ setDeleteId(null);
181
+ showToast('Credential deleted');
182
+ void load();
183
+ } catch (e: unknown) {
184
+ showToast((e as Error).message);
185
+ }
186
+ };
187
+
188
+ if (loading) {
189
+ return (
190
+ <Page>
191
+ <PageHeader eyebrow="" title="Credentials" />
192
+ <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>Loading…</p>
193
+ </Page>
194
+ );
195
+ }
196
+
197
+ const profiles = data?.profiles ?? [];
198
+ const providers = data?.providers ?? [];
199
+
200
+ // Group profiles by provider
201
+ const profilesByProvider = providers.reduce<Record<string, CredentialProfile[]>>((acc, prov) => {
202
+ acc[prov.id] = profiles.filter((p) => p.providerId === prov.id);
203
+ return acc;
204
+ }, {});
205
+
206
+ return (
207
+ <Page>
208
+ <PageHeader
209
+ eyebrow=""
210
+ title="Credentials"
211
+ description="Store third-party credentials once, sync to all agent containers."
212
+ />
213
+
214
+ {error && (
215
+ <div style={{ padding: '10px 14px', background: 'var(--bg2)', border: '1px solid var(--red)', color: 'var(--red)', fontSize: 12, marginBottom: 16 }}>
216
+ {error}
217
+ </div>
218
+ )}
219
+
220
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 'clamp(12px, 3vw, 16px)' }}>
221
+ {providers.map((provider) => {
222
+ const provProfiles = profilesByProvider[provider.id] ?? [];
223
+ const isConfigured = provProfiles.length > 0;
224
+ const accentColor = PROVIDER_COLOR[provider.id] ?? 'var(--violet)';
225
+
226
+ return (
227
+ <div
228
+ key={provider.id}
229
+ style={{
230
+ background: 'var(--bg2)',
231
+ border: '1px solid var(--border)',
232
+ display: 'flex',
233
+ flexDirection: 'column',
234
+ opacity: isConfigured ? 1 : 0.65,
235
+ transition: 'opacity 0.15s',
236
+ }}
237
+ >
238
+ {/* Card header */}
239
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 14px', borderBottom: '1px solid var(--border)' }}>
240
+ <div style={{
241
+ width: 28, height: 28, background: accentColor, flexShrink: 0,
242
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
243
+ fontSize: 11, fontWeight: 700, color: '#fff', letterSpacing: '0.02em',
244
+ fontFamily: 'var(--font-mono)',
245
+ }}>
246
+ {provider.label.slice(0, 2).toUpperCase()}
247
+ </div>
248
+ <div style={{ flex: 1, minWidth: 0 }}>
249
+ <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', fontFamily: 'var(--font-mono)' }}>
250
+ {provider.label}
251
+ </div>
252
+ <div style={{ fontSize: 11, color: 'var(--text-dim)' }}>
253
+ {CATEGORY_LABEL[provider.category] ?? provider.category}
254
+ </div>
255
+ </div>
256
+ {isConfigured ? (
257
+ <Pill tone="success">{provProfiles.length} configured</Pill>
258
+ ) : (
259
+ <Pill tone="neutral">Not set up</Pill>
260
+ )}
261
+ </div>
262
+
263
+ {/* Description */}
264
+ <div style={{ padding: '10px 14px', fontSize: 12, color: 'var(--text-dim)', borderBottom: '1px solid var(--border)' }}>
265
+ {provider.description}
266
+ {provider.docsUrl && (
267
+ <a href={provider.docsUrl} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--violet)', marginLeft: 6, fontSize: 11 }}>
268
+ docs ↗
269
+ </a>
270
+ )}
271
+ </div>
272
+
273
+ {/* Delivery method */}
274
+ {provider.deliveryMethod && (
275
+ <div style={{ padding: '8px 14px', borderBottom: '1px solid var(--border)', fontSize: 11, color: 'var(--text-dim)', fontFamily: 'var(--font-mono)' }}>
276
+ {provider.deliveryMethod}
277
+ </div>
278
+ )}
279
+
280
+ {/* Configured credentials list */}
281
+ {provProfiles.length > 0 && (
282
+ <div style={{ display: 'flex', flexDirection: 'column' }}>
283
+ {provProfiles.map((p) => {
284
+ const providerType = p.providerId;
285
+ const isGithub = providerType === 'github-pat';
286
+ const isVercel = providerType === 'vercel';
287
+ const isSupabase = providerType === 'supabase';
288
+ const isTrello = providerType === 'trello';
289
+ const isNotion = providerType === 'notion';
290
+
291
+ return (
292
+ <div key={p.id} style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
293
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
294
+ <div style={{ flex: 1, minWidth: 0 }}>
295
+ <div style={{ fontSize: 12, color: 'var(--text)', fontWeight: 500, marginBottom: 2 }}>{p.label}</div>
296
+ <div style={{ display: 'flex', gap: 10, fontSize: 11, color: 'var(--text-dim)', fontFamily: 'var(--font-mono)' }}>
297
+ <span>{p.secretMasked ?? '••••••••'}</span>
298
+ <span style={{ color: p.status === 'active' ? 'var(--green)' : 'var(--red)' }}>●</span>
299
+ <span>synced {formatRelative(p.lastSyncedAt)}</span>
300
+ </div>
301
+ </div>
302
+ <div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
303
+ <Button type="button" variant="ghost" size="sm" onClick={() => setEditProfile(p)}>Edit</Button>
304
+ {detectData.some((d) => d.matchedProfiles?.some((m) => m.profileId === p.id)) ? null : (
305
+ <Button type="button" variant="danger" size="sm" onClick={() => setDeleteId(p.id)}>×</Button>
306
+ )}
307
+ </div>
308
+ </div>
309
+ {/* Agent detect status — all containers shown for every provider */}
310
+ {detectData.length > 0 && (
311
+ <div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 3 }}>
312
+ {detectData.map((d) => {
313
+ const matchByProfile = d.matchedProfiles?.some((m) => m.profileId === p.id);
314
+
315
+ // Determine CLI installed status for this provider on this agent
316
+ const cliOk = isGithub
317
+ ? d.githubCliInstalled
318
+ : isVercel
319
+ ? d.vercelCliInstalled
320
+ : isSupabase
321
+ ? d.supabaseCliInstalled
322
+ : isTrello
323
+ ? d.trelloCliInstalled
324
+ : true; // notion has no CLI — always available
325
+
326
+ let extraInfo = '';
327
+ if (matchByProfile) {
328
+ if (isGithub && d.githubUser) {
329
+ extraInfo = d.githubUser;
330
+ } else if (isVercel) {
331
+ const parts = [];
332
+ if (d.vercelUser) parts.push(d.vercelUser);
333
+ if (d.vercelTeam) parts.push('@' + d.vercelTeam);
334
+ extraInfo = parts.join(' ');
335
+ } else if (isSupabase) {
336
+ const parts = [];
337
+ if (d.supabaseLoggedIn) parts.push('logged in');
338
+ if (d.supabaseLinked) parts.push('linked: ' + d.supabaseProjectRef);
339
+ extraInfo = parts.join(', ');
340
+ } else if (isTrello) {
341
+ extraInfo = '';
342
+ }
343
+ }
344
+
345
+ // Show CLI missing indicator when not matched and CLI is absent
346
+ const showCliMissing = !matchByProfile && !cliOk;
347
+
348
+ return (
349
+ <div key={d.container || d.agentId} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, fontFamily: 'var(--font-mono)' }}>
350
+ <span style={{ color: matchByProfile ? 'var(--green)' : 'var(--text-dim)', fontSize: 12, fontWeight: 700 }}>
351
+ {matchByProfile ? '✓' : '○'}
352
+ </span>
353
+ <span style={{ color: 'var(--text-dim)', flex: 1 }}>
354
+ {d.displayName || d.agentId}
355
+ {extraInfo && (
356
+ <span style={{ marginLeft: 6, color: 'var(--green)', fontSize: 10 }}>{extraInfo}</span>
357
+ )}
358
+ {showCliMissing && (
359
+ <span style={{ marginLeft: 6, color: 'var(--red)', fontSize: 10 }}>CLI not installed</span>
360
+ )}
361
+ </span>
362
+ <div style={{ display: 'flex', gap: 4, flexShrink: 0 }}>
363
+ {showCliMissing ? (
364
+ <span style={{ fontSize: 10, color: 'var(--text-dim)', padding: '4px 8px', border: '1px solid var(--border)' }}>
365
+ Rebuild image
366
+ </span>
367
+ ) : matchByProfile ? (
368
+ <Button
369
+ variant='danger'
370
+ size='sm'
371
+ loading={syncingContainer === d.container}
372
+ disabled={syncingContainer === d.container}
373
+ onClick={() => handleDeSyncOne(p.id, d.container, d.displayName || d.agentId)}
374
+ >
375
+ De-sync
376
+ </Button>
377
+ ) : (
378
+ <Button
379
+ variant='primary'
380
+ size='sm'
381
+ loading={syncingContainer === d.container}
382
+ disabled={syncingContainer === d.container}
383
+ onClick={() => handleSyncOne(p.id, d.container, d.displayName || d.agentId)}
384
+ >
385
+ Sync
386
+ </Button>
387
+ )}
388
+ </div>
389
+ </div>
390
+ );
391
+ })}
392
+ </div>
393
+ )}
394
+ </div>
395
+ );
396
+ })}
397
+ </div>
398
+ )}
399
+
400
+ {/* Footer action */}
401
+ <div style={{ padding: '10px 14px', marginTop: 'auto' }}>
402
+ <Button
403
+ type="button"
404
+ variant={isConfigured ? 'ghost' : 'primary'}
405
+ size="sm"
406
+ onClick={() => setConfigureProviderId(provider.id)}
407
+ >
408
+ {isConfigured ? '+ Add another' : 'Configure'}
409
+ </Button>
410
+ </div>
411
+ </div>
412
+ );
413
+ })}
414
+ </div>
415
+
416
+ {/* Configure / Add Modal */}
417
+ <AddEditModal
418
+ open={!!configureProviderId}
419
+ providers={providers}
420
+ initialProviderId={configureProviderId ?? undefined}
421
+ onClose={() => setConfigureProviderId(null)}
422
+ onSaved={() => { setConfigureProviderId(null); showToast('Credential saved'); void load(); }}
423
+ />
424
+
425
+ {/* Edit Modal */}
426
+ {editProfile && (
427
+ <AddEditModal
428
+ open
429
+ providers={providers}
430
+ editProfile={editProfile}
431
+ onClose={() => setEditProfile(null)}
432
+ onSaved={() => { setEditProfile(null); showToast('Credential updated'); void load(); }}
433
+ />
434
+ )}
435
+
436
+ {/* Delete Confirm Modal */}
437
+ <Modal open={!!deleteId} onClose={() => setDeleteId(null)} title="Delete Credential" maxWidth={440}>
438
+ <p style={{ fontSize: 13, color: 'var(--text)', marginBottom: 20 }}>
439
+ This will permanently delete the credential and its secret. This cannot be undone.
440
+ </p>
441
+ <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
442
+ <Button type="button" variant="secondary" size="sm" onClick={() => setDeleteId(null)}>Cancel</Button>
443
+ <Button type="button" variant="danger" size="sm" onClick={() => { if (deleteId) void handleDelete(deleteId); }}>Delete</Button>
444
+ </div>
445
+ </Modal>
446
+
447
+ {toast && (
448
+ <div style={{
449
+ position: 'fixed', bottom: 24, right: 24, padding: '10px 16px',
450
+ background: 'var(--bg2)', border: '1px solid var(--border)',
451
+ fontSize: 13, color: 'var(--text)', zIndex: 9999,
452
+ }}>
453
+ {toast}
454
+ </div>
455
+ )}
456
+ </Page>
457
+ );
458
+ }
459
+
460
+ interface AddEditModalProps {
461
+ open: boolean;
462
+ providers: CredentialProviderDef[];
463
+ initialProviderId?: string;
464
+ editProfile?: CredentialProfile | null;
465
+ onClose: () => void;
466
+ onSaved: () => void;
467
+ }
468
+
469
+ function AddEditModal({ open, providers, initialProviderId, editProfile, onClose, onSaved }: AddEditModalProps) {
470
+ const isEdit = !!editProfile;
471
+ const [label, setLabel] = useState(editProfile?.label ?? '');
472
+ const [fields, setFields] = useState<FieldValues>({});
473
+ const [saving, setSaving] = useState(false);
474
+ const [error, setError] = useState<string | null>(null);
475
+
476
+ const providerId = editProfile?.providerId ?? initialProviderId ?? providers[0]?.id ?? '';
477
+ const provider = providers.find((p) => p.id === providerId);
478
+
479
+ useEffect(() => {
480
+ if (open) {
481
+ setLabel(editProfile?.label ?? '');
482
+ setFields({});
483
+ setError(null);
484
+ }
485
+ }, [open, editProfile]);
486
+
487
+ const setField = (key: string, value: string) => setFields((prev) => ({ ...prev, [key]: value }));
488
+
489
+ const handleSave = async () => {
490
+ if (!provider) return;
491
+ if (!label.trim()) { setError('Label is required'); return; }
492
+ if (!isEdit) {
493
+ const missingField = provider.fields.find((f) => !fields[f.key]?.trim());
494
+ if (missingField) { setError(`${missingField.label} is required`); return; }
495
+ }
496
+
497
+ setSaving(true);
498
+ setError(null);
499
+ try {
500
+ if (isEdit && editProfile) {
501
+ const res = await fetch(`/api/credentials/${editProfile.id}`, {
502
+ method: 'PATCH',
503
+ headers: { 'Content-Type': 'application/json' },
504
+ body: JSON.stringify({ label: label.trim(), secret: fields }),
505
+ });
506
+ const json = await res.json() as { error?: string };
507
+ if (json.error) throw new Error(json.error);
508
+ } else {
509
+ const res = await fetch('/api/credentials', {
510
+ method: 'POST',
511
+ headers: { 'Content-Type': 'application/json' },
512
+ body: JSON.stringify({ providerId, label: label.trim(), secret: fields }),
513
+ });
514
+ const json = await res.json() as { error?: string };
515
+ if (json.error) throw new Error(json.error);
516
+ }
517
+ onSaved();
518
+ } catch (e: unknown) {
519
+ setError((e as Error).message);
520
+ } finally {
521
+ setSaving(false);
522
+ }
523
+ };
524
+
525
+ const modalTitle = isEdit
526
+ ? `Edit — ${provider?.label ?? providerId}`
527
+ : `Configure — ${provider?.label ?? providerId}`;
528
+
529
+ return (
530
+ <Modal open={open} onClose={onClose} title={modalTitle} maxWidth={520}>
531
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
532
+ {provider && (
533
+ <div style={{ fontSize: 11, color: 'var(--text-dim)', padding: '6px 10px', background: 'var(--bg)', border: '1px solid var(--border)' }}>
534
+ {provider.description}
535
+ </div>
536
+ )}
537
+
538
+ <Input
539
+ label="Label"
540
+ value={label}
541
+ onChange={(e) => setLabel(e.target.value)}
542
+ placeholder={`e.g. Production ${provider?.label ?? ''}`}
543
+ />
544
+
545
+ {provider?.fields.map((field) => (
546
+ <div key={field.key}>
547
+ <Input
548
+ label={field.label}
549
+ type={field.type}
550
+ value={fields[field.key] ?? ''}
551
+ onChange={(e) => setField(field.key, e.target.value)}
552
+ placeholder={isEdit ? '(leave empty to keep current)' : field.placeholder}
553
+ autoComplete="off"
554
+ />
555
+ {field.hint && (
556
+ <div style={{ fontSize: 11, color: 'var(--text-dim)', marginTop: 4 }}>{field.hint}</div>
557
+ )}
558
+ {provider.id === 'trello' && field.key === 'apiKey' && fields['apiKey'] && (
559
+ <button
560
+ onClick={() =>
561
+ window.open(
562
+ `https://trello.com/1/authorize?key=${encodeURIComponent(fields['apiKey'])}&response_type=token&scope=read,write&expiration=never&name=Rev4a`,
563
+ '_blank',
564
+ )
565
+ }
566
+ style={{
567
+ marginTop: 6,
568
+ fontSize: 11,
569
+ padding: '4px 10px',
570
+ background: 'var(--accent)',
571
+ color: '#fff',
572
+ border: 'none',
573
+ borderRadius: 0,
574
+ cursor: 'pointer',
575
+ }}
576
+ >
577
+ Generate Trello Token
578
+ </button>
579
+ )}
580
+ </div>
581
+ ))}
582
+
583
+ {error && (
584
+ <div style={{ fontSize: 12, color: 'var(--red)', padding: '6px 10px', border: '1px solid var(--red)' }}>
585
+ {error}
586
+ </div>
587
+ )}
588
+
589
+ <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
590
+ <Button type="button" variant="secondary" size="sm" onClick={onClose}>Cancel</Button>
591
+ <Button type="button" variant="primary" size="sm" loading={saving} onClick={() => void handleSave()}>
592
+ {isEdit ? 'Save Changes' : 'Save'}
593
+ </Button>
594
+ </div>
595
+ </div>
596
+ </Modal>
597
+ );
598
+ }
@@ -0,0 +1,10 @@
1
+ import { Skeleton, CardRowSkeleton } from '../components/Skeleton';
2
+
3
+ const PageSkeleton = () => (
4
+ <div style={{ padding: '24px 20px', maxWidth: 1080, margin: '0 auto' }}>
5
+ <Skeleton width={120} height={20} accent style={{ marginBottom: 20 }} />
6
+ <CardRowSkeleton count={5} height={48} />
7
+ </div>
8
+ );
9
+
10
+ export default PageSkeleton;
@@ -0,0 +1,7 @@
1
+ import CredentialsPageClient from './PageClient';
2
+
3
+ export const dynamic = 'force-dynamic';
4
+
5
+ export default function CredentialsPage() {
6
+ return <CredentialsPageClient />;
7
+ }