devpush 0.0.0-stage → 1.0.0-beta.3

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 (456) hide show
  1. package/README.md +6 -2
  2. package/agent-release.json +37 -0
  3. package/dist/package/LICENSE.md +21 -0
  4. package/dist/package/migrate.mjs +182 -0
  5. package/dist/package/plugins.mjs +125 -0
  6. package/dist/package/vite.mjs +439 -0
  7. package/package.json +170 -4
  8. package/public/apple-touch-icon.png +0 -0
  9. package/public/favicon.svg +1 -0
  10. package/public/logo-email.png +0 -0
  11. package/public/robots.txt +3 -0
  12. package/public/social.png +0 -0
  13. package/src/cloudflare-runtime.d.ts +78 -0
  14. package/src/components/about-dialog.tsx +70 -0
  15. package/src/components/copy-button.tsx +74 -0
  16. package/src/components/logo-mark.tsx +20 -0
  17. package/src/components/pending-collection.tsx +103 -0
  18. package/src/components/resource-avatar.tsx +65 -0
  19. package/src/components/route-error-state.tsx +95 -0
  20. package/src/components/section-navigation.tsx +39 -0
  21. package/src/components/settings-layout.tsx +111 -0
  22. package/src/components/storage-capabilities.tsx +11 -0
  23. package/src/components/table-pager.tsx +101 -0
  24. package/src/components/ui/alert-dialog.tsx +180 -0
  25. package/src/components/ui/alert.tsx +78 -0
  26. package/src/components/ui/avatar.tsx +58 -0
  27. package/src/components/ui/badge.tsx +49 -0
  28. package/src/components/ui/button-group.tsx +38 -0
  29. package/src/components/ui/button.tsx +67 -0
  30. package/src/components/ui/card.tsx +82 -0
  31. package/src/components/ui/chart.tsx +307 -0
  32. package/src/components/ui/checkbox.tsx +30 -0
  33. package/src/components/ui/combobox.tsx +256 -0
  34. package/src/components/ui/dialog.tsx +163 -0
  35. package/src/components/ui/dropdown-menu.tsx +110 -0
  36. package/src/components/ui/empty.tsx +65 -0
  37. package/src/components/ui/field.tsx +192 -0
  38. package/src/components/ui/input-group.tsx +118 -0
  39. package/src/components/ui/input-otp.tsx +70 -0
  40. package/src/components/ui/input.tsx +19 -0
  41. package/src/components/ui/label.tsx +24 -0
  42. package/src/components/ui/pagination.tsx +34 -0
  43. package/src/components/ui/popover.tsx +86 -0
  44. package/src/components/ui/select.tsx +145 -0
  45. package/src/components/ui/skeleton.tsx +19 -0
  46. package/src/components/ui/sonner.tsx +47 -0
  47. package/src/components/ui/switch.tsx +31 -0
  48. package/src/components/ui/table.tsx +81 -0
  49. package/src/components/ui/textarea.tsx +18 -0
  50. package/src/components/ui/tooltip.tsx +55 -0
  51. package/src/features/admin/admin-settings.tsx +1048 -0
  52. package/src/features/auth/current-user-context-query.ts +11 -0
  53. package/src/features/auth/login-form.tsx +442 -0
  54. package/src/features/auth/sign-out-menu-item.tsx +42 -0
  55. package/src/features/deployments/deployment-actions.tsx +336 -0
  56. package/src/features/deployments/deployment-alias-badge.tsx +35 -0
  57. package/src/features/deployments/deployment-detail.tsx +596 -0
  58. package/src/features/deployments/deployment-filters.ts +66 -0
  59. package/src/features/deployments/deployment-list.tsx +465 -0
  60. package/src/features/deployments/deployment-visit-links.tsx +104 -0
  61. package/src/features/deployments/deployments-page.tsx +376 -0
  62. package/src/features/logs/log-scroll-area.tsx +69 -0
  63. package/src/features/notifications/email-preferences.tsx +232 -0
  64. package/src/features/onboarding/profile-onboarding.tsx +162 -0
  65. package/src/features/plugins/contributions.tsx +118 -0
  66. package/src/features/projects/backup-job-logs.tsx +143 -0
  67. package/src/features/projects/backup-policy-delete-dialog.tsx +82 -0
  68. package/src/features/projects/backup-restore-actions.tsx +573 -0
  69. package/src/features/projects/backup-status-icon.tsx +33 -0
  70. package/src/features/projects/backup-target-list.tsx +222 -0
  71. package/src/features/projects/backup-timestamp.tsx +32 -0
  72. package/src/features/projects/backup-volume-picker.tsx +158 -0
  73. package/src/features/projects/compose-service-input.tsx +64 -0
  74. package/src/features/projects/deploy-readiness.ts +28 -0
  75. package/src/features/projects/environment-dot.tsx +91 -0
  76. package/src/features/projects/log-keyword-filter.tsx +48 -0
  77. package/src/features/projects/manual-deploy-dialog.tsx +563 -0
  78. package/src/features/projects/pending-section.ts +26 -0
  79. package/src/features/projects/project-backup-details-pending.tsx +305 -0
  80. package/src/features/projects/project-backup-details.tsx +936 -0
  81. package/src/features/projects/project-backup-policy.tsx +644 -0
  82. package/src/features/projects/project-backup-utils.ts +190 -0
  83. package/src/features/projects/project-backups-pending.tsx +136 -0
  84. package/src/features/projects/project-backups.tsx +876 -0
  85. package/src/features/projects/project-environment-settings.tsx +1128 -0
  86. package/src/features/projects/project-heading.tsx +68 -0
  87. package/src/features/projects/project-layout.tsx +188 -0
  88. package/src/features/projects/project-logs.tsx +461 -0
  89. package/src/features/projects/project-overview.tsx +280 -0
  90. package/src/features/projects/project-route-pending.tsx +1064 -0
  91. package/src/features/projects/project-routing-settings.tsx +663 -0
  92. package/src/features/projects/project-settings-section.tsx +64 -0
  93. package/src/features/projects/project-settings-utils.ts +101 -0
  94. package/src/features/projects/project-settings.tsx +1472 -0
  95. package/src/features/projects/repository-picker-dialog.tsx +404 -0
  96. package/src/features/repositories/new-project-dialog-launcher.tsx +44 -0
  97. package/src/features/repositories/new-project-dialog.tsx +1204 -0
  98. package/src/features/repositories/new-project-pending.tsx +28 -0
  99. package/src/features/repositories/repository-list-pending.tsx +59 -0
  100. package/src/features/servers/server-availability-alert.tsx +44 -0
  101. package/src/features/servers/server-availability.ts +29 -0
  102. package/src/features/servers/server-avatar.tsx +52 -0
  103. package/src/features/servers/server-detail-pending.tsx +315 -0
  104. package/src/features/servers/server-detail.tsx +855 -0
  105. package/src/features/servers/server-metric-chart-pending.tsx +38 -0
  106. package/src/features/servers/server-metric-chart.tsx +182 -0
  107. package/src/features/servers/server-metric-format.ts +50 -0
  108. package/src/features/servers/server-metrics-pending.tsx +75 -0
  109. package/src/features/servers/server-metrics.tsx +136 -0
  110. package/src/features/servers/team-servers.tsx +432 -0
  111. package/src/features/settings/settings-route-pending.tsx +406 -0
  112. package/src/features/teams/backup-storage-settings.tsx +483 -0
  113. package/src/features/teams/navigation.ts +51 -0
  114. package/src/features/teams/new-team-dialog.tsx +132 -0
  115. package/src/features/teams/pending-section.ts +19 -0
  116. package/src/features/teams/team-header.tsx +1286 -0
  117. package/src/features/teams/team-heading.tsx +57 -0
  118. package/src/features/teams/team-home.tsx +391 -0
  119. package/src/features/teams/team-invite.tsx +155 -0
  120. package/src/features/teams/team-route-pending.tsx +505 -0
  121. package/src/features/teams/team-settings.tsx +1555 -0
  122. package/src/features/teams/use-workspace-updates.ts +126 -0
  123. package/src/features/users/api-keys-settings.tsx +544 -0
  124. package/src/features/users/api-scope-selection.tsx +269 -0
  125. package/src/features/users/connected-apps-settings.tsx +449 -0
  126. package/src/features/users/mcp-consent.tsx +220 -0
  127. package/src/features/users/profile-picture.tsx +171 -0
  128. package/src/features/users/user-settings.tsx +1064 -0
  129. package/src/lib/account-settings-schema.ts +78 -0
  130. package/src/lib/action-feedback.ts +81 -0
  131. package/src/lib/admin-search-schema.ts +8 -0
  132. package/src/lib/agent-release.d.mts +21 -0
  133. package/src/lib/agent-release.mjs +90 -0
  134. package/src/lib/allowlist-schema.ts +79 -0
  135. package/src/lib/api-key-schema.ts +176 -0
  136. package/src/lib/auth/client.ts +20 -0
  137. package/src/lib/avatar-schema.ts +11 -0
  138. package/src/lib/backup-cron.ts +118 -0
  139. package/src/lib/backup-database.ts +41 -0
  140. package/src/lib/backup-feature.ts +2 -0
  141. package/src/lib/backup-paths.ts +7 -0
  142. package/src/lib/backup-policy-schema.ts +85 -0
  143. package/src/lib/backup-resource-schema.ts +12 -0
  144. package/src/lib/backup-restore-schema.ts +15 -0
  145. package/src/lib/backup-storage-schema.ts +55 -0
  146. package/src/lib/deferred-module.ts +38 -0
  147. package/src/lib/deployment-logs-query.ts +63 -0
  148. package/src/lib/display-name.ts +7 -0
  149. package/src/lib/error-message.ts +119 -0
  150. package/src/lib/form-errors.ts +10 -0
  151. package/src/lib/internal-redirect.ts +25 -0
  152. package/src/lib/manual-deploy-options-query.ts +21 -0
  153. package/src/lib/notification-preferences.ts +43 -0
  154. package/src/lib/onboarding.ts +11 -0
  155. package/src/lib/operation-logs-query.ts +51 -0
  156. package/src/lib/page-head.ts +14 -0
  157. package/src/lib/project-context-query.ts +257 -0
  158. package/src/lib/project-creation-schema.ts +64 -0
  159. package/src/lib/project-env-schema.ts +23 -0
  160. package/src/lib/project-environments-schema.ts +56 -0
  161. package/src/lib/project-routing-schema.ts +94 -0
  162. package/src/lib/project-runtime-config.ts +131 -0
  163. package/src/lib/project-settings-schema.ts +75 -0
  164. package/src/lib/public-api-schema.ts +128 -0
  165. package/src/lib/public-error.ts +337 -0
  166. package/src/lib/query.ts +13 -0
  167. package/src/lib/repository-selection-query.ts +11 -0
  168. package/src/lib/resource-cache.ts +30 -0
  169. package/src/lib/runtime-id.ts +19 -0
  170. package/src/lib/search.ts +14 -0
  171. package/src/lib/server-context-query.ts +43 -0
  172. package/src/lib/server-name-schema.ts +11 -0
  173. package/src/lib/settings-conflict.ts +45 -0
  174. package/src/lib/settings-version.ts +7 -0
  175. package/src/lib/team-context-query.ts +126 -0
  176. package/src/lib/team-context-schema.ts +8 -0
  177. package/src/lib/team-forms-schema.ts +45 -0
  178. package/src/lib/team-role.ts +13 -0
  179. package/src/lib/team-settings-schema.ts +48 -0
  180. package/src/lib/urls.ts +3 -0
  181. package/src/lib/use-avatar-input.ts +61 -0
  182. package/src/lib/use-refresh-error.ts +18 -0
  183. package/src/lib/use-settings-version.ts +23 -0
  184. package/src/lib/utils.ts +6 -0
  185. package/src/lib/validation.ts +5 -0
  186. package/src/lib/workspace-updates.ts +53 -0
  187. package/src/package/config.ts +10 -0
  188. package/src/package/env.mjs +54 -0
  189. package/src/package/migrate-api.d.ts +5 -0
  190. package/src/package/migrate.ts +56 -0
  191. package/src/package/plugins.ts +141 -0
  192. package/src/package/sqlite.mjs +2 -0
  193. package/src/package/vite-api.d.ts +5 -0
  194. package/src/package/vite.ts +324 -0
  195. package/src/package/watch-guard.cjs +26 -0
  196. package/src/plugins/client.ts +57 -0
  197. package/src/plugins/contract.ts +188 -0
  198. package/src/plugins/default.server.ts +4 -0
  199. package/src/plugins/default.ui.ts +4 -0
  200. package/src/plugins/operations.ts +45 -0
  201. package/src/plugins/server.ts +197 -0
  202. package/src/plugins/version.ts +1 -0
  203. package/src/routeTree.gen.ts +1275 -0
  204. package/src/router.tsx +46 -0
  205. package/src/routes/$teamSlug.index.tsx +53 -0
  206. package/src/routes/$teamSlug.new-project.tsx +13 -0
  207. package/src/routes/$teamSlug.projects.$projectName.backups.$backupId.tsx +40 -0
  208. package/src/routes/$teamSlug.projects.$projectName.backups.tsx +82 -0
  209. package/src/routes/$teamSlug.projects.$projectName.deployments.$deploymentId.tsx +52 -0
  210. package/src/routes/$teamSlug.projects.$projectName.deployments.tsx +86 -0
  211. package/src/routes/$teamSlug.projects.$projectName.logs.tsx +55 -0
  212. package/src/routes/$teamSlug.projects.$projectName.settings.tsx +51 -0
  213. package/src/routes/$teamSlug.projects.$projectName.tsx +106 -0
  214. package/src/routes/$teamSlug.projects.tsx +40 -0
  215. package/src/routes/$teamSlug.servers.$serverId.tsx +31 -0
  216. package/src/routes/$teamSlug.servers.index.tsx +59 -0
  217. package/src/routes/$teamSlug.servers.tsx +7 -0
  218. package/src/routes/$teamSlug.settings.tsx +56 -0
  219. package/src/routes/$teamSlug.tsx +86 -0
  220. package/src/routes/[.]well-known.oauth-authorization-server.api.auth.ts +13 -0
  221. package/src/routes/[.]well-known.oauth-protected-resource.mcp.ts +6 -0
  222. package/src/routes/__root.tsx +155 -0
  223. package/src/routes/admin.tsx +55 -0
  224. package/src/routes/api/agents/certificates.ts +21 -0
  225. package/src/routes/api/agents/credentials/rotate.ts +22 -0
  226. package/src/routes/api/agents/heartbeat.ts +42 -0
  227. package/src/routes/api/agents/job-logs.ts +22 -0
  228. package/src/routes/api/agents/jobs/complete.ts +22 -0
  229. package/src/routes/api/agents/jobs/next.ts +29 -0
  230. package/src/routes/api/agents/jobs/progress.ts +32 -0
  231. package/src/routes/api/agents/jobs/status.ts +22 -0
  232. package/src/routes/api/agents/register.ts +45 -0
  233. package/src/routes/api/agents/runtime-logs.ts +22 -0
  234. package/src/routes/api/agents/switch.ts +25 -0
  235. package/src/routes/api/auth/$.ts +38 -0
  236. package/src/routes/api/cron/backups.ts +33 -0
  237. package/src/routes/api/cron/maintenance.ts +30 -0
  238. package/src/routes/api/github/install.callback.ts +51 -0
  239. package/src/routes/api/github/install.ts +41 -0
  240. package/src/routes/api/github/webhook.ts +11 -0
  241. package/src/routes/api/health.ts +19 -0
  242. package/src/routes/api/install/agent.$token.ts +50 -0
  243. package/src/routes/api/install/agent.binary.ts +83 -0
  244. package/src/routes/api/install/agent.checksum.ts +45 -0
  245. package/src/routes/api/install/agent.ts +26 -0
  246. package/src/routes/api/plugins/$.ts +11 -0
  247. package/src/routes/api/v1/$.ts +15 -0
  248. package/src/routes/api/workspaces/updates.ts +8 -0
  249. package/src/routes/auth.login.tsx +50 -0
  250. package/src/routes/deployments.$deploymentId.unavailable.tsx +21 -0
  251. package/src/routes/index.tsx +23 -0
  252. package/src/routes/invites.$token.tsx +35 -0
  253. package/src/routes/mcp.ts +13 -0
  254. package/src/routes/oauth.consent.tsx +54 -0
  255. package/src/routes/onboarding.tsx +37 -0
  256. package/src/routes/onboarding.workspace.tsx +44 -0
  257. package/src/routes/settings.tsx +30 -0
  258. package/src/routes/uploads/avatars/$fileName.ts +44 -0
  259. package/src/server/admin/access.ts +67 -0
  260. package/src/server/admin/functions.ts +122 -0
  261. package/src/server/admin/service.ts +260 -0
  262. package/src/server/agents/backup-state.ts +210 -0
  263. package/src/server/agents/certificates.ts +96 -0
  264. package/src/server/agents/claims.ts +116 -0
  265. package/src/server/agents/cleanup.ts +52 -0
  266. package/src/server/agents/compatibility.ts +116 -0
  267. package/src/server/agents/completion.ts +83 -0
  268. package/src/server/agents/container-cleanup-state.ts +51 -0
  269. package/src/server/agents/deployment-state.ts +111 -0
  270. package/src/server/agents/job-payloads.ts +159 -0
  271. package/src/server/agents/jobs.ts +1293 -0
  272. package/src/server/agents/protocol.ts +400 -0
  273. package/src/server/agents/reconciliation.ts +43 -0
  274. package/src/server/agents/runtime-logs.ts +337 -0
  275. package/src/server/agents/runtime-state.ts +125 -0
  276. package/src/server/api/admission.ts +12 -0
  277. package/src/server/api/authorization.ts +115 -0
  278. package/src/server/api/credentials.ts +569 -0
  279. package/src/server/api/cursors.ts +72 -0
  280. package/src/server/api/errors.ts +24 -0
  281. package/src/server/api/functions.ts +60 -0
  282. package/src/server/api/handler.ts +274 -0
  283. package/src/server/api/mutation-operations.ts +913 -0
  284. package/src/server/api/operation-context.ts +139 -0
  285. package/src/server/api/operation-contract.ts +1099 -0
  286. package/src/server/api/operations.ts +64 -0
  287. package/src/server/api/read-operations.ts +891 -0
  288. package/src/server/api/retention.ts +33 -0
  289. package/src/server/auth/access.ts +109 -0
  290. package/src/server/auth/auth-methods.ts +28 -0
  291. package/src/server/auth/better-auth.ts +518 -0
  292. package/src/server/auth/context.ts +28 -0
  293. package/src/server/auth/email-change.ts +47 -0
  294. package/src/server/auth/email-delivery.ts +51 -0
  295. package/src/server/auth/provider-identity.ts +19 -0
  296. package/src/server/auth/provider-tokens.ts +27 -0
  297. package/src/server/auth/session.server.ts +19 -0
  298. package/src/server/backups/database-commands.ts +40 -0
  299. package/src/server/backups/destination.ts +12 -0
  300. package/src/server/backups/feature.ts +6 -0
  301. package/src/server/backups/policy-validation.ts +51 -0
  302. package/src/server/backups/queue-state.ts +137 -0
  303. package/src/server/backups/service.ts +918 -0
  304. package/src/server/backups/storage-functions.ts +42 -0
  305. package/src/server/backups/storage-state.ts +67 -0
  306. package/src/server/backups/storage.ts +237 -0
  307. package/src/server/config.ts +274 -0
  308. package/src/server/db/atomic.ts +16 -0
  309. package/src/server/db/d1-connection.ts +83 -0
  310. package/src/server/db/errors.ts +17 -0
  311. package/src/server/db/index.cloudflare.ts +15 -0
  312. package/src/server/db/index.ts +35 -0
  313. package/src/server/db/libsql-connection.ts +72 -0
  314. package/src/server/db/log-schema.ts +102 -0
  315. package/src/server/db/migrate-libsql.ts +29 -0
  316. package/src/server/db/migrate-sqlite.ts +47 -0
  317. package/src/server/db/migration-history.ts +12 -0
  318. package/src/server/db/migrations-logs/0000_initial.sql +48 -0
  319. package/src/server/db/migrations-logs/0001_runtime_log_order.sql +2 -0
  320. package/src/server/db/migrations-logs/meta/0000_snapshot.json +327 -0
  321. package/src/server/db/migrations-logs/meta/0001_snapshot.json +329 -0
  322. package/src/server/db/migrations-logs/meta/_journal.json +20 -0
  323. package/src/server/db/migrations-sqlite/0000_initial.sql +894 -0
  324. package/src/server/db/migrations-sqlite/0001_agent_indexes.sql +5 -0
  325. package/src/server/db/migrations-sqlite/meta/0000_snapshot.json +5816 -0
  326. package/src/server/db/migrations-sqlite/meta/0001_snapshot.json +5841 -0
  327. package/src/server/db/migrations-sqlite/meta/_journal.json +20 -0
  328. package/src/server/db/oauth-schema.ts +216 -0
  329. package/src/server/db/remote-config.ts +47 -0
  330. package/src/server/db/schema.ts +1218 -0
  331. package/src/server/db/sqlite-connection.ts +94 -0
  332. package/src/server/db/sqlite-files.ts +27 -0
  333. package/src/server/db/sqlite-params.ts +19 -0
  334. package/src/server/db/worker-connection.ts +47 -0
  335. package/src/server/deleted-resources.ts +133 -0
  336. package/src/server/deletion-recovery.ts +272 -0
  337. package/src/server/email/layout.tsx +85 -0
  338. package/src/server/email/notification-template.tsx +62 -0
  339. package/src/server/email/otp-template.tsx +48 -0
  340. package/src/server/email/service.ts +251 -0
  341. package/src/server/email/team-invite-template.tsx +59 -0
  342. package/src/server/email/theme.ts +10 -0
  343. package/src/server/github/http.ts +14 -0
  344. package/src/server/github/install-state.ts +48 -0
  345. package/src/server/github/service.ts +831 -0
  346. package/src/server/github/webhook-handler.ts +198 -0
  347. package/src/server/github/webhooks.ts +205 -0
  348. package/src/server/http/auth.ts +11 -0
  349. package/src/server/http/error-diagnostics.ts +66 -0
  350. package/src/server/http/plugin-request.ts +15 -0
  351. package/src/server/http/proxy-request.ts +53 -0
  352. package/src/server/http/public-errors.ts +48 -0
  353. package/src/server/http/rate-limit.ts +59 -0
  354. package/src/server/http/request-body.ts +54 -0
  355. package/src/server/http/request-ip.ts +54 -0
  356. package/src/server/http/security-headers.ts +36 -0
  357. package/src/server/install/agent-binary.ts +230 -0
  358. package/src/server/install/agent-script.ts +565 -0
  359. package/src/server/install/release-binary.ts +65 -0
  360. package/src/server/logs/budget.ts +72 -0
  361. package/src/server/logs/cleanup.ts +64 -0
  362. package/src/server/logs/deployments.ts +98 -0
  363. package/src/server/logs/operations.ts +145 -0
  364. package/src/server/logs/retention.ts +39 -0
  365. package/src/server/maintenance.ts +61 -0
  366. package/src/server/mcp/connections.ts +358 -0
  367. package/src/server/mcp/functions.ts +63 -0
  368. package/src/server/mcp/handler.ts +295 -0
  369. package/src/server/mcp/oauth-discovery.cloudflare.ts +6 -0
  370. package/src/server/mcp/oauth-discovery.ts +8 -0
  371. package/src/server/mcp/oauth-provider.ts +220 -0
  372. package/src/server/mcp/urls.ts +20 -0
  373. package/src/server/notifications/functions.ts +51 -0
  374. package/src/server/notifications/observe.ts +303 -0
  375. package/src/server/notifications/outbox.ts +335 -0
  376. package/src/server/notifications/preferences.ts +131 -0
  377. package/src/server/notifications/query.ts +16 -0
  378. package/src/server/plugins/scope.ts +17 -0
  379. package/src/server/plugins/server-context.ts +157 -0
  380. package/src/server/plugins/services.server.ts +10 -0
  381. package/src/server/plugins/workspace-access.ts +74 -0
  382. package/src/server/projects/backup-policy-state.ts +68 -0
  383. package/src/server/projects/backups.ts +219 -0
  384. package/src/server/projects/compose-metadata.ts +174 -0
  385. package/src/server/projects/configuration-state.ts +90 -0
  386. package/src/server/projects/context.ts +909 -0
  387. package/src/server/projects/creation-state.ts +46 -0
  388. package/src/server/projects/deletion-state.ts +69 -0
  389. package/src/server/projects/deployment-config.ts +139 -0
  390. package/src/server/projects/deployment-logs.ts +97 -0
  391. package/src/server/projects/deployments.ts +1123 -0
  392. package/src/server/projects/domain-state.ts +71 -0
  393. package/src/server/projects/environment-branches.ts +75 -0
  394. package/src/server/projects/environment-state.ts +158 -0
  395. package/src/server/projects/environments.ts +424 -0
  396. package/src/server/projects/functions.ts +748 -0
  397. package/src/server/projects/generated-hostname.ts +77 -0
  398. package/src/server/projects/hostnames.ts +244 -0
  399. package/src/server/projects/mappers.ts +457 -0
  400. package/src/server/projects/names.ts +41 -0
  401. package/src/server/projects/project-env-vars.ts +28 -0
  402. package/src/server/projects/queue-state.ts +118 -0
  403. package/src/server/projects/repository.ts +368 -0
  404. package/src/server/projects/route-state.ts +37 -0
  405. package/src/server/projects/runtime-logs.ts +97 -0
  406. package/src/server/projects/settings-context.ts +58 -0
  407. package/src/server/projects/settings.ts +738 -0
  408. package/src/server/projects/types.ts +355 -0
  409. package/src/server/realtime/agent-events.ts +32 -0
  410. package/src/server/realtime/node-broadcaster.ts +71 -0
  411. package/src/server/realtime/node-handler.ts +55 -0
  412. package/src/server/realtime/service.ts +125 -0
  413. package/src/server/realtime/workspace-object.cloudflare.ts +62 -0
  414. package/src/server/realtime/workspace-object.ts +3 -0
  415. package/src/server/resources/color.ts +13 -0
  416. package/src/server/runtime/background.ts +21 -0
  417. package/src/server/runtime/node-asset-cache-plugin.ts +12 -0
  418. package/src/server/runtime/node-scheduler-plugin.ts +17 -0
  419. package/src/server/runtime/node-scheduler.ts +51 -0
  420. package/src/server/runtime/platform-env.cloudflare.ts +17 -0
  421. package/src/server/runtime/platform-env.ts +19 -0
  422. package/src/server/runtime/scheduled-task-error.ts +9 -0
  423. package/src/server/scheduled-tasks.ts +28 -0
  424. package/src/server/secrets/crypto.ts +56 -0
  425. package/src/server/servers/functions.ts +233 -0
  426. package/src/server/servers/lifecycle.ts +122 -0
  427. package/src/server/servers/metric-history-schema.ts +24 -0
  428. package/src/server/servers/metric-history.ts +55 -0
  429. package/src/server/servers/monitoring.ts +592 -0
  430. package/src/server/servers/service.ts +1211 -0
  431. package/src/server/servers/status.ts +32 -0
  432. package/src/server/servers/switch.ts +96 -0
  433. package/src/server/settings-version.ts +43 -0
  434. package/src/server/storage/capabilities.ts +6 -0
  435. package/src/server/storage/local-object-store.cloudflare.ts +7 -0
  436. package/src/server/storage/local-object-store.ts +50 -0
  437. package/src/server/storage/object-store.ts +160 -0
  438. package/src/server/teams/creation.ts +51 -0
  439. package/src/server/teams/delete-policy.ts +13 -0
  440. package/src/server/teams/deletion-state.ts +80 -0
  441. package/src/server/teams/functions.ts +324 -0
  442. package/src/server/teams/invitations.ts +38 -0
  443. package/src/server/teams/membership.ts +86 -0
  444. package/src/server/teams/queries.ts +261 -0
  445. package/src/server/teams/service.ts +1127 -0
  446. package/src/server/teams/types.ts +101 -0
  447. package/src/server/uploads/avatar.ts +133 -0
  448. package/src/server/users/deletion-state.ts +93 -0
  449. package/src/server/users/functions.ts +160 -0
  450. package/src/server/users/onboarding.ts +17 -0
  451. package/src/server/users/service.ts +644 -0
  452. package/src/server.ts +55 -0
  453. package/src/styles/shadcn.css +653 -0
  454. package/src/styles/theme.css +76 -0
  455. package/src/styles.css +177 -0
  456. package/wrangler.template.jsonc +37 -0
@@ -0,0 +1,1555 @@
1
+ import { NotificationMute } from '#devpush/features/notifications/email-preferences'
2
+ import { useImageUploads } from '#devpush/components/storage-capabilities'
3
+ import {
4
+ mutationErrorOptions,
5
+ afterMutation,
6
+ copyText,
7
+ } from '#devpush/lib/action-feedback'
8
+ import {
9
+ WorkspaceSettingsContributions,
10
+ workspaceSettingsNavigation,
11
+ } from '#devpush/features/plugins/contributions'
12
+ import {
13
+ Empty,
14
+ EmptyHeader,
15
+ EmptyTitle,
16
+ EmptyDescription,
17
+ } from '#devpush/components/ui/empty'
18
+ import { useSettingsVersion } from '#devpush/lib/use-settings-version'
19
+ import { backupsEnabled } from '#devpush/lib/backup-feature'
20
+ import { showSettingsConflict } from '#devpush/lib/settings-conflict'
21
+ import { CopyButton } from '#devpush/components/copy-button'
22
+ import { BackupStorageSettings } from './backup-storage-settings'
23
+ import type { BackupStorageSummary } from '#devpush/lib/backup-storage-schema'
24
+ import { setTeamContextQueryData } from '#devpush/lib/team-context-query'
25
+ import {
26
+ bulkInviteSchema,
27
+ parseInviteEmails,
28
+ } from '#devpush/lib/team-forms-schema'
29
+ import { useAvatarInput } from '#devpush/lib/use-avatar-input'
30
+ import { teamGeneralSchema } from '#devpush/lib/team-settings-schema'
31
+ import { formErrors } from '#devpush/lib/form-errors'
32
+ import type { FormErrors } from '#devpush/lib/form-errors'
33
+ import { useRouter } from '@tanstack/react-router'
34
+ import { useQueryClient } from '@tanstack/react-query'
35
+ import {
36
+ InfoIcon,
37
+ LoaderIcon,
38
+ PlusIcon,
39
+ RefreshCwIcon,
40
+ SearchIcon,
41
+ Trash2Icon,
42
+ UserRoundCheckIcon,
43
+ UploadIcon,
44
+ } from 'lucide-react'
45
+ import { useEffect, useMemo, useRef, useState } from 'react'
46
+ import { toast } from 'sonner'
47
+
48
+ import {
49
+ getPageCount,
50
+ getPageItems,
51
+ TablePager,
52
+ } from '#devpush/components/table-pager'
53
+ import { teamSettingsNavigationItems } from './navigation'
54
+ import { TeamHeading } from './team-heading'
55
+ import {
56
+ SettingsLayout,
57
+ SettingsSection,
58
+ } from '#devpush/components/settings-layout'
59
+ import { Alert, AlertTitle } from '#devpush/components/ui/alert'
60
+ import {
61
+ AlertDialog,
62
+ AlertDialogAction,
63
+ AlertDialogCancel,
64
+ AlertDialogContent,
65
+ AlertDialogDescription,
66
+ AlertDialogFooter,
67
+ AlertDialogHeader,
68
+ AlertDialogTitle,
69
+ AlertDialogTrigger,
70
+ } from '#devpush/components/ui/alert-dialog'
71
+ import {
72
+ Avatar,
73
+ AvatarFallback,
74
+ AvatarImage,
75
+ } from '#devpush/components/ui/avatar'
76
+ import { Button } from '#devpush/components/ui/button'
77
+ import {
78
+ Dialog,
79
+ DialogContent,
80
+ DialogDescription,
81
+ DialogFooter,
82
+ DialogHeader,
83
+ DialogTitle,
84
+ } from '#devpush/components/ui/dialog'
85
+ import {
86
+ Field,
87
+ FieldDescription,
88
+ FieldError,
89
+ FieldGroup,
90
+ FieldLabel,
91
+ } from '#devpush/components/ui/field'
92
+ import { Input } from '#devpush/components/ui/input'
93
+ import {
94
+ InputGroup,
95
+ InputGroupAddon,
96
+ InputGroupInput,
97
+ } from '#devpush/components/ui/input-group'
98
+ import {
99
+ Select,
100
+ SelectContent,
101
+ SelectItem,
102
+ SelectTrigger,
103
+ SelectValue,
104
+ } from '#devpush/components/ui/select'
105
+ import {
106
+ Table,
107
+ TableBody,
108
+ TableCell,
109
+ TableHead,
110
+ TableHeader,
111
+ TableRow,
112
+ } from '#devpush/components/ui/table'
113
+ import { Textarea } from '#devpush/components/ui/textarea'
114
+ import {
115
+ Tooltip,
116
+ TooltipContent,
117
+ TooltipTrigger,
118
+ } from '#devpush/components/ui/tooltip'
119
+ import { ResourceAvatar } from '#devpush/components/resource-avatar'
120
+ import {
121
+ canDeleteTeam as canDeleteTeamByRole,
122
+ canManageTeamMembers,
123
+ } from '#devpush/lib/team-role'
124
+ import type { TeamRole } from '#devpush/lib/team-role'
125
+ import { getPublicErrorMessage } from '#devpush/lib/public-error'
126
+ import { invalidateResourceQueries } from '#devpush/lib/resource-cache'
127
+ import {
128
+ createTeamInvitesAction,
129
+ deleteTeamAction,
130
+ removeTeamMemberAction,
131
+ resendTeamInviteAction,
132
+ revokeTeamInviteAction,
133
+ transferTeamOwnershipAction,
134
+ updateTeamGeneralAction,
135
+ updateTeamMemberRoleAction,
136
+ } from '#devpush/server/teams/functions'
137
+ import type {
138
+ TeamInviteSummary,
139
+ TeamMemberSummary,
140
+ TeamSummary,
141
+ } from '#devpush/server/teams/types'
142
+
143
+ export function TeamSettings({
144
+ backupStorage = [],
145
+ canDeleteTeam,
146
+ currentUserId,
147
+ isDefaultTeam,
148
+ invites,
149
+ members,
150
+ team,
151
+ }: {
152
+ backupStorage?: BackupStorageSummary[]
153
+ canDeleteTeam: boolean
154
+ currentUserId: string
155
+ isDefaultTeam: boolean
156
+ invites: Array<TeamInviteSummary>
157
+ members: Array<TeamMemberSummary>
158
+ team: TeamSummary
159
+ }) {
160
+ const router = useRouter()
161
+ const imageUploads = useImageUploads()
162
+ const queryClient = useQueryClient()
163
+
164
+ async function refreshTeam() {
165
+ await afterMutation(
166
+ () => invalidateResourceQueries(queryClient, team.slug),
167
+ 'The action completed, but refreshing the data failed. Please reload.',
168
+ )
169
+ await afterMutation(
170
+ () => router.invalidate(),
171
+ 'The action completed, but refreshing the page failed. Please reload.',
172
+ )
173
+ }
174
+ const fileInputRef = useRef<HTMLInputElement>(null)
175
+ const [savedGeneral, setSavedGeneral] = useState(team)
176
+ const [generalErrors, setGeneralErrors] = useState<FormErrors>({})
177
+ const [name, setName] = useState(team.name)
178
+ const [slug, setSlug] = useState(team.slug)
179
+ const {
180
+ dataUrl: avatarDataUrl,
181
+ setDataUrl: setAvatarDataUrl,
182
+ read: readAvatar,
183
+ cancelRead: cancelAvatarRead,
184
+ pending: isReadingAvatar,
185
+ } = useAvatarInput()
186
+ const [deleteAvatar, setDeleteAvatar] = useState(false)
187
+ const [isSavingGeneral, setIsSavingGeneral] = useState(false)
188
+ const [memberQuery, setMemberQuery] = useState('')
189
+ const [bulkInviteErrors, setBulkInviteErrors] = useState<FormErrors>({})
190
+ const [bulkInviteEmails, setBulkInviteEmails] = useState('')
191
+ const [bulkInviteRole, setBulkInviteRole] = useState<'admin' | 'member'>(
192
+ 'member',
193
+ )
194
+ const [bulkInviteDialogOpen, setBulkInviteDialogOpen] = useState(false)
195
+ const [memberPage, setMemberPage] = useState(1)
196
+ const [isBulkInviting, setIsBulkInviting] = useState(false)
197
+ const [confirmation, setConfirmation] = useState<string | null>(null)
198
+ const [teamDeleted, setTeamDeleted] = useState(false)
199
+ const [isDeletingTeam, setIsDeletingTeam] = useState(false)
200
+ const [deleteTeamConfirmation, setDeleteTeamConfirmation] = useState('')
201
+ const [removeMemberConfirmation, setRemoveMemberConfirmation] = useState('')
202
+ const [busyId, setBusyId] = useState<string | null>(null)
203
+ const [pendingRoleChange, setPendingRoleChange] = useState<{
204
+ member: TeamMemberSummary
205
+ role: TeamRole
206
+ } | null>(null)
207
+ const [pendingOwnershipTransfer, setPendingOwnershipTransfer] =
208
+ useState<TeamMemberSummary | null>(null)
209
+ const canManageMembers = canManageTeamMembers(team.role)
210
+ const canManageOwners = team.role === 'owner'
211
+ const ownerCount = members.filter((member) => member.role === 'owner').length
212
+ const filteredMembers = useMemo(() => {
213
+ const query = memberQuery.trim().toLowerCase()
214
+ if (!query) return members
215
+
216
+ return members.filter((member) =>
217
+ [member.user.name, member.user.username, member.user.email, member.role]
218
+ .filter(Boolean)
219
+ .some((value) => value.toLowerCase().includes(query)),
220
+ )
221
+ }, [members, memberQuery])
222
+ const memberPageCount = getPageCount(filteredMembers.length)
223
+ const pagedMembers = useMemo(
224
+ () => getPageItems(filteredMembers, memberPage),
225
+ [filteredMembers, memberPage],
226
+ )
227
+ const avatarUrl = deleteAvatar ? null : (avatarDataUrl ?? team.image)
228
+ const generalDirty =
229
+ isReadingAvatar ||
230
+ name.trim() !== savedGeneral.name ||
231
+ slug.trim() !== savedGeneral.slug ||
232
+ Boolean(avatarDataUrl) ||
233
+ deleteAvatar
234
+
235
+ const settingsVersion = useSettingsVersion({
236
+ version: team.settingsVersion,
237
+ dirty: generalDirty,
238
+ saving: isSavingGeneral,
239
+ refresh: () => {
240
+ setName(team.name)
241
+ setSlug(team.slug)
242
+ setSavedGeneral(team)
243
+ setGeneralErrors({})
244
+ },
245
+ })
246
+
247
+ function resetGeneral() {
248
+ settingsVersion.current = team.settingsVersion
249
+ setSavedGeneral(team)
250
+ setGeneralErrors({})
251
+ setName(team.name)
252
+ setSlug(team.slug)
253
+ setAvatarDataUrl(null)
254
+ setDeleteAvatar(false)
255
+ }
256
+
257
+ useEffect(() => {
258
+ if (memberPage > memberPageCount) {
259
+ setMemberPage(memberPageCount)
260
+ }
261
+ }, [memberPage, memberPageCount])
262
+
263
+ useEffect(() => {
264
+ setMemberPage(1)
265
+ }, [memberQuery])
266
+
267
+ async function chooseAvatar(file: File | undefined) {
268
+ if (!file) return
269
+ cancelAvatarRead()
270
+ setGeneralErrors((current) => ({ ...current, avatarDataUrl: undefined }))
271
+
272
+ if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
273
+ setGeneralErrors((current) => ({
274
+ ...current,
275
+ avatarDataUrl: 'Upload a PNG, JPEG, or WebP image.',
276
+ }))
277
+ return
278
+ }
279
+
280
+ if (file.size > 2 * 1024 * 1024) {
281
+ setGeneralErrors((current) => ({
282
+ ...current,
283
+ avatarDataUrl: 'Avatars must be 2 MB or smaller.',
284
+ }))
285
+ return
286
+ }
287
+
288
+ try {
289
+ const dataUrl = await readAvatar(file)
290
+ if (dataUrl === null) return
291
+ setAvatarDataUrl(dataUrl)
292
+ setDeleteAvatar(false)
293
+ } catch {
294
+ setGeneralErrors((current) => ({
295
+ ...current,
296
+ avatarDataUrl:
297
+ 'Unable to read this image. Please choose it again or try another file.',
298
+ }))
299
+ }
300
+ }
301
+
302
+ async function saveGeneral() {
303
+ if (!canManageMembers) return
304
+ if (isSavingGeneral || isReadingAvatar || !generalDirty) return
305
+ const parsed = teamGeneralSchema.safeParse({
306
+ teamId: team.id,
307
+ settingsVersion: settingsVersion.current,
308
+ teamSlug: team.slug,
309
+ name,
310
+ slug,
311
+ avatarDataUrl,
312
+ deleteAvatar,
313
+ })
314
+ if (!parsed.success) {
315
+ const errors = formErrors(parsed.error.issues)
316
+ if (errors.form) toast.error(errors.form)
317
+ setGeneralErrors(errors)
318
+ document
319
+ .getElementById(
320
+ `team-${errors.name ? 'name' : errors.slug ? 'slug' : 'avatar-button'}`,
321
+ )
322
+ ?.focus()
323
+ return
324
+ }
325
+ setGeneralErrors({})
326
+ setIsSavingGeneral(true)
327
+
328
+ try {
329
+ const result = await updateTeamGeneralAction({ data: parsed.data })
330
+ if (result.fieldErrors) {
331
+ if (result.fieldErrors.form) toast.error(result.fieldErrors.form)
332
+ setGeneralErrors(result.fieldErrors)
333
+ return
334
+ }
335
+ const nextTeam = result.team
336
+ settingsVersion.current = nextTeam.settingsVersion
337
+ setSavedGeneral(nextTeam)
338
+ setName(nextTeam.name)
339
+ setSlug(nextTeam.slug)
340
+ await setTeamContextQueryData(queryClient, nextTeam)
341
+ setAvatarDataUrl(null)
342
+ setDeleteAvatar(false)
343
+ toast.success('Team settings saved.')
344
+ if (nextTeam.slug !== team.slug) {
345
+ await invalidateResourceQueries(queryClient)
346
+ await afterMutation(
347
+ () =>
348
+ router.navigate({
349
+ to: '/$teamSlug/settings',
350
+ params: { teamSlug: nextTeam.slug },
351
+ }),
352
+ 'The action completed, but opening the next page failed. Please reload.',
353
+ )
354
+ }
355
+ } catch (error) {
356
+ if (showSettingsConflict(error)) return
357
+ toast.error(
358
+ getPublicErrorMessage(error, 'Unable to save team. Please try again.'),
359
+ ...mutationErrorOptions(error),
360
+ )
361
+ } finally {
362
+ setIsSavingGeneral(false)
363
+ }
364
+ }
365
+
366
+ async function sendBulkInvites() {
367
+ if (isBulkInviting) return
368
+ const parsed = bulkInviteSchema.safeParse({
369
+ teamSlug: team.slug,
370
+ emails: bulkInviteEmails,
371
+ role: bulkInviteRole,
372
+ })
373
+ if (!parsed.success) {
374
+ setBulkInviteErrors(formErrors(parsed.error.issues))
375
+ document.getElementById('bulk-invite-emails')?.focus()
376
+ return
377
+ }
378
+ const submitted = bulkInviteEmails.split(/[\s,]+/).filter(Boolean)
379
+ const existing = new Set(
380
+ members.map((member) => member.user.email.toLowerCase()),
381
+ )
382
+ const emails = parseInviteEmails(bulkInviteEmails).filter(
383
+ (email) => !existing.has(email),
384
+ )
385
+ const skipped = submitted.length - emails.length
386
+ if (!emails.length) {
387
+ toast.warning(
388
+ `No invitations sent. ${skipped} ${skipped === 1 ? 'address' : 'addresses'} skipped.`,
389
+ )
390
+ setBulkInviteEmails('')
391
+ setBulkInviteErrors({})
392
+ setBulkInviteDialogOpen(false)
393
+ return
394
+ }
395
+ setBulkInviteErrors({})
396
+ setIsBulkInviting(true)
397
+
398
+ try {
399
+ const result = await createTeamInvitesAction({
400
+ data: {
401
+ teamSlug: team.slug,
402
+ emails: emails.join(', '),
403
+ role: bulkInviteRole,
404
+ },
405
+ })
406
+ const summary = `${result.emailSentCount} ${result.emailSentCount === 1 ? 'invitation' : 'invitations'} sent.${skipped ? ` ${skipped} ${skipped === 1 ? 'address' : 'addresses'} skipped.` : ''}${result.emailCount > result.emailSentCount ? ` ${result.emailCount - result.emailSentCount} invitation email(s) failed to send.` : ''}${result.failedEmails.length ? ` ${result.failedEmails.length} address(es) could not be invited.` : ''}`
407
+ if (
408
+ result.failedEmails.length ||
409
+ result.emailSentCount !== result.emailCount ||
410
+ skipped
411
+ )
412
+ toast.warning(summary)
413
+ else toast.success(summary)
414
+ if (result.failedEmails.length) {
415
+ setBulkInviteEmails(
416
+ result.failedEmails.map((item) => item.email).join('\n'),
417
+ )
418
+ setBulkInviteErrors({
419
+ emails: result.failedEmails.map((item) => item.message).join(' '),
420
+ })
421
+ } else {
422
+ setBulkInviteEmails('')
423
+ setBulkInviteRole('member')
424
+ setBulkInviteDialogOpen(false)
425
+ }
426
+ await refreshTeam()
427
+ } catch (error) {
428
+ if (showSettingsConflict(error)) return
429
+ toast.error(
430
+ getPublicErrorMessage(
431
+ error,
432
+ 'Unable to send invites. Please try again.',
433
+ ),
434
+ ...mutationErrorOptions(error),
435
+ )
436
+ } finally {
437
+ setIsBulkInviting(false)
438
+ }
439
+ }
440
+
441
+ async function updateRole(memberUserId: string, role: TeamRole) {
442
+ if (busyId || isDeletingTeam || teamDeleted) return
443
+
444
+ setBusyId(memberUserId)
445
+
446
+ try {
447
+ await updateTeamMemberRoleAction({
448
+ data: { teamSlug: team.slug, memberUserId, role },
449
+ })
450
+ toast.success('Member role updated.')
451
+ await refreshTeam()
452
+ setPendingRoleChange(null)
453
+ } catch (error) {
454
+ if (showSettingsConflict(error)) return
455
+ toast.error(
456
+ getPublicErrorMessage(error, 'Unable to update member.'),
457
+ ...mutationErrorOptions(error),
458
+ )
459
+ } finally {
460
+ setBusyId(null)
461
+ }
462
+ }
463
+
464
+ async function removeMember(memberUserId: string) {
465
+ const member = members.find((item) => item.user.id === memberUserId)
466
+ if (!member || removeMemberConfirmation !== member.user.email) return
467
+ if (busyId || isDeletingTeam || teamDeleted) return
468
+
469
+ setBusyId(memberUserId)
470
+
471
+ try {
472
+ await removeTeamMemberAction({
473
+ data: { teamSlug: team.slug, memberUserId },
474
+ })
475
+ setConfirmation(null)
476
+ toast.success('Member removed.')
477
+ await refreshTeam()
478
+ } catch (error) {
479
+ if (showSettingsConflict(error)) return
480
+ toast.error(
481
+ getPublicErrorMessage(error, 'Unable to remove member.'),
482
+ ...mutationErrorOptions(error),
483
+ )
484
+ } finally {
485
+ setBusyId(null)
486
+ }
487
+ }
488
+
489
+ async function transferOwnership(memberUserId: string) {
490
+ if (busyId || isDeletingTeam || teamDeleted) return
491
+
492
+ setBusyId(memberUserId)
493
+
494
+ try {
495
+ await transferTeamOwnershipAction({
496
+ data: { teamSlug: team.slug, memberUserId },
497
+ })
498
+ setPendingOwnershipTransfer(null)
499
+ toast.success('Ownership transferred.')
500
+ await refreshTeam()
501
+ } catch (error) {
502
+ if (showSettingsConflict(error)) return
503
+ toast.error(
504
+ getPublicErrorMessage(error, 'Unable to transfer ownership.'),
505
+ ...mutationErrorOptions(error),
506
+ )
507
+ } finally {
508
+ setBusyId(null)
509
+ }
510
+ }
511
+
512
+ async function resendInvite(inviteId: string) {
513
+ if (busyId || isDeletingTeam || teamDeleted) return
514
+
515
+ setBusyId(inviteId)
516
+
517
+ try {
518
+ const result = await resendTeamInviteAction({
519
+ data: { teamSlug: team.slug, inviteId },
520
+ })
521
+ await handleInviteResult(result, 'Invite resent.')
522
+ await refreshTeam()
523
+ } catch (error) {
524
+ if (showSettingsConflict(error)) return
525
+ toast.error(
526
+ getPublicErrorMessage(error, 'Unable to resend invite.'),
527
+ ...mutationErrorOptions(error),
528
+ )
529
+ } finally {
530
+ setBusyId(null)
531
+ }
532
+ }
533
+
534
+ async function handleInviteResult(
535
+ result: { emailSent: boolean; inviteUrl: string },
536
+ successMessage: string,
537
+ ) {
538
+ if (result.emailSent) {
539
+ toast.success(successMessage)
540
+ return
541
+ }
542
+
543
+ const copied = await copyText(result.inviteUrl, 'Invite link')
544
+ toast.warning(
545
+ copied
546
+ ? 'Invite created, but email failed. Invite link copied.'
547
+ : 'Invite created, but email and copying failed. Resend the invitation to try again.',
548
+ )
549
+ }
550
+
551
+ async function revokeInvite(inviteId: string) {
552
+ if (busyId || isDeletingTeam || teamDeleted) return
553
+
554
+ setBusyId(inviteId)
555
+
556
+ try {
557
+ await revokeTeamInviteAction({ data: { teamSlug: team.slug, inviteId } })
558
+ setConfirmation(null)
559
+ toast.success('Invite revoked.')
560
+ await refreshTeam()
561
+ } catch (error) {
562
+ if (showSettingsConflict(error)) return
563
+ toast.error(
564
+ getPublicErrorMessage(error, 'Unable to revoke invite.'),
565
+ ...mutationErrorOptions(error),
566
+ )
567
+ } finally {
568
+ setBusyId(null)
569
+ }
570
+ }
571
+
572
+ async function deleteCurrentTeam() {
573
+ if (
574
+ isDeletingTeam ||
575
+ teamDeleted ||
576
+ busyId ||
577
+ !canDeleteTeam ||
578
+ deleteTeamConfirmation !== team.slug
579
+ )
580
+ return
581
+
582
+ setIsDeletingTeam(true)
583
+
584
+ try {
585
+ await deleteTeamAction({
586
+ data: {
587
+ teamSlug: team.slug,
588
+ confirmationName: deleteTeamConfirmation,
589
+ },
590
+ })
591
+ await invalidateResourceQueries(queryClient)
592
+ setTeamDeleted(true)
593
+ setConfirmation(null)
594
+ toast.success('Team deleted.')
595
+ await afterMutation(
596
+ () => router.navigate({ to: '/' }),
597
+ 'The action completed, but opening the next page failed. Please reload.',
598
+ )
599
+ } catch (error) {
600
+ if (showSettingsConflict(error)) return
601
+ toast.error(
602
+ getPublicErrorMessage(error, 'Unable to delete team.'),
603
+ ...mutationErrorOptions(error),
604
+ )
605
+ } finally {
606
+ setIsDeletingTeam(false)
607
+ }
608
+ }
609
+
610
+ return (
611
+ <div className="space-y-8">
612
+ <TeamHeading team={team} crumb={<span>Team settings</span>} />
613
+
614
+ <SettingsLayout
615
+ navigation={[
616
+ ...teamSettingsNavigationItems.slice(0, 2),
617
+ ...workspaceSettingsNavigation,
618
+ ...teamSettingsNavigationItems.slice(2),
619
+ ]}
620
+ >
621
+ <SettingsSection
622
+ id="general"
623
+ title="General"
624
+ description="General settings for the team."
625
+ footer={
626
+ canManageMembers ? (
627
+ <SettingsActions
628
+ disabled={!generalDirty || isSavingGeneral}
629
+ isSaving={isSavingGeneral}
630
+ isReading={isReadingAvatar}
631
+ onCancel={resetGeneral}
632
+ />
633
+ ) : undefined
634
+ }
635
+ >
636
+ <form
637
+ id="team-general-form"
638
+ className="space-y-8"
639
+ noValidate
640
+ onSubmit={(event) => {
641
+ event.preventDefault()
642
+ void saveGeneral()
643
+ }}
644
+ >
645
+ <Field data-invalid={Boolean(generalErrors.avatarDataUrl)}>
646
+ <FieldLabel>Avatar</FieldLabel>
647
+ <div className="flex items-center gap-x-2">
648
+ <ResourceAvatar
649
+ name={name}
650
+ src={avatarUrl ?? undefined}
651
+ color={team.color}
652
+ size="md"
653
+ />
654
+ {imageUploads ? (
655
+ <input
656
+ ref={fileInputRef}
657
+ type="file"
658
+ disabled={!canManageMembers || isSavingGeneral}
659
+ aria-label="Team avatar"
660
+ aria-describedby="team-avatar-error"
661
+ accept="image/png,image/jpeg,image/webp"
662
+ className="hidden"
663
+ onChange={(event) => {
664
+ void chooseAvatar(event.target.files?.[0])
665
+ event.target.value = ''
666
+ }}
667
+ />
668
+ ) : null}
669
+ {imageUploads && canManageMembers ? (
670
+ <Button
671
+ variant="outline"
672
+ size="sm"
673
+ type="button"
674
+ id="team-avatar-button"
675
+ disabled={isSavingGeneral}
676
+ aria-invalid={Boolean(generalErrors.avatarDataUrl)}
677
+ aria-describedby="team-avatar-error"
678
+ onClick={() => fileInputRef.current?.click()}
679
+ >
680
+ <UploadIcon data-icon="inline-start" />
681
+ Upload image
682
+ </Button>
683
+ ) : null}
684
+ {canManageMembers && avatarUrl ? (
685
+ <Tooltip>
686
+ <TooltipTrigger asChild>
687
+ <Button
688
+ variant="destructive"
689
+ size="icon-sm"
690
+ type="button"
691
+ aria-label="Remove image"
692
+ disabled={isSavingGeneral}
693
+ onClick={() => {
694
+ setAvatarDataUrl(null)
695
+ setDeleteAvatar(true)
696
+ setGeneralErrors((current) => ({
697
+ ...current,
698
+ avatarDataUrl: undefined,
699
+ }))
700
+ }}
701
+ >
702
+ <Trash2Icon />
703
+ </Button>
704
+ </TooltipTrigger>
705
+ <TooltipContent>Remove image</TooltipContent>
706
+ </Tooltip>
707
+ ) : null}
708
+ </div>
709
+ {isReadingAvatar ? (
710
+ <p role="status" className="text-sm text-muted-foreground">
711
+ Reading image…
712
+ </p>
713
+ ) : null}
714
+ <FieldError id="team-avatar-error">
715
+ {generalErrors.avatarDataUrl}
716
+ </FieldError>
717
+ </Field>
718
+
719
+ <Field data-invalid={Boolean(generalErrors.name)}>
720
+ <FieldLabel htmlFor="team-name">Display name</FieldLabel>
721
+ <Input
722
+ id="team-name"
723
+ readOnly={!canManageMembers}
724
+ required
725
+ disabled={isSavingGeneral}
726
+ maxLength={100}
727
+ aria-invalid={Boolean(generalErrors.name)}
728
+ aria-describedby="team-name-error"
729
+ value={name}
730
+ onChange={(event) => {
731
+ setName(event.target.value)
732
+ setGeneralErrors((current) => ({
733
+ ...current,
734
+ name: undefined,
735
+ }))
736
+ }}
737
+ />
738
+ <FieldError id="team-name-error">{generalErrors.name}</FieldError>
739
+ </Field>
740
+
741
+ <Field data-invalid={Boolean(generalErrors.slug)}>
742
+ <FieldLabel htmlFor="team-slug">Slug</FieldLabel>
743
+ <Input
744
+ id="team-slug"
745
+ readOnly={!canManageMembers}
746
+ required
747
+ disabled={isSavingGeneral}
748
+ maxLength={100}
749
+ aria-invalid={Boolean(generalErrors.slug)}
750
+ aria-describedby="team-slug-error"
751
+ value={slug}
752
+ onChange={(event) => {
753
+ setSlug(event.target.value)
754
+ setGeneralErrors((current) => ({
755
+ ...current,
756
+ slug: undefined,
757
+ }))
758
+ }}
759
+ />
760
+ <FieldError id="team-slug-error">{generalErrors.slug}</FieldError>
761
+ </Field>
762
+
763
+ <Field>
764
+ <FieldLabel htmlFor="team-id">Team ID</FieldLabel>
765
+ <InputGroup>
766
+ <InputGroupInput
767
+ id="team-id"
768
+ className="font-mono text-[13px] text-muted-foreground"
769
+ value={team.id}
770
+ readOnly
771
+ />
772
+ <InputGroupAddon align="inline-end">
773
+ <CopyButton value={team.id} label="Copy team ID" inputGroup />
774
+ </InputGroupAddon>
775
+ </InputGroup>
776
+ <FieldDescription>
777
+ A unique identifier used in API, environment variables, etc.
778
+ </FieldDescription>
779
+ </Field>
780
+ </form>
781
+ </SettingsSection>
782
+
783
+ <SettingsSection
784
+ id="members"
785
+ title="Members"
786
+ description="Manage the members of the team."
787
+ >
788
+ <div className="space-y-6">
789
+ <div className="space-y-4">
790
+ <Dialog
791
+ open={bulkInviteDialogOpen}
792
+ onOpenChange={(open) => {
793
+ if (!isBulkInviting) setBulkInviteDialogOpen(open)
794
+ }}
795
+ >
796
+ <DialogContent>
797
+ <form
798
+ id="bulk-invite-form"
799
+ noValidate
800
+ className="grid gap-4"
801
+ onSubmit={(event) => {
802
+ event.preventDefault()
803
+ void sendBulkInvites()
804
+ }}
805
+ >
806
+ <fieldset disabled={isBulkInviting} className="grid gap-4">
807
+ <DialogHeader>
808
+ <DialogTitle>Add members</DialogTitle>
809
+ <DialogDescription>
810
+ Paste email addresses separated by commas, spaces, or
811
+ new lines. Duplicate, invalid and existing member
812
+ addresses are skipped.
813
+ </DialogDescription>
814
+ </DialogHeader>
815
+ <FieldGroup>
816
+ <Field>
817
+ <FieldLabel htmlFor="bulk-invite-role">
818
+ Role
819
+ </FieldLabel>
820
+ <Select
821
+ value={bulkInviteRole}
822
+ onValueChange={(value) =>
823
+ setBulkInviteRole(
824
+ value === 'admin' ? 'admin' : 'member',
825
+ )
826
+ }
827
+ >
828
+ <SelectTrigger id="bulk-invite-role">
829
+ <SelectValue />
830
+ </SelectTrigger>
831
+ <SelectContent>
832
+ <SelectItem value="member">Member</SelectItem>
833
+ <SelectItem value="admin">Admin</SelectItem>
834
+ </SelectContent>
835
+ </Select>
836
+ </Field>
837
+ <Field>
838
+ <FieldLabel htmlFor="bulk-invite-emails">
839
+ Email addresses
840
+ </FieldLabel>
841
+ <Textarea
842
+ id="bulk-invite-emails"
843
+ required
844
+ maxLength={20000}
845
+ aria-invalid={Boolean(bulkInviteErrors.emails)}
846
+ aria-describedby="bulk-invite-emails-error"
847
+ value={bulkInviteEmails}
848
+ onChange={(event) => {
849
+ setBulkInviteEmails(event.target.value)
850
+ setBulkInviteErrors({})
851
+ }}
852
+ placeholder="name@example.com"
853
+ className="resize-none max-h-64 overflow-y-auto"
854
+ />
855
+ <FieldError id="bulk-invite-emails-error">
856
+ {bulkInviteErrors.emails}
857
+ </FieldError>
858
+ </Field>
859
+ </FieldGroup>
860
+ <FieldError role="alert">
861
+ {bulkInviteErrors.form}
862
+ </FieldError>
863
+ <DialogFooter>
864
+ <Button
865
+ type="button"
866
+ variant="ghost"
867
+ onClick={() => setBulkInviteDialogOpen(false)}
868
+ disabled={isBulkInviting}
869
+ >
870
+ Cancel
871
+ </Button>
872
+ <Button type="submit" disabled={isBulkInviting}>
873
+ {isBulkInviting ? (
874
+ <LoaderIcon
875
+ data-icon="inline-start"
876
+ className="animate-spin"
877
+ />
878
+ ) : null}
879
+ Add members
880
+ </Button>
881
+ </DialogFooter>
882
+ </fieldset>
883
+ </form>
884
+ </DialogContent>
885
+ </Dialog>
886
+ <div className="flex items-center justify-between gap-2">
887
+ <InputGroup className="max-w-72">
888
+ <InputGroupAddon>
889
+ <SearchIcon />
890
+ </InputGroupAddon>
891
+ <InputGroupInput
892
+ placeholder="Search..."
893
+ value={memberQuery}
894
+ onChange={(event) => setMemberQuery(event.target.value)}
895
+ />
896
+ </InputGroup>
897
+ {canManageMembers ? (
898
+ <Button
899
+ type="button"
900
+ variant="outline"
901
+ onClick={() => {
902
+ setBulkInviteErrors({})
903
+ setBulkInviteDialogOpen(true)
904
+ }}
905
+ >
906
+ <PlusIcon data-icon="inline-start" />
907
+ Add members
908
+ </Button>
909
+ ) : null}
910
+ </div>
911
+
912
+ {filteredMembers.length === 0 ? (
913
+ <Empty className="min-h-40">
914
+ <EmptyHeader>
915
+ <EmptyTitle>No members found.</EmptyTitle>
916
+ <EmptyDescription>
917
+ Try a different name, username or email.
918
+ </EmptyDescription>
919
+ </EmptyHeader>
920
+ </Empty>
921
+ ) : (
922
+ <div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
923
+ <Table>
924
+ <TableHeader>
925
+ <TableRow>
926
+ <TableHead className="pl-4">Name</TableHead>
927
+ <TableHead>Email</TableHead>
928
+ <TableHead className="w-[120px]">Role</TableHead>
929
+ <TableHead className="w-28">
930
+ <span className="sr-only">Actions</span>
931
+ </TableHead>
932
+ </TableRow>
933
+ </TableHeader>
934
+ <TableBody>
935
+ {pagedMembers.map((member) => {
936
+ const displayName =
937
+ member.user.name ||
938
+ member.user.email.split('@')[0] ||
939
+ member.user.email
940
+ const isLastOwner =
941
+ member.role === 'owner' && ownerCount <= 1
942
+ const canManageMember =
943
+ canManageMembers &&
944
+ (canManageOwners || member.role !== 'owner')
945
+ const canTransferToMember =
946
+ canManageOwners &&
947
+ member.user.id !== currentUserId &&
948
+ member.role !== 'owner'
949
+
950
+ return (
951
+ <TableRow key={member.id}>
952
+ <TableCell className="pl-4">
953
+ <div className="flex items-center gap-3">
954
+ <Avatar className="size-8">
955
+ <AvatarImage
956
+ src={member.user.image ?? undefined}
957
+ alt={displayName}
958
+ />
959
+ <AvatarFallback>
960
+ {displayName.charAt(0).toLocaleUpperCase()}
961
+ </AvatarFallback>
962
+ </Avatar>
963
+ <div className="min-w-0">
964
+ <div className="truncate font-medium">
965
+ {displayName}
966
+ </div>
967
+ <div className="truncate text-xs text-muted-foreground">
968
+ {member.user.username}
969
+ </div>
970
+ </div>
971
+ </div>
972
+ </TableCell>
973
+ <TableCell className="truncate text-muted-foreground">
974
+ {member.user.email}
975
+ </TableCell>
976
+ <TableCell>
977
+ {canManageMember ? (
978
+ <Select
979
+ value={member.role}
980
+ onValueChange={(value) => {
981
+ const nextRole = value as TeamRole
982
+ if (nextRole === member.role) return
983
+
984
+ setPendingRoleChange({
985
+ member,
986
+ role: nextRole,
987
+ })
988
+ }}
989
+ disabled={
990
+ busyId === member.user.id || isLastOwner
991
+ }
992
+ >
993
+ <SelectTrigger
994
+ size="sm"
995
+ aria-label={`Role for ${displayName}`}
996
+ >
997
+ <SelectValue />
998
+ </SelectTrigger>
999
+ <SelectContent>
1000
+ {canManageOwners ? (
1001
+ <SelectItem value="owner">
1002
+ Owner
1003
+ </SelectItem>
1004
+ ) : null}
1005
+ <SelectItem value="admin">Admin</SelectItem>
1006
+ <SelectItem value="member">
1007
+ Member
1008
+ </SelectItem>
1009
+ </SelectContent>
1010
+ </Select>
1011
+ ) : (
1012
+ <span className="capitalize text-muted-foreground">
1013
+ {member.role}
1014
+ </span>
1015
+ )}
1016
+ </TableCell>
1017
+ <TableCell className="pr-4 text-right">
1018
+ <div className="ml-auto">
1019
+ <div className="flex items-center justify-end gap-1">
1020
+ {canTransferToMember ? (
1021
+ <Tooltip>
1022
+ <TooltipTrigger asChild>
1023
+ <Button
1024
+ variant="ghost"
1025
+ size="icon"
1026
+ type="button"
1027
+ aria-label={`Transfer ownership to ${displayName}`}
1028
+ disabled={busyId === member.user.id}
1029
+ onClick={() => {
1030
+ setPendingOwnershipTransfer(member)
1031
+ }}
1032
+ >
1033
+ <UserRoundCheckIcon />
1034
+ </Button>
1035
+ </TooltipTrigger>
1036
+ <TooltipContent>
1037
+ Transfer ownership
1038
+ </TooltipContent>
1039
+ </Tooltip>
1040
+ ) : null}
1041
+ {canManageMember ? (
1042
+ <AlertDialog
1043
+ open={
1044
+ confirmation ===
1045
+ `remove:${member.user.id}`
1046
+ }
1047
+ onOpenChange={(open) => {
1048
+ if (
1049
+ !busyId &&
1050
+ !isDeletingTeam &&
1051
+ !teamDeleted
1052
+ ) {
1053
+ setConfirmation(
1054
+ open
1055
+ ? `remove:${member.user.id}`
1056
+ : null,
1057
+ )
1058
+ }
1059
+ }}
1060
+ >
1061
+ <Tooltip>
1062
+ <TooltipTrigger asChild>
1063
+ <AlertDialogTrigger asChild>
1064
+ <Button
1065
+ variant="destructive"
1066
+ size="icon"
1067
+ type="button"
1068
+ aria-label="Remove member"
1069
+ disabled={
1070
+ busyId === member.user.id ||
1071
+ isLastOwner
1072
+ }
1073
+ onClick={() =>
1074
+ setRemoveMemberConfirmation('')
1075
+ }
1076
+ >
1077
+ <Trash2Icon />
1078
+ </Button>
1079
+ </AlertDialogTrigger>
1080
+ </TooltipTrigger>
1081
+ <TooltipContent>
1082
+ Remove member
1083
+ </TooltipContent>
1084
+ </Tooltip>
1085
+ <AlertDialogContent>
1086
+ <AlertDialogHeader>
1087
+ <AlertDialogTitle>
1088
+ Remove {displayName}?
1089
+ </AlertDialogTitle>
1090
+ <AlertDialogDescription>
1091
+ This removes the member from the
1092
+ team. They will no longer have
1093
+ access to this team or its projects.{' '}
1094
+ {`Type "${member.user.email}" to confirm.`}
1095
+ </AlertDialogDescription>
1096
+ </AlertDialogHeader>
1097
+ <Field>
1098
+ <Input
1099
+ aria-label={`Type ${member.user.email} to confirm`}
1100
+ id={`remove-member-${member.user.id}`}
1101
+ required
1102
+ disabled={Boolean(busyId)}
1103
+ placeholder={member.user.email}
1104
+ value={removeMemberConfirmation}
1105
+ onChange={(event) =>
1106
+ setRemoveMemberConfirmation(
1107
+ event.target.value,
1108
+ )
1109
+ }
1110
+ onKeyDown={(event) => {
1111
+ if (
1112
+ event.key === 'Enter' &&
1113
+ busyId !== member.user.id &&
1114
+ removeMemberConfirmation ===
1115
+ member.user.email
1116
+ ) {
1117
+ event.preventDefault()
1118
+ void removeMember(
1119
+ member.user.id,
1120
+ )
1121
+ }
1122
+ }}
1123
+ />
1124
+ </Field>
1125
+ <AlertDialogFooter>
1126
+ <AlertDialogCancel
1127
+ disabled={busyId === member.user.id}
1128
+ >
1129
+ Cancel
1130
+ </AlertDialogCancel>
1131
+ <AlertDialogAction
1132
+ variant="destructive"
1133
+ disabled={
1134
+ busyId === member.user.id ||
1135
+ removeMemberConfirmation !==
1136
+ member.user.email
1137
+ }
1138
+ onClick={(event) => {
1139
+ event.preventDefault()
1140
+ void removeMember(member.user.id)
1141
+ }}
1142
+ >
1143
+ {busyId === member.user.id ? (
1144
+ <LoaderIcon className="animate-spin" />
1145
+ ) : null}
1146
+ Remove member
1147
+ </AlertDialogAction>
1148
+ </AlertDialogFooter>
1149
+ </AlertDialogContent>
1150
+ </AlertDialog>
1151
+ ) : null}
1152
+ </div>
1153
+ </div>
1154
+ </TableCell>
1155
+ </TableRow>
1156
+ )
1157
+ })}
1158
+ </TableBody>
1159
+ </Table>
1160
+ </div>
1161
+ )}
1162
+ <TablePager
1163
+ page={memberPage}
1164
+ pageCount={memberPageCount}
1165
+ onPageChange={setMemberPage}
1166
+ />
1167
+ </div>
1168
+ {invites.length > 0 ? (
1169
+ <div className="space-y-4">
1170
+ <h3 className="text-sm font-medium">
1171
+ {invites.length}{' '}
1172
+ {invites.length === 1 ? 'pending invite' : 'pending invites'}
1173
+ </h3>
1174
+ <div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
1175
+ <Table>
1176
+ <TableHeader>
1177
+ <TableRow>
1178
+ <TableHead className="pl-4">Email</TableHead>
1179
+ <TableHead className="w-[120px]">Role</TableHead>
1180
+ <TableHead className="w-[120px]">Expires</TableHead>
1181
+ <TableHead className="w-28">
1182
+ <span className="sr-only">Actions</span>
1183
+ </TableHead>
1184
+ </TableRow>
1185
+ </TableHeader>
1186
+ <TableBody>
1187
+ {invites.map((invite) => (
1188
+ <TableRow key={invite.id}>
1189
+ <TableCell className="pl-4 truncate font-medium text-foreground">
1190
+ {invite.email}
1191
+ </TableCell>
1192
+ <TableCell className="capitalize text-muted-foreground">
1193
+ {invite.role}
1194
+ </TableCell>
1195
+ <TableCell className="text-muted-foreground">
1196
+ {formatInviteDate(invite.expiresAt)}
1197
+ </TableCell>
1198
+ <TableCell className="pr-4 text-right">
1199
+ <div className="ml-auto">
1200
+ <div className="flex items-center justify-end gap-1">
1201
+ <Tooltip>
1202
+ <TooltipTrigger asChild>
1203
+ <Button
1204
+ variant="ghost"
1205
+ size="icon"
1206
+ type="button"
1207
+ aria-label="Resend invite"
1208
+ disabled={busyId === invite.id}
1209
+ onClick={() =>
1210
+ void resendInvite(invite.id)
1211
+ }
1212
+ >
1213
+ <RefreshCwIcon />
1214
+ </Button>
1215
+ </TooltipTrigger>
1216
+ <TooltipContent>Resend invite</TooltipContent>
1217
+ </Tooltip>
1218
+ <AlertDialog
1219
+ open={confirmation === `revoke:${invite.id}`}
1220
+ onOpenChange={(open) => {
1221
+ if (
1222
+ !busyId &&
1223
+ !isDeletingTeam &&
1224
+ !teamDeleted
1225
+ ) {
1226
+ setConfirmation(
1227
+ open ? `revoke:${invite.id}` : null,
1228
+ )
1229
+ }
1230
+ }}
1231
+ >
1232
+ <Tooltip>
1233
+ <TooltipTrigger asChild>
1234
+ <AlertDialogTrigger asChild>
1235
+ <Button
1236
+ variant="destructive"
1237
+ size="icon"
1238
+ type="button"
1239
+ aria-label="Remove invite"
1240
+ disabled={busyId === invite.id}
1241
+ >
1242
+ <Trash2Icon />
1243
+ </Button>
1244
+ </AlertDialogTrigger>
1245
+ </TooltipTrigger>
1246
+ <TooltipContent>
1247
+ Remove invite
1248
+ </TooltipContent>
1249
+ </Tooltip>
1250
+ <AlertDialogContent>
1251
+ <AlertDialogHeader>
1252
+ <AlertDialogTitle>
1253
+ Revoke invite?
1254
+ </AlertDialogTitle>
1255
+ <AlertDialogDescription>
1256
+ This revokes the pending invitation for{' '}
1257
+ {invite.email}.
1258
+ </AlertDialogDescription>
1259
+ </AlertDialogHeader>
1260
+ <AlertDialogFooter>
1261
+ <AlertDialogCancel
1262
+ disabled={busyId === invite.id}
1263
+ >
1264
+ Cancel
1265
+ </AlertDialogCancel>
1266
+ <AlertDialogAction
1267
+ variant="destructive"
1268
+ disabled={busyId === invite.id}
1269
+ onClick={(event) => {
1270
+ event.preventDefault()
1271
+ void revokeInvite(invite.id)
1272
+ }}
1273
+ >
1274
+ {busyId === invite.id ? (
1275
+ <LoaderIcon className="animate-spin" />
1276
+ ) : null}
1277
+ Revoke invite
1278
+ </AlertDialogAction>
1279
+ </AlertDialogFooter>
1280
+ </AlertDialogContent>
1281
+ </AlertDialog>
1282
+ </div>
1283
+ </div>
1284
+ </TableCell>
1285
+ </TableRow>
1286
+ ))}
1287
+ </TableBody>
1288
+ </Table>
1289
+ </div>
1290
+ </div>
1291
+ ) : null}
1292
+ <AlertDialog
1293
+ open={Boolean(pendingRoleChange)}
1294
+ onOpenChange={(open) => {
1295
+ if (!open && !busyId) {
1296
+ setPendingRoleChange(null)
1297
+ }
1298
+ }}
1299
+ >
1300
+ <AlertDialogContent>
1301
+ <AlertDialogHeader>
1302
+ <AlertDialogTitle>Change member role?</AlertDialogTitle>
1303
+ <AlertDialogDescription>
1304
+ {pendingRoleChange ? (
1305
+ <>
1306
+ This will change{' '}
1307
+ {pendingRoleChange.member.user.name ||
1308
+ pendingRoleChange.member.user.email}{' '}
1309
+ to {pendingRoleChange.role}.
1310
+ </>
1311
+ ) : null}
1312
+ </AlertDialogDescription>
1313
+ </AlertDialogHeader>
1314
+ <AlertDialogFooter>
1315
+ <AlertDialogCancel
1316
+ disabled={
1317
+ pendingRoleChange
1318
+ ? busyId === pendingRoleChange.member.user.id
1319
+ : false
1320
+ }
1321
+ >
1322
+ Cancel
1323
+ </AlertDialogCancel>
1324
+ <AlertDialogAction
1325
+ disabled={
1326
+ pendingRoleChange
1327
+ ? busyId === pendingRoleChange.member.user.id
1328
+ : true
1329
+ }
1330
+ onClick={(event) => {
1331
+ event.preventDefault()
1332
+ if (!pendingRoleChange) return
1333
+ void updateRole(
1334
+ pendingRoleChange.member.user.id,
1335
+ pendingRoleChange.role,
1336
+ )
1337
+ }}
1338
+ >
1339
+ {pendingRoleChange &&
1340
+ busyId === pendingRoleChange.member.user.id ? (
1341
+ <LoaderIcon className="animate-spin" />
1342
+ ) : null}
1343
+ Change role
1344
+ </AlertDialogAction>
1345
+ </AlertDialogFooter>
1346
+ </AlertDialogContent>
1347
+ </AlertDialog>
1348
+ <AlertDialog
1349
+ open={Boolean(pendingOwnershipTransfer)}
1350
+ onOpenChange={(open) => {
1351
+ if (!open && !busyId) {
1352
+ setPendingOwnershipTransfer(null)
1353
+ }
1354
+ }}
1355
+ >
1356
+ <AlertDialogContent>
1357
+ <AlertDialogHeader>
1358
+ <AlertDialogTitle>Transfer ownership?</AlertDialogTitle>
1359
+ <AlertDialogDescription>
1360
+ {pendingOwnershipTransfer ? (
1361
+ <>
1362
+ This will make{' '}
1363
+ {pendingOwnershipTransfer.user.name ||
1364
+ pendingOwnershipTransfer.user.email}{' '}
1365
+ the team owner and change your role to admin.
1366
+ </>
1367
+ ) : null}
1368
+ </AlertDialogDescription>
1369
+ </AlertDialogHeader>
1370
+ <AlertDialogFooter>
1371
+ <AlertDialogCancel
1372
+ disabled={
1373
+ pendingOwnershipTransfer
1374
+ ? busyId === pendingOwnershipTransfer.user.id
1375
+ : false
1376
+ }
1377
+ >
1378
+ Cancel
1379
+ </AlertDialogCancel>
1380
+ <AlertDialogAction
1381
+ disabled={
1382
+ pendingOwnershipTransfer
1383
+ ? busyId === pendingOwnershipTransfer.user.id
1384
+ : true
1385
+ }
1386
+ onClick={(event) => {
1387
+ event.preventDefault()
1388
+ if (!pendingOwnershipTransfer) return
1389
+ void transferOwnership(pendingOwnershipTransfer.user.id)
1390
+ }}
1391
+ >
1392
+ {pendingOwnershipTransfer &&
1393
+ busyId === pendingOwnershipTransfer.user.id ? (
1394
+ <LoaderIcon className="animate-spin" />
1395
+ ) : null}
1396
+ Transfer ownership
1397
+ </AlertDialogAction>
1398
+ </AlertDialogFooter>
1399
+ </AlertDialogContent>
1400
+ </AlertDialog>
1401
+ </div>
1402
+ </SettingsSection>
1403
+ <WorkspaceSettingsContributions teamSlug={team.slug} role={team.role} />
1404
+ {backupsEnabled ? (
1405
+ <BackupStorageSettings team={team} storage={backupStorage} />
1406
+ ) : null}
1407
+ <NotificationMute scope={{ kind: 'workspace', id: team.id }} />
1408
+ <SettingsSection
1409
+ id="danger"
1410
+ title="Danger zone"
1411
+ description="Permanently delete the team and all of its settings, projects and deployments."
1412
+ >
1413
+ {!canDeleteTeamByRole(team.role) ? (
1414
+ <Alert>
1415
+ <InfoIcon />
1416
+ <AlertTitle>Only team owners can delete a team.</AlertTitle>
1417
+ </Alert>
1418
+ ) : !canDeleteTeam ? (
1419
+ <Alert>
1420
+ <InfoIcon />
1421
+ <AlertTitle>
1422
+ {isDefaultTeam
1423
+ ? 'This is your default team, you cannot leave or delete it.'
1424
+ : 'This team is a default team, it cannot be deleted.'}
1425
+ </AlertTitle>
1426
+ </Alert>
1427
+ ) : (
1428
+ <AlertDialog
1429
+ open={confirmation === 'delete'}
1430
+ onOpenChange={(open) => {
1431
+ if (!busyId && !isDeletingTeam && !teamDeleted) {
1432
+ setConfirmation(open ? 'delete' : null)
1433
+ }
1434
+ }}
1435
+ >
1436
+ <AlertDialogTrigger asChild>
1437
+ <Button
1438
+ variant="destructive"
1439
+ disabled={isDeletingTeam || teamDeleted}
1440
+ onClick={() => setDeleteTeamConfirmation('')}
1441
+ >
1442
+ {isDeletingTeam ? (
1443
+ <LoaderIcon
1444
+ data-icon="inline-start"
1445
+ className="animate-spin"
1446
+ />
1447
+ ) : (
1448
+ <Trash2Icon data-icon="inline-start" />
1449
+ )}
1450
+ Delete team
1451
+ </Button>
1452
+ </AlertDialogTrigger>
1453
+ <AlertDialogContent>
1454
+ <AlertDialogHeader>
1455
+ <AlertDialogTitle>Delete this team?</AlertDialogTitle>
1456
+ <AlertDialogDescription>
1457
+ This permanently deletes the team, its projects, servers,
1458
+ members, and pending invites. This cannot be undone.{' '}
1459
+ {`Type "${team.slug}" to confirm.`}
1460
+ </AlertDialogDescription>
1461
+ </AlertDialogHeader>
1462
+ <Field>
1463
+ <Input
1464
+ aria-label={`Type ${team.slug} to confirm`}
1465
+ id="delete-team-confirmation"
1466
+ required
1467
+ disabled={isDeletingTeam || teamDeleted}
1468
+ placeholder={team.slug}
1469
+ value={deleteTeamConfirmation}
1470
+ onChange={(event) =>
1471
+ setDeleteTeamConfirmation(event.target.value)
1472
+ }
1473
+ onKeyDown={(event) => {
1474
+ if (
1475
+ event.key === 'Enter' &&
1476
+ !isDeletingTeam &&
1477
+ deleteTeamConfirmation === team.slug
1478
+ ) {
1479
+ event.preventDefault()
1480
+ void deleteCurrentTeam()
1481
+ }
1482
+ }}
1483
+ />
1484
+ </Field>
1485
+ <AlertDialogFooter>
1486
+ <AlertDialogCancel disabled={isDeletingTeam || teamDeleted}>
1487
+ Cancel
1488
+ </AlertDialogCancel>
1489
+ <AlertDialogAction
1490
+ variant="destructive"
1491
+ disabled={
1492
+ isDeletingTeam || deleteTeamConfirmation !== team.slug
1493
+ }
1494
+ onClick={(event) => {
1495
+ event.preventDefault()
1496
+ void deleteCurrentTeam()
1497
+ }}
1498
+ >
1499
+ {isDeletingTeam ? (
1500
+ <LoaderIcon className="animate-spin" />
1501
+ ) : null}
1502
+ Delete team
1503
+ </AlertDialogAction>
1504
+ </AlertDialogFooter>
1505
+ </AlertDialogContent>
1506
+ </AlertDialog>
1507
+ )}
1508
+ </SettingsSection>
1509
+ </SettingsLayout>
1510
+ </div>
1511
+ )
1512
+ }
1513
+
1514
+ function SettingsActions({
1515
+ disabled,
1516
+ isSaving,
1517
+ isReading,
1518
+ onCancel,
1519
+ }: {
1520
+ disabled: boolean
1521
+ isSaving: boolean
1522
+ isReading: boolean
1523
+ onCancel: () => void
1524
+ }) {
1525
+ return (
1526
+ <>
1527
+ <Button
1528
+ variant="ghost"
1529
+ type="button"
1530
+ onClick={onCancel}
1531
+ disabled={disabled || isSaving}
1532
+ >
1533
+ Cancel
1534
+ </Button>
1535
+ <Button
1536
+ type="submit"
1537
+ form="team-general-form"
1538
+ disabled={disabled || isReading}
1539
+ >
1540
+ {isSaving ? (
1541
+ <LoaderIcon data-icon="inline-start" className="animate-spin" />
1542
+ ) : null}
1543
+ Save
1544
+ </Button>
1545
+ </>
1546
+ )
1547
+ }
1548
+
1549
+ function formatInviteDate(value: string) {
1550
+ return new Intl.DateTimeFormat('en-US', {
1551
+ day: 'numeric',
1552
+ month: 'short',
1553
+ year: 'numeric',
1554
+ }).format(new Date(value))
1555
+ }