@app-vitals/agent-admin 0.0.1 → 0.1.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.
- package/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +62 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/admin-ui-accounts.ts
|
|
3
|
+
* GET/POST /admin/accounts[/:id] — platform-admin account management
|
|
4
|
+
* (SSP-5.2). Admin only (403 otherwise) and flag-gated (404 when self-serve
|
|
5
|
+
* is off). Status and trial edits go through AccountLifecycle (SSP-8.2), so
|
|
6
|
+
* suspend locks down the account's crons and reactivate restores them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Context, Hono, MiddlewareHandler } from "hono";
|
|
10
|
+
import { normalizeEmail } from "./account-email.ts";
|
|
11
|
+
import type { AccountInviteService } from "./account-invites.ts";
|
|
12
|
+
import type { AccountLifecycle } from "./account-lifecycle.ts";
|
|
13
|
+
import {
|
|
14
|
+
AccountMemberNotFoundError,
|
|
15
|
+
type AccountMemberService,
|
|
16
|
+
LastOwnerError,
|
|
17
|
+
} from "./account-members.ts";
|
|
18
|
+
import type { AccountService } from "./accounts.ts";
|
|
19
|
+
import type { AdminUIEnv } from "./admin-ui.ts";
|
|
20
|
+
import {
|
|
21
|
+
type AccountAgentRef,
|
|
22
|
+
ADMIN_ACCOUNTS_PATH,
|
|
23
|
+
renderAccountDetailPage,
|
|
24
|
+
renderAccountsListPage,
|
|
25
|
+
} from "./admin-ui-accounts-pages.ts";
|
|
26
|
+
import { reconcileAgentAfterAccountChange } from "./agent-account-assignment.ts";
|
|
27
|
+
import type { AgentProvisioner } from "./agent-provisioner.ts";
|
|
28
|
+
import type { AgentService } from "./agents.ts";
|
|
29
|
+
import { UnprocessableEntityError } from "./errors.ts";
|
|
30
|
+
|
|
31
|
+
export interface AdminAccountsRouteDeps {
|
|
32
|
+
/** SHIPWRIGHT_SELF_SERVE_ENABLED === "enabled". */
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
requireAuth: MiddlewareHandler<AdminUIEnv>;
|
|
35
|
+
accounts: Pick<
|
|
36
|
+
AccountService,
|
|
37
|
+
"listWithCounts" | "getWithCounts" | "listAgents"
|
|
38
|
+
>;
|
|
39
|
+
/** SSP-8.2: account edits + status transitions (cron lockdown/restore). */
|
|
40
|
+
lifecycle: Pick<AccountLifecycle, "update">;
|
|
41
|
+
members: Pick<
|
|
42
|
+
AccountMemberService,
|
|
43
|
+
"listByAccount" | "getByEmail" | "add" | "remove" | "promote" | "demote"
|
|
44
|
+
>;
|
|
45
|
+
invites: Pick<AccountInviteService, "listPending">;
|
|
46
|
+
/** SSP-5.3: assign/unassign agents (admin only). */
|
|
47
|
+
agents: Pick<AgentService, "getDetail" | "updateFields">;
|
|
48
|
+
provisioner: Pick<AgentProvisioner, "reconcile">;
|
|
49
|
+
html: (content: string, opts?: { status?: number }) => Response;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
53
|
+
const MAX_NAME_LENGTH = 100;
|
|
54
|
+
|
|
55
|
+
export function registerAdminAccountsRoutes(
|
|
56
|
+
app: Hono<AdminUIEnv>,
|
|
57
|
+
deps: AdminAccountsRouteDeps,
|
|
58
|
+
): void {
|
|
59
|
+
const notFound = () => new Response("Not Found", { status: 404 });
|
|
60
|
+
const forbidden = () => new Response("Forbidden", { status: 403 });
|
|
61
|
+
|
|
62
|
+
app.use(ADMIN_ACCOUNTS_PATH, async (_c, next) =>
|
|
63
|
+
deps.enabled ? next() : notFound(),
|
|
64
|
+
);
|
|
65
|
+
app.use(`${ADMIN_ACCOUNTS_PATH}/*`, async (_c, next) =>
|
|
66
|
+
deps.enabled ? next() : notFound(),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const requireAdmin: MiddlewareHandler<AdminUIEnv> = async (c, next) =>
|
|
70
|
+
c.var.isAdmin === true ? next() : forbidden();
|
|
71
|
+
|
|
72
|
+
app.get(ADMIN_ACCOUNTS_PATH, deps.requireAuth, requireAdmin, async (c) => {
|
|
73
|
+
const accounts = await deps.accounts.listWithCounts();
|
|
74
|
+
const owners = new Map<string, string[]>();
|
|
75
|
+
await Promise.all(
|
|
76
|
+
accounts.map(async (a) => {
|
|
77
|
+
const members = await deps.members.listByAccount(a.id);
|
|
78
|
+
owners.set(
|
|
79
|
+
a.id,
|
|
80
|
+
members.filter((m) => m.role === "owner").map((m) => m.email),
|
|
81
|
+
);
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
return deps.html(
|
|
85
|
+
renderAccountsListPage({ userEmail: c.var.userEmail, accounts, owners }),
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
async function renderDetail(
|
|
90
|
+
c: Context<AdminUIEnv>,
|
|
91
|
+
id: string,
|
|
92
|
+
error?: string,
|
|
93
|
+
status = 200,
|
|
94
|
+
): Promise<Response> {
|
|
95
|
+
const account = await deps.accounts.getWithCounts(id);
|
|
96
|
+
if (!account) return notFound();
|
|
97
|
+
const [agents, members, invites] = await Promise.all([
|
|
98
|
+
deps.accounts.listAgents(id),
|
|
99
|
+
deps.members.listByAccount(id),
|
|
100
|
+
deps.invites.listPending(id),
|
|
101
|
+
]);
|
|
102
|
+
return deps.html(
|
|
103
|
+
renderAccountDetailPage({
|
|
104
|
+
userEmail: c.var.userEmail,
|
|
105
|
+
account,
|
|
106
|
+
agents: agents as AccountAgentRef[],
|
|
107
|
+
members,
|
|
108
|
+
invites,
|
|
109
|
+
error,
|
|
110
|
+
}),
|
|
111
|
+
{ status },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
app.get(`${ADMIN_ACCOUNTS_PATH}/:id`, deps.requireAuth, requireAdmin, (c) =>
|
|
116
|
+
renderDetail(c, c.req.param("id")),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Admin POST action: 404 for unknown account, runs `run` with the form,
|
|
121
|
+
* 302 back to the detail page on success, re-renders with the error on a
|
|
122
|
+
* known failure.
|
|
123
|
+
*/
|
|
124
|
+
function action(
|
|
125
|
+
path: string,
|
|
126
|
+
run: (args: { id: string; form: FormData }) => Promise<string | undefined>,
|
|
127
|
+
): void {
|
|
128
|
+
app.post(
|
|
129
|
+
`${ADMIN_ACCOUNTS_PATH}/:id/${path}`,
|
|
130
|
+
deps.requireAuth,
|
|
131
|
+
requireAdmin,
|
|
132
|
+
async (c) => {
|
|
133
|
+
const id = c.req.param("id");
|
|
134
|
+
if (!(await deps.accounts.getWithCounts(id))) return notFound();
|
|
135
|
+
let form = new FormData();
|
|
136
|
+
try {
|
|
137
|
+
form = await c.req.formData();
|
|
138
|
+
} catch {
|
|
139
|
+
// malformed body — treated as empty fields below
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const error = await run({ id, form });
|
|
143
|
+
if (error) return renderDetail(c, id, error, 400);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err instanceof LastOwnerError) {
|
|
146
|
+
return renderDetail(
|
|
147
|
+
c,
|
|
148
|
+
id,
|
|
149
|
+
"Cannot remove or demote the last owner of the account.",
|
|
150
|
+
400,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (err instanceof AccountMemberNotFoundError) {
|
|
154
|
+
return renderDetail(c, id, "Member not found.", 404);
|
|
155
|
+
}
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
return c.redirect(`${ADMIN_ACCOUNTS_PATH}/${id}`, 302);
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const field = (form: FormData, name: string) =>
|
|
164
|
+
(form.get(name)?.toString() ?? "").trim();
|
|
165
|
+
|
|
166
|
+
action("update", async ({ id, form }) => {
|
|
167
|
+
const name = field(form, "name");
|
|
168
|
+
if (!name) return "Account name is required.";
|
|
169
|
+
if (name.length > MAX_NAME_LENGTH) {
|
|
170
|
+
return `Account name must be at most ${MAX_NAME_LENGTH} characters.`;
|
|
171
|
+
}
|
|
172
|
+
const maxRaw = field(form, "maxAgents");
|
|
173
|
+
const maxAgents = Number(maxRaw);
|
|
174
|
+
if (maxRaw === "" || !Number.isInteger(maxAgents) || maxAgents < 0) {
|
|
175
|
+
return "Max agents must be a non-negative integer.";
|
|
176
|
+
}
|
|
177
|
+
const trialRaw = field(form, "trialExpiresAt");
|
|
178
|
+
let trialExpiresAt: Date | null = null;
|
|
179
|
+
if (trialRaw) {
|
|
180
|
+
trialExpiresAt = new Date(trialRaw);
|
|
181
|
+
if (Number.isNaN(trialExpiresAt.getTime())) {
|
|
182
|
+
return "Trial expiry must be a valid date.";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
await deps.lifecycle.update(id, {
|
|
186
|
+
name,
|
|
187
|
+
maxAgents,
|
|
188
|
+
plan: field(form, "plan") || null,
|
|
189
|
+
trialExpiresAt,
|
|
190
|
+
});
|
|
191
|
+
return undefined;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
action("suspend", async ({ id }) => {
|
|
195
|
+
await deps.lifecycle.update(id, { status: "suspended" });
|
|
196
|
+
return undefined;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
action("reactivate", async ({ id }) => {
|
|
200
|
+
try {
|
|
201
|
+
await deps.lifecycle.update(id, { status: "active" });
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err instanceof UnprocessableEntityError) return err.message;
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/** SSP-5.3: set (accountId) or clear (null) Agent.accountId, then reconcile. */
|
|
210
|
+
async function setAgentAccount(
|
|
211
|
+
agentId: string,
|
|
212
|
+
accountId: string | null,
|
|
213
|
+
): Promise<undefined> {
|
|
214
|
+
const updated = await deps.agents.updateFields(agentId, { accountId });
|
|
215
|
+
// The change is committed; a reconcile failure is logged, not surfaced as
|
|
216
|
+
// a form error (the next reconcile pass converges the workload).
|
|
217
|
+
const warning = await reconcileAgentAfterAccountChange(
|
|
218
|
+
deps.provisioner,
|
|
219
|
+
updated,
|
|
220
|
+
);
|
|
221
|
+
if (warning) console.warn(`[admin-accounts] ${agentId}: ${warning}`);
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
action("agents/assign", async ({ id, form }) => {
|
|
226
|
+
const agentId = field(form, "agentId");
|
|
227
|
+
if (!agentId) return "Agent ID is required.";
|
|
228
|
+
if (!(await deps.agents.getDetail(agentId))) {
|
|
229
|
+
return `Agent ${agentId} not found.`;
|
|
230
|
+
}
|
|
231
|
+
return setAgentAccount(agentId, id);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
action("agents/unassign", async ({ id, form }) => {
|
|
235
|
+
const agentId = field(form, "agentId");
|
|
236
|
+
const agent = await deps.agents.getDetail(agentId);
|
|
237
|
+
if (!agent || agent.accountId !== id) {
|
|
238
|
+
return "Agent is not assigned to this account.";
|
|
239
|
+
}
|
|
240
|
+
return setAgentAccount(agentId, null);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
action("members/add", async ({ id, form }) => {
|
|
244
|
+
const email = normalizeEmail(field(form, "email"));
|
|
245
|
+
if (!EMAIL_RE.test(email)) return "Enter a valid email address.";
|
|
246
|
+
if (await deps.members.getByEmail(email)) {
|
|
247
|
+
return "That email already belongs to an account.";
|
|
248
|
+
}
|
|
249
|
+
const role = field(form, "role") === "owner" ? "owner" : "member";
|
|
250
|
+
await deps.members.add(id, email, role);
|
|
251
|
+
return undefined;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
action("members/remove", async ({ id, form }) => {
|
|
255
|
+
await deps.members.remove(id, field(form, "email"));
|
|
256
|
+
return undefined;
|
|
257
|
+
});
|
|
258
|
+
action("members/promote", async ({ id, form }) => {
|
|
259
|
+
await deps.members.promote(id, field(form, "email"));
|
|
260
|
+
return undefined;
|
|
261
|
+
});
|
|
262
|
+
action("members/demote", async ({ id, form }) => {
|
|
263
|
+
await deps.members.demote(id, field(form, "email"));
|
|
264
|
+
return undefined;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/admin-ui-layout.ts
|
|
3
|
+
* Shared admin page-head helper — a dumb string wrapper around the
|
|
4
|
+
* `<!DOCTYPE html>` boilerplate that every render*Page function in
|
|
5
|
+
* admin-ui-pages.ts needs: charset, a normalized viewport meta, an escaped
|
|
6
|
+
* title, a <style> tag combining baseStyles() with page-specific extra
|
|
7
|
+
* rules, and the <body> wrapper.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately carries no toolbar logic — callers still invoke
|
|
10
|
+
* renderAdminToolbar() themselves as part of their `body` string (and
|
|
11
|
+
* renderLoginPage has no toolbar at all).
|
|
12
|
+
*
|
|
13
|
+
* Follows the same inline HTML template string pattern as
|
|
14
|
+
* metrics/src/dashboard/dashboard-page.ts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { baseStyles, escapeHtml } from "./admin-ui-styles.ts";
|
|
18
|
+
|
|
19
|
+
interface RenderAdminPageOptions {
|
|
20
|
+
/** Raw, unescaped page title — HTML-escaped internally before rendering. */
|
|
21
|
+
title: string;
|
|
22
|
+
/** Extra CSS rules appended inside <style> after baseStyles(). */
|
|
23
|
+
extraStyles?: string;
|
|
24
|
+
/** Extra markup appended inside <head>, after the <style> tag. */
|
|
25
|
+
headExtra?: string;
|
|
26
|
+
/** Optional class attribute for <body>. Omitted entirely when unset. */
|
|
27
|
+
bodyClass?: string;
|
|
28
|
+
/** Body markup — callers own toolbar calls, cards, forms, etc. */
|
|
29
|
+
body: string;
|
|
30
|
+
/** Markup appended after `body`, still inside <body> (e.g. a page-level <script>). */
|
|
31
|
+
bodyEnd?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Renders the shared `<!DOCTYPE html>` document wrapper used by every admin
|
|
36
|
+
* page. The viewport meta is normalized to
|
|
37
|
+
* `width=device-width, initial-scale=1, viewport-fit=cover` —
|
|
38
|
+
* `viewport-fit=cover` is load-bearing: it's what makes
|
|
39
|
+
* `env(safe-area-inset-*)` return non-zero on iOS, which the sticky composer
|
|
40
|
+
* and standalone/PWA mode both need. Does not add `maximum-scale=1`, which
|
|
41
|
+
* would be an accessibility regression.
|
|
42
|
+
*/
|
|
43
|
+
export function renderAdminPage(opts: RenderAdminPageOptions): string {
|
|
44
|
+
const bodyTag = opts.bodyClass
|
|
45
|
+
? `<body class="${opts.bodyClass}">`
|
|
46
|
+
: "<body>";
|
|
47
|
+
|
|
48
|
+
return `<!DOCTYPE html>
|
|
49
|
+
<html lang="en">
|
|
50
|
+
<head>
|
|
51
|
+
<meta charset="utf-8" />
|
|
52
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
53
|
+
<title>${escapeHtml(opts.title)}</title>
|
|
54
|
+
<style>${baseStyles()}${opts.extraStyles ?? ""}</style>${opts.headExtra ? `\n ${opts.headExtra}` : ""}
|
|
55
|
+
</head>
|
|
56
|
+
${bodyTag}
|
|
57
|
+
${opts.body}${opts.bodyEnd ? `\n ${opts.bodyEnd}` : ""}
|
|
58
|
+
</body>
|
|
59
|
+
</html>`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/admin-ui-layout.unit.test.ts
|
|
3
|
+
* Pure unit tests for renderAdminPage() — the shared admin page-head helper.
|
|
4
|
+
*
|
|
5
|
+
* Strategy: call renderAdminPage() directly, assert on the returned HTML
|
|
6
|
+
* string. No I/O, no Hono, no HTTP — pure string → string.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { renderAdminPage } from "./admin-ui-layout.ts";
|
|
11
|
+
|
|
12
|
+
describe("renderAdminPage", () => {
|
|
13
|
+
test("renders a full HTML document with DOCTYPE, head, and body", () => {
|
|
14
|
+
const html = renderAdminPage({
|
|
15
|
+
title: "My Page",
|
|
16
|
+
body: "<div>hello</div>",
|
|
17
|
+
});
|
|
18
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
19
|
+
expect(html).toContain('<html lang="en">');
|
|
20
|
+
expect(html).toContain("</html>");
|
|
21
|
+
expect(html).toContain("<div>hello</div>");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("escapes the title param", () => {
|
|
25
|
+
const html = renderAdminPage({
|
|
26
|
+
title: '<script>alert("xss")</script>',
|
|
27
|
+
body: "<div>hello</div>",
|
|
28
|
+
});
|
|
29
|
+
expect(html).not.toContain("<title><script>");
|
|
30
|
+
expect(html).toContain(
|
|
31
|
+
"<title><script>alert("xss")</script></title>",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("normalizes the viewport meta to width=device-width, initial-scale=1, viewport-fit=cover", () => {
|
|
36
|
+
const html = renderAdminPage({ title: "Page", body: "<div></div>" });
|
|
37
|
+
expect(html).toContain(
|
|
38
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />',
|
|
39
|
+
);
|
|
40
|
+
expect(html).not.toContain("maximum-scale=1");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("renders exactly one DOCTYPE and one viewport meta tag", () => {
|
|
44
|
+
const html = renderAdminPage({ title: "Page", body: "<div></div>" });
|
|
45
|
+
expect(html.match(/<!DOCTYPE html>/g)?.length).toBe(1);
|
|
46
|
+
expect(html.match(/<meta name="viewport"/g)?.length).toBe(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("includes charset meta tag", () => {
|
|
50
|
+
const html = renderAdminPage({ title: "Page", body: "<div></div>" });
|
|
51
|
+
expect(html).toContain('<meta charset="utf-8" />');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("extraStyles content appears after baseStyles() inside the style tag", () => {
|
|
55
|
+
const html = renderAdminPage({
|
|
56
|
+
title: "Page",
|
|
57
|
+
extraStyles: ".my-extra-class { color: red; }",
|
|
58
|
+
body: "<div></div>",
|
|
59
|
+
});
|
|
60
|
+
const styleOpenIdx = html.indexOf("<style>");
|
|
61
|
+
const styleCloseIdx = html.indexOf("</style>");
|
|
62
|
+
const extraIdx = html.indexOf(".my-extra-class");
|
|
63
|
+
expect(styleOpenIdx).toBeGreaterThan(-1);
|
|
64
|
+
expect(extraIdx).toBeGreaterThan(styleOpenIdx);
|
|
65
|
+
expect(extraIdx).toBeLessThan(styleCloseIdx);
|
|
66
|
+
|
|
67
|
+
// baseStyles() output (e.g. the .page-header rule) must precede extraStyles.
|
|
68
|
+
const baseStylesIdx = html.indexOf(".page-header");
|
|
69
|
+
expect(baseStylesIdx).toBeGreaterThan(-1);
|
|
70
|
+
expect(baseStylesIdx).toBeLessThan(extraIdx);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("without extraStyles, style tag still closes cleanly right after baseStyles()", () => {
|
|
74
|
+
const html = renderAdminPage({ title: "Page", body: "<div></div>" });
|
|
75
|
+
expect(html).toContain("</style>");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("headExtra content is included in the head, after the style tag", () => {
|
|
79
|
+
const html = renderAdminPage({
|
|
80
|
+
title: "Page",
|
|
81
|
+
headExtra: '<link rel="manifest" href="/manifest.json" />',
|
|
82
|
+
body: "<div></div>",
|
|
83
|
+
});
|
|
84
|
+
const styleCloseIdx = html.indexOf("</style>");
|
|
85
|
+
const headExtraIdx = html.indexOf("/manifest.json");
|
|
86
|
+
const headCloseIdx = html.indexOf("</head>");
|
|
87
|
+
expect(headExtraIdx).toBeGreaterThan(styleCloseIdx);
|
|
88
|
+
expect(headExtraIdx).toBeLessThan(headCloseIdx);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("without headExtra, head still closes cleanly", () => {
|
|
92
|
+
const html = renderAdminPage({ title: "Page", body: "<div></div>" });
|
|
93
|
+
expect(html).toContain("</head>");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("bodyClass is applied to the body tag when provided", () => {
|
|
97
|
+
const html = renderAdminPage({
|
|
98
|
+
title: "Page",
|
|
99
|
+
bodyClass: "my-body-class",
|
|
100
|
+
body: "<div></div>",
|
|
101
|
+
});
|
|
102
|
+
expect(html).toContain('<body class="my-body-class">');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("body has no class attribute when bodyClass is omitted", () => {
|
|
106
|
+
const html = renderAdminPage({ title: "Page", body: "<div></div>" });
|
|
107
|
+
expect(html).toContain("<body>");
|
|
108
|
+
expect(html).not.toContain("<body class=");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("bodyEnd content appears after body content and before </body>", () => {
|
|
112
|
+
const html = renderAdminPage({
|
|
113
|
+
title: "Page",
|
|
114
|
+
body: '<div id="main-body-marker">main</div>',
|
|
115
|
+
bodyEnd: '<script id="body-end-marker">console.log("end")</script>',
|
|
116
|
+
});
|
|
117
|
+
const bodyIdx = html.indexOf("main-body-marker");
|
|
118
|
+
const bodyEndIdx = html.indexOf("body-end-marker");
|
|
119
|
+
const bodyCloseIdx = html.indexOf("</body>");
|
|
120
|
+
expect(bodyIdx).toBeGreaterThan(-1);
|
|
121
|
+
expect(bodyEndIdx).toBeGreaterThan(bodyIdx);
|
|
122
|
+
expect(bodyEndIdx).toBeLessThan(bodyCloseIdx);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("without bodyEnd, body closes cleanly right after body content", () => {
|
|
126
|
+
const html = renderAdminPage({ title: "Page", body: "<div>content</div>" });
|
|
127
|
+
expect(html).toContain("<div>content</div>");
|
|
128
|
+
expect(html).toContain("</body>");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("does not call renderAdminToolbar or inject any toolbar markup", () => {
|
|
132
|
+
const html = renderAdminPage({ title: "Page", body: "<div></div>" });
|
|
133
|
+
// baseStyles() legitimately contains `.vos-toolbar { ... }` and
|
|
134
|
+
// `.vos-signout-btn { ... }` CSS rules, but renderAdminPage itself must
|
|
135
|
+
// never inject the toolbar's actual <nav>/<button> markup — that's the
|
|
136
|
+
// caller's responsibility via body.
|
|
137
|
+
expect(html).not.toContain('<nav class="vos-toolbar"');
|
|
138
|
+
expect(html).not.toContain('class="vos-signout-btn"');
|
|
139
|
+
});
|
|
140
|
+
});
|