@agent-native/dispatch 0.6.0 → 0.7.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 (159) hide show
  1. package/README.md +1 -1
  2. package/dist/actions/create-pylon-ticket.d.ts +3 -0
  3. package/dist/actions/create-pylon-ticket.d.ts.map +1 -0
  4. package/dist/actions/create-pylon-ticket.js +94 -0
  5. package/dist/actions/create-pylon-ticket.js.map +1 -0
  6. package/dist/actions/create-vault-grant.js +1 -1
  7. package/dist/actions/create-vault-grant.js.map +1 -1
  8. package/dist/actions/create-vault-secret.d.ts.map +1 -1
  9. package/dist/actions/create-vault-secret.js +4 -3
  10. package/dist/actions/create-vault-secret.js.map +1 -1
  11. package/dist/actions/get-vault-access-settings.d.ts +3 -0
  12. package/dist/actions/get-vault-access-settings.d.ts.map +1 -0
  13. package/dist/actions/get-vault-access-settings.js +10 -0
  14. package/dist/actions/get-vault-access-settings.js.map +1 -0
  15. package/dist/actions/grant-vault-secrets-to-app.js +1 -1
  16. package/dist/actions/grant-vault-secrets-to-app.js.map +1 -1
  17. package/dist/actions/index.d.ts.map +1 -1
  18. package/dist/actions/index.js +8 -0
  19. package/dist/actions/index.js.map +1 -1
  20. package/dist/actions/list-integrations-catalog.js +1 -1
  21. package/dist/actions/list-integrations-catalog.js.map +1 -1
  22. package/dist/actions/list-vault-grants.js +1 -1
  23. package/dist/actions/list-vault-grants.js.map +1 -1
  24. package/dist/actions/list-workspace-apps.d.ts.map +1 -1
  25. package/dist/actions/list-workspace-apps.js +5 -1
  26. package/dist/actions/list-workspace-apps.js.map +1 -1
  27. package/dist/actions/set-vault-access-settings.d.ts +3 -0
  28. package/dist/actions/set-vault-access-settings.d.ts.map +1 -0
  29. package/dist/actions/set-vault-access-settings.js +13 -0
  30. package/dist/actions/set-vault-access-settings.js.map +1 -0
  31. package/dist/actions/start-workspace-app-creation.d.ts.map +1 -1
  32. package/dist/actions/start-workspace-app-creation.js +6 -0
  33. package/dist/actions/start-workspace-app-creation.js.map +1 -1
  34. package/dist/actions/sync-vault-to-app.js +1 -1
  35. package/dist/actions/sync-vault-to-app.js.map +1 -1
  36. package/dist/actions/update-workspace-app-metadata.d.ts +3 -0
  37. package/dist/actions/update-workspace-app-metadata.d.ts.map +1 -0
  38. package/dist/actions/update-workspace-app-metadata.js +30 -0
  39. package/dist/actions/update-workspace-app-metadata.js.map +1 -0
  40. package/dist/actions/view-screen.d.ts.map +1 -1
  41. package/dist/actions/view-screen.js +4 -2
  42. package/dist/actions/view-screen.js.map +1 -1
  43. package/dist/components/app-keys-popover.d.ts.map +1 -1
  44. package/dist/components/app-keys-popover.js +17 -5
  45. package/dist/components/app-keys-popover.js.map +1 -1
  46. package/dist/components/create-app-popover.d.ts.map +1 -1
  47. package/dist/components/create-app-popover.js +38 -14
  48. package/dist/components/create-app-popover.js.map +1 -1
  49. package/dist/components/dispatch-shell.d.ts +4 -4
  50. package/dist/components/dispatch-shell.d.ts.map +1 -1
  51. package/dist/components/dispatch-shell.js +6 -6
  52. package/dist/components/dispatch-shell.js.map +1 -1
  53. package/dist/components/layout/Layout.d.ts.map +1 -1
  54. package/dist/components/layout/Layout.js +10 -3
  55. package/dist/components/layout/Layout.js.map +1 -1
  56. package/dist/components/messaging-setup-panel.d.ts.map +1 -1
  57. package/dist/components/messaging-setup-panel.js +2 -2
  58. package/dist/components/messaging-setup-panel.js.map +1 -1
  59. package/dist/components/workspace-app-card.d.ts.map +1 -1
  60. package/dist/components/workspace-app-card.js +41 -2
  61. package/dist/components/workspace-app-card.js.map +1 -1
  62. package/dist/hooks/use-navigation-state.js +12 -5
  63. package/dist/hooks/use-navigation-state.js.map +1 -1
  64. package/dist/lib/catch-all-target.d.ts +2 -0
  65. package/dist/lib/catch-all-target.d.ts.map +1 -0
  66. package/dist/lib/catch-all-target.js +95 -0
  67. package/dist/lib/catch-all-target.js.map +1 -0
  68. package/dist/lib/workspace-apps.d.ts +9 -0
  69. package/dist/lib/workspace-apps.d.ts.map +1 -1
  70. package/dist/lib/workspace-apps.js.map +1 -1
  71. package/dist/routes/pages/$appId.d.ts +2 -24
  72. package/dist/routes/pages/$appId.d.ts.map +1 -1
  73. package/dist/routes/pages/$appId.js +42 -8
  74. package/dist/routes/pages/$appId.js.map +1 -1
  75. package/dist/routes/pages/approval.d.ts.map +1 -1
  76. package/dist/routes/pages/approval.js +2 -1
  77. package/dist/routes/pages/approval.js.map +1 -1
  78. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  79. package/dist/routes/pages/apps.$appId.js +2 -1
  80. package/dist/routes/pages/apps.$appId.js.map +1 -1
  81. package/dist/routes/pages/integrations.d.ts.map +1 -1
  82. package/dist/routes/pages/integrations.js +20 -15
  83. package/dist/routes/pages/integrations.js.map +1 -1
  84. package/dist/routes/pages/new-app.js +1 -1
  85. package/dist/routes/pages/new-app.js.map +1 -1
  86. package/dist/routes/pages/overview.d.ts.map +1 -1
  87. package/dist/routes/pages/overview.js +14 -1
  88. package/dist/routes/pages/overview.js.map +1 -1
  89. package/dist/routes/pages/vault.d.ts.map +1 -1
  90. package/dist/routes/pages/vault.js +25 -6
  91. package/dist/routes/pages/vault.js.map +1 -1
  92. package/dist/routes/pages/workspace.d.ts.map +1 -1
  93. package/dist/routes/pages/workspace.js +5 -3
  94. package/dist/routes/pages/workspace.js.map +1 -1
  95. package/dist/server/lib/app-creation-store.d.ts +13 -0
  96. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  97. package/dist/server/lib/app-creation-store.js +295 -9
  98. package/dist/server/lib/app-creation-store.js.map +1 -1
  99. package/dist/server/lib/env-config.d.ts.map +1 -1
  100. package/dist/server/lib/env-config.js +5 -0
  101. package/dist/server/lib/env-config.js.map +1 -1
  102. package/dist/server/lib/onboarding-steps.d.ts +12 -0
  103. package/dist/server/lib/onboarding-steps.d.ts.map +1 -0
  104. package/dist/server/lib/onboarding-steps.js +47 -0
  105. package/dist/server/lib/onboarding-steps.js.map +1 -0
  106. package/dist/server/lib/vault-store.d.ts +55 -0
  107. package/dist/server/lib/vault-store.d.ts.map +1 -1
  108. package/dist/server/lib/vault-store.js +210 -41
  109. package/dist/server/lib/vault-store.js.map +1 -1
  110. package/dist/server/plugins/agent-chat.d.ts.map +1 -1
  111. package/dist/server/plugins/agent-chat.js +2 -1
  112. package/dist/server/plugins/agent-chat.js.map +1 -1
  113. package/dist/server/plugins/core-routes.d.ts.map +1 -1
  114. package/dist/server/plugins/core-routes.js +4 -0
  115. package/dist/server/plugins/core-routes.js.map +1 -1
  116. package/dist/server/plugins/integrations.js +2 -2
  117. package/dist/server/plugins/integrations.js.map +1 -1
  118. package/package.json +13 -11
  119. package/src/actions/create-pylon-ticket.ts +109 -0
  120. package/src/actions/create-vault-grant.ts +1 -1
  121. package/src/actions/create-vault-secret.ts +4 -3
  122. package/src/actions/get-vault-access-settings.ts +11 -0
  123. package/src/actions/grant-vault-secrets-to-app.ts +1 -1
  124. package/src/actions/index.ts +8 -0
  125. package/src/actions/list-integrations-catalog.ts +1 -1
  126. package/src/actions/list-vault-grants.ts +1 -1
  127. package/src/actions/list-workspace-apps.ts +5 -1
  128. package/src/actions/set-vault-access-settings.ts +16 -0
  129. package/src/actions/start-workspace-app-creation.ts +8 -0
  130. package/src/actions/sync-vault-to-app.ts +1 -1
  131. package/src/actions/update-workspace-app-metadata.ts +32 -0
  132. package/src/actions/view-screen.ts +4 -1
  133. package/src/components/app-keys-popover.tsx +38 -8
  134. package/src/components/create-app-popover.tsx +47 -14
  135. package/src/components/dispatch-shell.tsx +16 -15
  136. package/src/components/layout/Layout.tsx +11 -5
  137. package/src/components/messaging-setup-panel.tsx +54 -39
  138. package/src/components/workspace-app-card.tsx +102 -0
  139. package/src/hooks/use-navigation-state.ts +10 -4
  140. package/src/lib/catch-all-target.spec.ts +218 -0
  141. package/src/lib/catch-all-target.ts +99 -0
  142. package/src/lib/workspace-apps.ts +9 -0
  143. package/src/routes/pages/$appId.tsx +45 -7
  144. package/src/routes/pages/approval.tsx +33 -3
  145. package/src/routes/pages/apps.$appId.tsx +6 -1
  146. package/src/routes/pages/integrations.tsx +57 -18
  147. package/src/routes/pages/new-app.tsx +1 -1
  148. package/src/routes/pages/overview.tsx +69 -29
  149. package/src/routes/pages/vault.tsx +101 -21
  150. package/src/routes/pages/workspace.tsx +21 -3
  151. package/src/server/lib/app-creation-store.spec.ts +61 -2
  152. package/src/server/lib/app-creation-store.ts +386 -11
  153. package/src/server/lib/env-config.ts +5 -0
  154. package/src/server/lib/onboarding-steps.ts +49 -0
  155. package/src/server/lib/vault-store.spec.ts +69 -0
  156. package/src/server/lib/vault-store.ts +266 -49
  157. package/src/server/plugins/agent-chat.ts +2 -1
  158. package/src/server/plugins/core-routes.ts +5 -0
  159. package/src/server/plugins/integrations.ts +2 -2
@@ -1,12 +1,13 @@
1
1
  import { useEffect, useMemo } from "react";
2
2
  import {
3
3
  Link,
4
+ Navigate,
4
5
  redirect,
5
6
  useParams,
7
+ type ClientLoaderFunctionArgs,
6
8
  type LoaderFunctionArgs,
7
9
  } from "react-router";
8
10
  import { useActionQuery, appPath } from "@agent-native/core/client";
9
- import { loadWorkspaceAppsManifest } from "@agent-native/core/server/agent-discovery";
10
11
  import {
11
12
  IconArrowLeft,
12
13
  IconArrowUpRight,
@@ -16,6 +17,7 @@ import { DispatchShell } from "@/components/dispatch-shell";
16
17
  import { Spinner } from "@/components/ui/spinner";
17
18
  import { Badge } from "@/components/ui/badge";
18
19
  import { Button } from "@/components/ui/button";
20
+ import { resolveCatchAllTarget } from "@/lib/catch-all-target";
19
21
  import {
20
22
  workspaceAppHref,
21
23
  type WorkspaceAppSummary,
@@ -47,19 +49,49 @@ export function meta() {
47
49
  * OAuth callbackURL, so Google sign-in completes back at /dispatch/todo
48
50
  * and looks broken. This route fixes both the post-creation navigation
49
51
  * and the OAuth round-trip from a single place.
52
+ *
53
+ * Built-in template fallback: when no workspace manifest is available
54
+ * (framework dev with each template on its own port, hosted dispatch with
55
+ * no sibling apps), redirect to the matching first-party template's deploy
56
+ * URL — `http://localhost:<devPort>` in dev, `https://<id>.agent-native.com`
57
+ * in production. Without this, a user visiting `/forms` on dispatch is
58
+ * forced to sign in (auth guard) and then lands on this route's "Page not
59
+ * found" pane after the post-login reload.
60
+ *
61
+ * `appId === "dispatch"` short-circuit: when the segment matches Dispatch
62
+ * itself (e.g. `/dispatch/dispatch`), we go straight to the overview rather
63
+ * than chaining through `/dispatch` (which polled `useActionQuery` re-fired
64
+ * `window.location.assign` against and looped forever in production).
50
65
  */
66
+ function dispatchSelfRedirect(appId: string | undefined): string | null {
67
+ if (appId === "dispatch") return appPath("/overview");
68
+ return null;
69
+ }
70
+
51
71
  export function loader({ params }: LoaderFunctionArgs) {
52
72
  const appId = params.appId;
53
73
  if (!appId) return null;
54
- const apps = loadWorkspaceAppsManifest();
55
- if (!apps) return null;
56
- const app = apps.find((entry) => entry?.id === appId);
57
- const target =
58
- app?.path && app.path.startsWith("/") ? app.path : app ? `/${appId}` : null;
74
+ const selfTarget = dispatchSelfRedirect(appId);
75
+ if (selfTarget) throw redirect(selfTarget);
76
+ const target = resolveCatchAllTarget(appId);
59
77
  if (target) throw redirect(target);
60
78
  return null;
61
79
  }
62
80
 
81
+ export async function clientLoader({
82
+ params,
83
+ serverLoader,
84
+ }: ClientLoaderFunctionArgs) {
85
+ const selfTarget = dispatchSelfRedirect(params.appId);
86
+ if (selfTarget) throw redirect(selfTarget);
87
+ // Defer to the server loader so the built-in template fallback runs on
88
+ // SPA navigations too (e.g. clicking a `/<template-id>` link inside
89
+ // dispatch). Without this the client side would only check the workspace
90
+ // apps query, which never lists the static first-party templates and so
91
+ // the user would land on the "Page not found" pane.
92
+ return serverLoader();
93
+ }
94
+
63
95
  export default function WorkspaceAppCatchAllRoute() {
64
96
  const { appId } = useParams();
65
97
  const { data: apps = [], isLoading } = useActionQuery(
@@ -73,11 +105,17 @@ export default function WorkspaceAppCatchAllRoute() {
73
105
  [appId, apps],
74
106
  );
75
107
  const href = app ? workspaceAppHref(app) : null;
108
+ const isSelfReference = appId === "dispatch";
76
109
 
77
110
  useEffect(() => {
111
+ if (isSelfReference) return;
78
112
  if (!app || app.status === "pending" || !href) return;
79
113
  window.location.assign(href);
80
- }, [app, href]);
114
+ }, [app, href, isSelfReference]);
115
+
116
+ if (isSelfReference) {
117
+ return <Navigate to={appPath("/overview")} replace />;
118
+ }
81
119
 
82
120
  if ((isLoading && !app) || (app && app.status !== "pending" && href)) {
83
121
  return (
@@ -9,6 +9,7 @@ import {
9
9
  import { toast } from "sonner";
10
10
  import { Button } from "@/components/ui/button";
11
11
  import { Badge } from "@/components/ui/badge";
12
+ import { Skeleton } from "@/components/ui/skeleton";
12
13
  import {
13
14
  IconCheck,
14
15
  IconX,
@@ -98,9 +99,38 @@ export default function ApprovalPreviewRoute() {
98
99
 
99
100
  if (isLoading) {
100
101
  return (
101
- <div className="flex min-h-screen items-center justify-center bg-background p-6">
102
- <div className="w-full max-w-md rounded-2xl border bg-card p-6 text-center">
103
- <p className="text-sm text-muted-foreground">Loading...</p>
102
+ <div className="flex min-h-screen items-start justify-center bg-background p-6">
103
+ <div className="w-full max-w-md space-y-4">
104
+ <div className="rounded-2xl border bg-card p-5">
105
+ <div className="flex items-start gap-3">
106
+ <Skeleton className="h-9 w-9 shrink-0 rounded-xl" />
107
+ <div className="min-w-0 flex-1 space-y-2">
108
+ <div className="flex flex-wrap items-center gap-2">
109
+ <Skeleton className="h-4 w-40" />
110
+ <Skeleton className="h-5 w-20 rounded-full" />
111
+ </div>
112
+ <Skeleton className="h-3 w-32" />
113
+ </div>
114
+ </div>
115
+ <div className="mt-4 space-y-2 rounded-xl border bg-muted/30 px-4 py-3">
116
+ <div className="flex justify-between gap-4">
117
+ <Skeleton className="h-3 w-20" />
118
+ <Skeleton className="h-3 w-24" />
119
+ </div>
120
+ <div className="flex justify-between gap-4">
121
+ <Skeleton className="h-3 w-20" />
122
+ <Skeleton className="h-3 w-28" />
123
+ </div>
124
+ <div className="flex justify-between gap-4">
125
+ <Skeleton className="h-3 w-16" />
126
+ <Skeleton className="h-3 w-32" />
127
+ </div>
128
+ </div>
129
+ <div className="mt-4 flex gap-2">
130
+ <Skeleton className="h-8 flex-1 rounded-md" />
131
+ <Skeleton className="h-8 flex-1 rounded-md" />
132
+ </div>
133
+ </div>
104
134
  </div>
105
135
  </div>
106
136
  );
@@ -9,6 +9,7 @@ import {
9
9
  import { DispatchShell } from "@/components/dispatch-shell";
10
10
  import { Badge } from "@/components/ui/badge";
11
11
  import { Button } from "@/components/ui/button";
12
+ import { Skeleton } from "@/components/ui/skeleton";
12
13
  import {
13
14
  workspaceAppHref,
14
15
  type WorkspaceAppSummary,
@@ -53,7 +54,11 @@ export default function WorkspaceAppRoute() {
53
54
  </Button>
54
55
 
55
56
  {isLoading && !app ? (
56
- <p className="text-sm text-muted-foreground">Loading app status...</p>
57
+ <div className="space-y-3">
58
+ <Skeleton className="h-5 w-48" />
59
+ <Skeleton className="h-4 w-full" />
60
+ <Skeleton className="h-4 w-2/3" />
61
+ </div>
57
62
  ) : !app ? (
58
63
  <div className="space-y-3">
59
64
  <h2 className="text-base font-semibold text-foreground">
@@ -28,6 +28,11 @@ import {
28
28
  } from "@/components/ui/dialog";
29
29
  import { Input } from "@/components/ui/input";
30
30
  import { Label } from "@/components/ui/label";
31
+ import {
32
+ Tooltip,
33
+ TooltipContent,
34
+ TooltipTrigger,
35
+ } from "@/components/ui/tooltip";
31
36
 
32
37
  export function meta() {
33
38
  return [{ title: "Connections — Dispatch" }];
@@ -90,10 +95,12 @@ function ConnectDialog({
90
95
  service,
91
96
  open,
92
97
  onOpenChange,
98
+ accessMode,
93
99
  }: {
94
100
  service: Service;
95
101
  open: boolean;
96
102
  onOpenChange: (next: boolean) => void;
103
+ accessMode: "all-apps" | "manual";
97
104
  }) {
98
105
  const [value, setValue] = useState("");
99
106
  const qc = useQueryClient();
@@ -128,16 +135,18 @@ function ConnectDialog({
128
135
  throw new Error("Secret created but id missing");
129
136
  }
130
137
 
131
- // 2. Grant + sync to every app that declared this credential.
132
- const targets = service.apps.filter((a) => !a.vaultGranted);
133
- for (const app of targets) {
134
- try {
135
- await createGrant.mutateAsync({
136
- secretId,
137
- appId: app.appId,
138
- });
139
- } catch (err) {
140
- console.warn(`grant to ${app.appId} failed`, err);
138
+ // 2. Manual mode needs grants; all-apps mode only needs sync.
139
+ if (accessMode === "manual") {
140
+ const targets = service.apps.filter((a) => !a.vaultGranted);
141
+ for (const app of targets) {
142
+ try {
143
+ await createGrant.mutateAsync({
144
+ secretId,
145
+ appId: app.appId,
146
+ });
147
+ } catch (err) {
148
+ console.warn(`grant to ${app.appId} failed`, err);
149
+ }
141
150
  }
142
151
  }
143
152
  for (const app of service.apps) {
@@ -217,7 +226,13 @@ function ConnectDialog({
217
226
  );
218
227
  }
219
228
 
220
- function ConnectorCard({ service }: { service: Service }) {
229
+ function ConnectorCard({
230
+ service,
231
+ accessMode,
232
+ }: {
233
+ service: Service;
234
+ accessMode: "all-apps" | "manual";
235
+ }) {
221
236
  const [open, setOpen] = useState(false);
222
237
  const isConnected = service.apps.some((a) => a.configured);
223
238
  const appCount = service.apps.length;
@@ -251,10 +266,15 @@ function ConnectorCard({ service }: { service: Service }) {
251
266
  </Badge>
252
267
  )}
253
268
  </div>
254
- <div className="min-w-0">
255
- <div className="text-sm font-semibold text-foreground truncate">
256
- {service.label}
257
- </div>
269
+ <div className="w-full min-w-0">
270
+ <Tooltip>
271
+ <TooltipTrigger asChild>
272
+ <div className="text-sm font-semibold text-foreground truncate">
273
+ {service.label}
274
+ </div>
275
+ </TooltipTrigger>
276
+ <TooltipContent>{service.label}</TooltipContent>
277
+ </Tooltip>
258
278
  <div className="font-mono text-xs text-muted-foreground/80 truncate">
259
279
  {service.key}
260
280
  </div>
@@ -263,7 +283,12 @@ function ConnectorCard({ service }: { service: Service }) {
263
283
  Used by {appCount} {appCount === 1 ? "app" : "apps"}
264
284
  </div>
265
285
  </button>
266
- <ConnectDialog service={service} open={open} onOpenChange={setOpen} />
286
+ <ConnectDialog
287
+ service={service}
288
+ open={open}
289
+ onOpenChange={setOpen}
290
+ accessMode={accessMode}
291
+ />
267
292
  </>
268
293
  );
269
294
  }
@@ -297,7 +322,13 @@ export default function ConnectionsRoute() {
297
322
  "list-integrations-catalog",
298
323
  {},
299
324
  );
325
+ const { data: accessSettings } = useActionQuery(
326
+ "get-vault-access-settings",
327
+ {},
328
+ );
300
329
  const apps = (catalog as CatalogApp[]) || [];
330
+ const accessMode =
331
+ (accessSettings as any)?.mode === "manual" ? "manual" : "all-apps";
301
332
 
302
333
  const services = useMemo<Service[]>(() => {
303
334
  const map = new Map<string, Service>();
@@ -357,7 +388,11 @@ export default function ConnectionsRoute() {
357
388
  </div>
358
389
  <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
359
390
  {available.map((service) => (
360
- <ConnectorCard key={service.key} service={service} />
391
+ <ConnectorCard
392
+ key={service.key}
393
+ service={service}
394
+ accessMode={accessMode}
395
+ />
361
396
  ))}
362
397
  </div>
363
398
  </section>
@@ -373,7 +408,11 @@ export default function ConnectionsRoute() {
373
408
  </div>
374
409
  <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
375
410
  {connected.map((service) => (
376
- <ConnectorCard key={service.key} service={service} />
411
+ <ConnectorCard
412
+ key={service.key}
413
+ service={service}
414
+ accessMode={accessMode}
415
+ />
377
416
  ))}
378
417
  </div>
379
418
  </section>
@@ -9,7 +9,7 @@ export default function NewAppRoute() {
9
9
  return (
10
10
  <DispatchShell
11
11
  title="New App"
12
- description="Create a workspace app from a prompt and grant it selected vault keys."
12
+ description="Create a workspace app from a prompt and apply the workspace vault policy."
13
13
  >
14
14
  <NewWorkspaceAppFlow sourceApp="dispatch" className="px-0 py-0" />
15
15
  </DispatchShell>
@@ -131,6 +131,60 @@ function AppCardSkeleton() {
131
131
  );
132
132
  }
133
133
 
134
+ interface RecentAuditEvent {
135
+ id: string;
136
+ summary: string;
137
+ actor: string;
138
+ createdAt: string;
139
+ }
140
+
141
+ function RecentActivityList({
142
+ isLoading,
143
+ events,
144
+ }: {
145
+ isLoading: boolean;
146
+ events: RecentAuditEvent[];
147
+ }) {
148
+ if (isLoading && events.length === 0) {
149
+ return (
150
+ <div className="mt-4 space-y-3">
151
+ {Array.from({ length: 3 }).map((_, index) => (
152
+ <div
153
+ key={index}
154
+ className="rounded-xl border bg-muted/30 px-4 py-3 space-y-2"
155
+ >
156
+ <Skeleton className="h-4 w-3/5" />
157
+ <Skeleton className="h-3 w-2/5" />
158
+ </div>
159
+ ))}
160
+ </div>
161
+ );
162
+ }
163
+ if (events.length === 0) {
164
+ return (
165
+ <div className="mt-4 space-y-3">
166
+ <div className="rounded-xl border border-dashed px-4 py-6 text-sm text-muted-foreground">
167
+ No activity yet.
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+ return (
173
+ <div className="mt-4 space-y-3">
174
+ {events.map((event) => (
175
+ <div key={event.id} className="rounded-xl border bg-muted/30 px-4 py-3">
176
+ <div className="text-sm font-medium text-foreground">
177
+ {event.summary}
178
+ </div>
179
+ <div className="mt-1 text-xs text-muted-foreground">
180
+ {event.actor} · {new Date(event.createdAt).toLocaleString()}
181
+ </div>
182
+ </div>
183
+ ))}
184
+ </div>
185
+ );
186
+ }
187
+
134
188
  function WorkspaceAppsSection({
135
189
  apps,
136
190
  isLoading,
@@ -563,7 +617,7 @@ export default function OverviewRoute() {
563
617
  <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
564
618
  <StatCard
565
619
  label="Vault secrets"
566
- help="Credentials stored in the workspace vault. Grant them to apps from the Vault page."
620
+ help="Credentials stored in the workspace vault."
567
621
  value={data?.vault?.secretCount || 0}
568
622
  icon={IconKey}
569
623
  cta={
@@ -575,8 +629,16 @@ export default function OverviewRoute() {
575
629
  }
576
630
  />
577
631
  <StatCard
578
- label="Active grants"
579
- help="Secrets currently granted to apps. Sync them to push credentials."
632
+ label={
633
+ data?.vault?.accessMode === "manual"
634
+ ? "Active grants"
635
+ : "Accessible keys"
636
+ }
637
+ help={
638
+ data?.vault?.accessMode === "manual"
639
+ ? "Secrets currently granted to apps. Sync them to push credentials."
640
+ : "Vault keys available to every workspace app."
641
+ }
580
642
  value={data?.vault?.activeGrantCount || 0}
581
643
  icon={IconShieldCheck}
582
644
  />
@@ -625,33 +687,11 @@ export default function OverviewRoute() {
625
687
  <h2 className="text-lg font-semibold text-foreground">
626
688
  Recent activity
627
689
  </h2>
628
- {isLoading && (
629
- <span className="text-xs text-muted-foreground">
630
- Loading...
631
- </span>
632
- )}
633
- </div>
634
- <div className="mt-4 space-y-3">
635
- {(data?.recentAudit || []).map((event) => (
636
- <div
637
- key={event.id}
638
- className="rounded-xl border bg-muted/30 px-4 py-3"
639
- >
640
- <div className="text-sm font-medium text-foreground">
641
- {event.summary}
642
- </div>
643
- <div className="mt-1 text-xs text-muted-foreground">
644
- {event.actor} ·{" "}
645
- {new Date(event.createdAt).toLocaleString()}
646
- </div>
647
- </div>
648
- ))}
649
- {!isLoading && (data?.recentAudit?.length || 0) === 0 && (
650
- <div className="rounded-xl border border-dashed px-4 py-6 text-sm text-muted-foreground">
651
- No activity yet.
652
- </div>
653
- )}
654
690
  </div>
691
+ <RecentActivityList
692
+ isLoading={isLoading}
693
+ events={data?.recentAudit ?? []}
694
+ />
655
695
  </section>
656
696
 
657
697
  <section className="rounded-2xl border bg-card p-5">
@@ -37,6 +37,7 @@ import {
37
37
  } from "@/components/ui/alert-dialog";
38
38
  import { Input } from "@/components/ui/input";
39
39
  import { Label } from "@/components/ui/label";
40
+ import { Switch } from "@/components/ui/switch";
40
41
  import {
41
42
  Select,
42
43
  SelectContent,
@@ -46,6 +47,7 @@ import {
46
47
  } from "@/components/ui/select";
47
48
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
48
49
  import { Textarea } from "@/components/ui/textarea";
50
+ import { Skeleton } from "@/components/ui/skeleton";
49
51
 
50
52
  const PROVIDERS = [
51
53
  "google",
@@ -60,6 +62,8 @@ const PROVIDERS = [
60
62
  "other",
61
63
  ];
62
64
 
65
+ type VaultAccessMode = "all-apps" | "manual";
66
+
63
67
  export function meta() {
64
68
  return [{ title: "Vault — Dispatch" }];
65
69
  }
@@ -240,7 +244,53 @@ function GrantDialog({
240
244
  );
241
245
  }
242
246
 
243
- function SecretRow({ secret, grants }: { secret: any; grants: any[] }) {
247
+ function VaultAccessSettingsCard({ mode }: { mode: VaultAccessMode }) {
248
+ const update = useActionMutation("set-vault-access-settings", {
249
+ onSuccess: (next: any) =>
250
+ toast.success(
251
+ next?.mode === "manual"
252
+ ? "Manual vault access enabled"
253
+ : "All apps can use vault keys",
254
+ ),
255
+ onError: (err) => toast.error(String(err)),
256
+ });
257
+ const allApps = mode !== "manual";
258
+
259
+ return (
260
+ <div className="rounded-xl border bg-card px-4 py-3">
261
+ <div className="flex items-center justify-between gap-4">
262
+ <div className="min-w-0">
263
+ <Label className="text-sm font-medium">
264
+ All apps can use vault keys
265
+ </Label>
266
+ <p className="mt-1 text-xs text-muted-foreground">
267
+ {allApps
268
+ ? "Every workspace app can receive every saved key."
269
+ : "Only apps with explicit grants can receive saved keys."}
270
+ </p>
271
+ </div>
272
+ <Switch
273
+ checked={allApps}
274
+ disabled={update.isPending}
275
+ onCheckedChange={(checked) =>
276
+ update.mutate({ mode: checked ? "all-apps" : "manual" })
277
+ }
278
+ aria-label="Allow all workspace apps to use vault keys"
279
+ />
280
+ </div>
281
+ </div>
282
+ );
283
+ }
284
+
285
+ function SecretRow({
286
+ secret,
287
+ grants,
288
+ accessMode,
289
+ }: {
290
+ secret: any;
291
+ grants: any[];
292
+ accessMode: VaultAccessMode;
293
+ }) {
244
294
  const [expanded, setExpanded] = useState(false);
245
295
  const [showValue, setShowValue] = useState(false);
246
296
 
@@ -259,6 +309,7 @@ function SecretRow({ secret, grants }: { secret: any; grants: any[] }) {
259
309
  });
260
310
 
261
311
  const activeGrants = grants.filter((g) => g.status === "active");
312
+ const allApps = accessMode !== "manual";
262
313
 
263
314
  return (
264
315
  <div className="rounded-xl border bg-card">
@@ -289,7 +340,9 @@ function SecretRow({ secret, grants }: { secret: any; grants: any[] }) {
289
340
  </div>
290
341
  <div className="flex items-center gap-2">
291
342
  <Badge variant="outline" className="text-xs">
292
- {activeGrants.length} grant{activeGrants.length !== 1 ? "s" : ""}
343
+ {allApps
344
+ ? "All apps"
345
+ : `${activeGrants.length} grant${activeGrants.length !== 1 ? "s" : ""}`}
293
346
  </Badge>
294
347
  </div>
295
348
  </button>
@@ -319,11 +372,17 @@ function SecretRow({ secret, grants }: { secret: any; grants: any[] }) {
319
372
  <div className="space-y-2">
320
373
  <div className="flex items-center justify-between">
321
374
  <span className="text-xs font-medium text-foreground">
322
- Grants
375
+ {allApps ? "Access" : "Grants"}
323
376
  </span>
324
- <GrantDialog secretId={secret.id} secretName={secret.name} />
377
+ {!allApps && (
378
+ <GrantDialog secretId={secret.id} secretName={secret.name} />
379
+ )}
325
380
  </div>
326
- {activeGrants.length > 0 ? (
381
+ {allApps ? (
382
+ <div className="rounded-lg border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
383
+ Available to every workspace app.
384
+ </div>
385
+ ) : activeGrants.length > 0 ? (
327
386
  <div className="space-y-1.5">
328
387
  {activeGrants.map((grant: any) => (
329
388
  <div
@@ -365,7 +424,7 @@ function SecretRow({ secret, grants }: { secret: any; grants: any[] }) {
365
424
  </div>
366
425
  ) : (
367
426
  <div className="rounded-lg border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
368
- No grants yet. Grant this secret to an app to share it.
427
+ No grants yet.
369
428
  </div>
370
429
  )}
371
430
  </div>
@@ -387,8 +446,8 @@ function SecretRow({ secret, grants }: { secret: any; grants: any[] }) {
387
446
  <AlertDialogTitle>Delete this secret?</AlertDialogTitle>
388
447
  <AlertDialogDescription>
389
448
  Removing “{secret.name}” revokes all of its grants. Apps
390
- that depended on this credential will lose access on the
391
- next sync. This cannot be undone.
449
+ that depended on this credential can lose access on the next
450
+ sync. This cannot be undone.
392
451
  </AlertDialogDescription>
393
452
  </AlertDialogHeader>
394
453
  <AlertDialogFooter>
@@ -507,6 +566,12 @@ export default function VaultRoute() {
507
566
  const { data: grants } = useActionQuery("list-vault-grants", {});
508
567
  const { data: requests } = useActionQuery("list-vault-requests", {});
509
568
  const { data: audit } = useActionQuery("list-vault-audit", { limit: 20 });
569
+ const { data: accessSettings } = useActionQuery(
570
+ "get-vault-access-settings",
571
+ {},
572
+ );
573
+ const accessMode: VaultAccessMode =
574
+ (accessSettings as any)?.mode === "manual" ? "manual" : "all-apps";
510
575
 
511
576
  const grantsBySecret = (grants || []).reduce(
512
577
  (acc: Record<string, any[]>, g: any) => {
@@ -524,7 +589,7 @@ export default function VaultRoute() {
524
589
  return (
525
590
  <DispatchShell
526
591
  title="Vault"
527
- description="Centralized secret management for your workspace. Store credentials once, grant them to apps."
592
+ description="Centralized secret management for your workspace. Store credentials once and sync them to apps."
528
593
  >
529
594
  <Tabs defaultValue="secrets">
530
595
  <TabsList>
@@ -546,25 +611,40 @@ export default function VaultRoute() {
546
611
  </TabsList>
547
612
 
548
613
  <TabsContent value="secrets" className="mt-4 space-y-3">
614
+ <VaultAccessSettingsCard mode={accessMode} />
615
+
549
616
  <div className="flex items-center justify-between">
550
617
  <div className="flex items-center gap-2 text-sm text-muted-foreground">
551
618
  <IconKey size={16} />
552
- <span>
553
- {secretsLoading
554
- ? "Loading..."
555
- : `${secrets?.length || 0} secret${(secrets?.length || 0) !== 1 ? "s" : ""}`}
556
- </span>
619
+ {secretsLoading ? (
620
+ <Skeleton className="h-4 w-20" />
621
+ ) : (
622
+ <span>
623
+ {`${secrets?.length || 0} secret${(secrets?.length || 0) !== 1 ? "s" : ""}`}
624
+ </span>
625
+ )}
557
626
  </div>
558
627
  <AddSecretDialog />
559
628
  </div>
560
629
 
561
- {(secrets || []).map((secret: any) => (
562
- <SecretRow
563
- key={secret.id}
564
- secret={secret}
565
- grants={grantsBySecret[secret.id] || []}
566
- />
567
- ))}
630
+ {secretsLoading && (secrets ?? []).length === 0
631
+ ? Array.from({ length: 3 }).map((_, index) => (
632
+ <div
633
+ key={index}
634
+ className="rounded-2xl border bg-card px-5 py-4 space-y-2"
635
+ >
636
+ <Skeleton className="h-4 w-1/3" />
637
+ <Skeleton className="h-3 w-2/3" />
638
+ </div>
639
+ ))
640
+ : (secrets || []).map((secret: any) => (
641
+ <SecretRow
642
+ key={secret.id}
643
+ secret={secret}
644
+ grants={grantsBySecret[secret.id] || []}
645
+ accessMode={accessMode}
646
+ />
647
+ ))}
568
648
 
569
649
  {!secretsLoading && (secrets?.length || 0) === 0 && (
570
650
  <div className="rounded-2xl border border-dashed px-6 py-12 text-center">