@app-vitals/agent-admin 0.0.1 → 0.1.1
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,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/admin-ui-account.smoke.test.ts
|
|
3
|
+
* Smoke tests for /admin/account routes (SSP-3.2). In-memory service doubles.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "bun:test";
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import type { MiddlewareHandler } from "hono";
|
|
9
|
+
import {
|
|
10
|
+
type AccountRouteDeps,
|
|
11
|
+
registerAccountRoutes,
|
|
12
|
+
} from "./admin-ui-account.ts";
|
|
13
|
+
import type { AdminUIEnv } from "./admin-ui.ts";
|
|
14
|
+
import {
|
|
15
|
+
AccountMemberNotFoundError,
|
|
16
|
+
LastOwnerError,
|
|
17
|
+
} from "./account-members.ts";
|
|
18
|
+
|
|
19
|
+
const d = new Date("2026-01-01T00:00:00Z");
|
|
20
|
+
|
|
21
|
+
function setup(
|
|
22
|
+
opts: { enabled?: boolean; seedMembers?: Array<[string, string]> } = {},
|
|
23
|
+
) {
|
|
24
|
+
const account = {
|
|
25
|
+
id: "a1",
|
|
26
|
+
name: "Acme",
|
|
27
|
+
status: "active",
|
|
28
|
+
maxAgents: 2,
|
|
29
|
+
plan: null,
|
|
30
|
+
trialExpiresAt: null,
|
|
31
|
+
trialExpiryWarnedAt: null,
|
|
32
|
+
createdAt: d,
|
|
33
|
+
updatedAt: d,
|
|
34
|
+
};
|
|
35
|
+
let members = (
|
|
36
|
+
opts.seedMembers ?? [
|
|
37
|
+
["owner@x.com", "owner"],
|
|
38
|
+
["member@x.com", "member"],
|
|
39
|
+
]
|
|
40
|
+
).map(([email, role], i) => ({
|
|
41
|
+
id: `m${i}`,
|
|
42
|
+
accountId: "a1",
|
|
43
|
+
email,
|
|
44
|
+
role,
|
|
45
|
+
createdAt: d,
|
|
46
|
+
}));
|
|
47
|
+
let invites: Array<{
|
|
48
|
+
id: string;
|
|
49
|
+
accountId: string;
|
|
50
|
+
email: string;
|
|
51
|
+
invitedBy: string;
|
|
52
|
+
acceptedAt: null;
|
|
53
|
+
createdAt: Date;
|
|
54
|
+
}> = [];
|
|
55
|
+
const ownersLeft = () => members.filter((m) => m.role === "owner").length;
|
|
56
|
+
|
|
57
|
+
const deps: AccountRouteDeps = {
|
|
58
|
+
enabled: opts.enabled ?? true,
|
|
59
|
+
requireAuth: (async (c, next) => {
|
|
60
|
+
c.set("userEmail", c.req.header("x-test-user-email") ?? "owner@x.com");
|
|
61
|
+
c.set("isAdmin", false);
|
|
62
|
+
await next();
|
|
63
|
+
}) as MiddlewareHandler<AdminUIEnv>,
|
|
64
|
+
html: (content, o) =>
|
|
65
|
+
new Response(content, {
|
|
66
|
+
status: o?.status ?? 200,
|
|
67
|
+
headers: { "Content-Type": "text/html" },
|
|
68
|
+
}),
|
|
69
|
+
accounts: {
|
|
70
|
+
async getByMemberEmail(email) {
|
|
71
|
+
return members.some((m) => m.email === email.toLowerCase())
|
|
72
|
+
? (account as never)
|
|
73
|
+
: null;
|
|
74
|
+
},
|
|
75
|
+
async update(_id, data) {
|
|
76
|
+
Object.assign(account, data);
|
|
77
|
+
return account as never;
|
|
78
|
+
},
|
|
79
|
+
async countAgents() {
|
|
80
|
+
return 1;
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
members: {
|
|
84
|
+
async listByAccount() {
|
|
85
|
+
return members as never;
|
|
86
|
+
},
|
|
87
|
+
async getByEmail(email) {
|
|
88
|
+
return (members.find((m) => m.email === email) ?? null) as never;
|
|
89
|
+
},
|
|
90
|
+
async remove(_a, email) {
|
|
91
|
+
const m = members.find((x) => x.email === email);
|
|
92
|
+
if (!m) throw new AccountMemberNotFoundError("a1", email);
|
|
93
|
+
if (m.role === "owner" && ownersLeft() <= 1)
|
|
94
|
+
throw new LastOwnerError("a1");
|
|
95
|
+
members = members.filter((x) => x !== m);
|
|
96
|
+
},
|
|
97
|
+
async promote(_a, email) {
|
|
98
|
+
const m = members.find((x) => x.email === email);
|
|
99
|
+
if (!m) throw new AccountMemberNotFoundError("a1", email);
|
|
100
|
+
m.role = "owner";
|
|
101
|
+
return m as never;
|
|
102
|
+
},
|
|
103
|
+
async demote(_a, email) {
|
|
104
|
+
const m = members.find((x) => x.email === email);
|
|
105
|
+
if (!m) throw new AccountMemberNotFoundError("a1", email);
|
|
106
|
+
if (m.role === "owner" && ownersLeft() <= 1)
|
|
107
|
+
throw new LastOwnerError("a1");
|
|
108
|
+
m.role = "member";
|
|
109
|
+
return m as never;
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
invites: {
|
|
113
|
+
async create(_a, rawEmail, by) {
|
|
114
|
+
const email = rawEmail.trim().toLowerCase();
|
|
115
|
+
const inv = {
|
|
116
|
+
id: `i${invites.length}`,
|
|
117
|
+
accountId: "a1",
|
|
118
|
+
email,
|
|
119
|
+
invitedBy: by,
|
|
120
|
+
acceptedAt: null,
|
|
121
|
+
createdAt: d,
|
|
122
|
+
};
|
|
123
|
+
invites = [...invites.filter((i) => i.email !== email), inv];
|
|
124
|
+
return inv as never;
|
|
125
|
+
},
|
|
126
|
+
async listPending() {
|
|
127
|
+
return invites as never;
|
|
128
|
+
},
|
|
129
|
+
async revoke(_a, email) {
|
|
130
|
+
invites = invites.filter((i) => i.email !== email);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
const app = new Hono<AdminUIEnv>();
|
|
135
|
+
registerAccountRoutes(app, deps);
|
|
136
|
+
return { app, getMembers: () => members, getAccount: () => account };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function post(
|
|
140
|
+
app: Hono<AdminUIEnv>,
|
|
141
|
+
path: string,
|
|
142
|
+
fields: Record<string, string>,
|
|
143
|
+
email?: string,
|
|
144
|
+
) {
|
|
145
|
+
return app.request(path, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
body: new URLSearchParams(fields).toString(),
|
|
148
|
+
headers: {
|
|
149
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
150
|
+
...(email ? { "x-test-user-email": email } : {}),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe("/admin/account", () => {
|
|
156
|
+
it("returns 404 for every route when the flag is off", async () => {
|
|
157
|
+
const { app } = setup({ enabled: false });
|
|
158
|
+
expect((await app.request("/admin/account")).status).toBe(404);
|
|
159
|
+
expect(
|
|
160
|
+
(await post(app, "/admin/account/invite", { email: "a@b.co" })).status,
|
|
161
|
+
).toBe(404);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("returns 404 for a signed-in user without an account", async () => {
|
|
165
|
+
const { app } = setup();
|
|
166
|
+
const res = await app.request("/admin/account", {
|
|
167
|
+
headers: { "x-test-user-email": "stranger@x.com" },
|
|
168
|
+
});
|
|
169
|
+
expect(res.status).toBe(404);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("GET renders the account for a member", async () => {
|
|
173
|
+
const { app } = setup();
|
|
174
|
+
const res = await app.request("/admin/account", {
|
|
175
|
+
headers: { "x-test-user-email": "member@x.com" },
|
|
176
|
+
});
|
|
177
|
+
expect(res.status).toBe(200);
|
|
178
|
+
const body = await res.text();
|
|
179
|
+
expect(body).toContain("Acme");
|
|
180
|
+
expect(body).toContain("1 / 2");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("owner can invite, see it pending, and revoke it", async () => {
|
|
184
|
+
const { app } = setup();
|
|
185
|
+
const res = await post(app, "/admin/account/invite", {
|
|
186
|
+
email: "New@X.com",
|
|
187
|
+
});
|
|
188
|
+
expect(res.status).toBe(302);
|
|
189
|
+
const page = await (await app.request("/admin/account")).text();
|
|
190
|
+
expect(page).toContain("new@x.com");
|
|
191
|
+
const rev = await post(app, "/admin/account/invites/revoke", {
|
|
192
|
+
email: "new@x.com",
|
|
193
|
+
});
|
|
194
|
+
expect(rev.status).toBe(302);
|
|
195
|
+
expect(await (await app.request("/admin/account")).text()).not.toContain(
|
|
196
|
+
"new@x.com",
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("rejects an invalid or already-member invite email with 400", async () => {
|
|
201
|
+
const { app } = setup();
|
|
202
|
+
expect(
|
|
203
|
+
(await post(app, "/admin/account/invite", { email: "nope" })).status,
|
|
204
|
+
).toBe(400);
|
|
205
|
+
expect(
|
|
206
|
+
(await post(app, "/admin/account/invite", { email: "member@x.com" }))
|
|
207
|
+
.status,
|
|
208
|
+
).toBe(400);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("member gets 403 on every mutation", async () => {
|
|
212
|
+
const { app } = setup();
|
|
213
|
+
for (const [path, fields] of [
|
|
214
|
+
["/admin/account/invite", { email: "a@b.co" }],
|
|
215
|
+
["/admin/account/invites/revoke", { email: "a@b.co" }],
|
|
216
|
+
["/admin/account/members/remove", { email: "owner@x.com" }],
|
|
217
|
+
["/admin/account/members/promote", { email: "member@x.com" }],
|
|
218
|
+
["/admin/account/members/demote", { email: "owner@x.com" }],
|
|
219
|
+
["/admin/account/rename", { name: "X" }],
|
|
220
|
+
] as const) {
|
|
221
|
+
const res = await post(app, path, { ...fields }, "member@x.com");
|
|
222
|
+
expect(res.status).toBe(403);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("removing or demoting the last owner shows an error and changes nothing", async () => {
|
|
227
|
+
const { app, getMembers } = setup();
|
|
228
|
+
const rm = await post(app, "/admin/account/members/remove", {
|
|
229
|
+
email: "owner@x.com",
|
|
230
|
+
});
|
|
231
|
+
expect(rm.status).toBe(400);
|
|
232
|
+
expect(await rm.text()).toContain("last owner");
|
|
233
|
+
const dm = await post(app, "/admin/account/members/demote", {
|
|
234
|
+
email: "owner@x.com",
|
|
235
|
+
});
|
|
236
|
+
expect(dm.status).toBe(400);
|
|
237
|
+
expect(getMembers().find((m) => m.email === "owner@x.com")?.role).toBe(
|
|
238
|
+
"owner",
|
|
239
|
+
);
|
|
240
|
+
expect(getMembers()).toHaveLength(2);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("owner can promote then remove a member", async () => {
|
|
244
|
+
const { app, getMembers } = setup();
|
|
245
|
+
expect(
|
|
246
|
+
(
|
|
247
|
+
await post(app, "/admin/account/members/promote", {
|
|
248
|
+
email: "member@x.com",
|
|
249
|
+
})
|
|
250
|
+
).status,
|
|
251
|
+
).toBe(302);
|
|
252
|
+
expect(getMembers().find((m) => m.email === "member@x.com")?.role).toBe(
|
|
253
|
+
"owner",
|
|
254
|
+
);
|
|
255
|
+
expect(
|
|
256
|
+
(
|
|
257
|
+
await post(app, "/admin/account/members/remove", {
|
|
258
|
+
email: "member@x.com",
|
|
259
|
+
})
|
|
260
|
+
).status,
|
|
261
|
+
).toBe(302);
|
|
262
|
+
expect(getMembers()).toHaveLength(1);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("removing an unknown member returns 404", async () => {
|
|
266
|
+
const { app } = setup();
|
|
267
|
+
expect(
|
|
268
|
+
(
|
|
269
|
+
await post(app, "/admin/account/members/remove", {
|
|
270
|
+
email: "ghost@x.com",
|
|
271
|
+
})
|
|
272
|
+
).status,
|
|
273
|
+
).toBe(404);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("owner can rename; empty name is 400", async () => {
|
|
277
|
+
const { app, getAccount } = setup();
|
|
278
|
+
expect(
|
|
279
|
+
(await post(app, "/admin/account/rename", { name: " New Name " })).status,
|
|
280
|
+
).toBe(302);
|
|
281
|
+
expect(getAccount().name).toBe("New Name");
|
|
282
|
+
expect(
|
|
283
|
+
(await post(app, "/admin/account/rename", { name: " " })).status,
|
|
284
|
+
).toBe(400);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/admin-ui-account.ts
|
|
3
|
+
* GET/POST /admin/account — self-serve account management (SSP-3.2): name,
|
|
4
|
+
* quota, members, pending invites. Owners manage; members are read-only.
|
|
5
|
+
*
|
|
6
|
+
* Registered into admin-ui.ts's app via registerAccountRoutes(). Flag-gated:
|
|
7
|
+
* every route 404s when self-serve is off, and for signed-in users with no
|
|
8
|
+
* account.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Context, Hono, MiddlewareHandler } from "hono";
|
|
12
|
+
import type { AccountInviteService } from "./account-invites.ts";
|
|
13
|
+
import {
|
|
14
|
+
AccountMemberNotFoundError,
|
|
15
|
+
type AccountMemberService,
|
|
16
|
+
LastOwnerError,
|
|
17
|
+
} from "./account-members.ts";
|
|
18
|
+
import { normalizeEmail } from "./account-email.ts";
|
|
19
|
+
import type { Account, AccountService } from "./accounts.ts";
|
|
20
|
+
import { ACCOUNT_PATH, renderAccountPage } from "./admin-ui-account-pages.ts";
|
|
21
|
+
import type { AdminUIEnv } from "./admin-ui.ts";
|
|
22
|
+
|
|
23
|
+
export interface AccountRouteDeps {
|
|
24
|
+
/** SHIPWRIGHT_SELF_SERVE_ENABLED === "enabled". */
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
requireAuth: MiddlewareHandler<AdminUIEnv>;
|
|
27
|
+
accounts: Pick<AccountService, "getByMemberEmail" | "update" | "countAgents">;
|
|
28
|
+
members: Pick<
|
|
29
|
+
AccountMemberService,
|
|
30
|
+
"listByAccount" | "getByEmail" | "remove" | "promote" | "demote"
|
|
31
|
+
>;
|
|
32
|
+
invites: Pick<AccountInviteService, "create" | "listPending" | "revoke">;
|
|
33
|
+
html: (content: string, opts?: { status?: number }) => Response;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
37
|
+
const MAX_NAME_LENGTH = 100;
|
|
38
|
+
|
|
39
|
+
export function registerAccountRoutes(
|
|
40
|
+
app: Hono<AdminUIEnv>,
|
|
41
|
+
deps: AccountRouteDeps,
|
|
42
|
+
): void {
|
|
43
|
+
const notFound = () => new Response("Not Found", { status: 404 });
|
|
44
|
+
|
|
45
|
+
// Flag-off 404s before auth so the route is indistinguishable from absent.
|
|
46
|
+
app.use(`${ACCOUNT_PATH}`, async (_c, next) =>
|
|
47
|
+
deps.enabled ? next() : notFound(),
|
|
48
|
+
);
|
|
49
|
+
app.use(`${ACCOUNT_PATH}/*`, async (_c, next) =>
|
|
50
|
+
deps.enabled ? next() : notFound(),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
async function render(
|
|
54
|
+
c: Context<AdminUIEnv>,
|
|
55
|
+
account: Account,
|
|
56
|
+
isOwner: boolean,
|
|
57
|
+
error?: string,
|
|
58
|
+
status = 200,
|
|
59
|
+
): Promise<Response> {
|
|
60
|
+
const [members, invites, agentCount] = await Promise.all([
|
|
61
|
+
deps.members.listByAccount(account.id),
|
|
62
|
+
deps.invites.listPending(account.id),
|
|
63
|
+
deps.accounts.countAgents(account.id),
|
|
64
|
+
]);
|
|
65
|
+
return deps.html(
|
|
66
|
+
renderAccountPage({
|
|
67
|
+
userEmail: c.var.userEmail,
|
|
68
|
+
account,
|
|
69
|
+
agentCount,
|
|
70
|
+
members,
|
|
71
|
+
invites,
|
|
72
|
+
isOwner,
|
|
73
|
+
error,
|
|
74
|
+
}),
|
|
75
|
+
{ status },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Resolves the caller's account + role, or a 404 Response. */
|
|
80
|
+
async function resolve(
|
|
81
|
+
c: Context<AdminUIEnv>,
|
|
82
|
+
): Promise<{ account: Account; isOwner: boolean } | Response> {
|
|
83
|
+
const account = await deps.accounts.getByMemberEmail(c.var.userEmail);
|
|
84
|
+
if (!account) return notFound();
|
|
85
|
+
const me = await deps.members.getByEmail(c.var.userEmail);
|
|
86
|
+
return { account, isOwner: me?.role === "owner" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
app.get(ACCOUNT_PATH, deps.requireAuth, async (c) => {
|
|
90
|
+
const ctx = await resolve(c);
|
|
91
|
+
if (ctx instanceof Response) return ctx;
|
|
92
|
+
return render(c, ctx.account, ctx.isOwner);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Shared shape for owner-only POST actions: resolve + 403 for non-owners,
|
|
97
|
+
* read one form field, run the action, 302 back on success, or re-render
|
|
98
|
+
* with the error and a 4xx on a known failure.
|
|
99
|
+
*/
|
|
100
|
+
function ownerAction(
|
|
101
|
+
path: string,
|
|
102
|
+
field: "email" | "name",
|
|
103
|
+
run: (args: {
|
|
104
|
+
account: Account;
|
|
105
|
+
value: string;
|
|
106
|
+
userEmail: string;
|
|
107
|
+
}) => Promise<string | undefined>,
|
|
108
|
+
): void {
|
|
109
|
+
app.post(`${ACCOUNT_PATH}/${path}`, deps.requireAuth, async (c) => {
|
|
110
|
+
const ctx = await resolve(c);
|
|
111
|
+
if (ctx instanceof Response) return ctx;
|
|
112
|
+
if (!ctx.isOwner) return new Response("Forbidden", { status: 403 });
|
|
113
|
+
|
|
114
|
+
let value = "";
|
|
115
|
+
try {
|
|
116
|
+
const form = await c.req.formData();
|
|
117
|
+
value = (form.get(field)?.toString() ?? "").trim();
|
|
118
|
+
} catch {
|
|
119
|
+
// malformed body — treated as an empty value below
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const error = await run({
|
|
124
|
+
account: ctx.account,
|
|
125
|
+
value,
|
|
126
|
+
userEmail: c.var.userEmail,
|
|
127
|
+
});
|
|
128
|
+
if (error) return render(c, ctx.account, true, error, 400);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (err instanceof LastOwnerError) {
|
|
131
|
+
return render(
|
|
132
|
+
c,
|
|
133
|
+
ctx.account,
|
|
134
|
+
true,
|
|
135
|
+
"Cannot remove or demote the last owner of the account.",
|
|
136
|
+
400,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (err instanceof AccountMemberNotFoundError) {
|
|
140
|
+
return render(c, ctx.account, true, "Member not found.", 404);
|
|
141
|
+
}
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
return c.redirect(ACCOUNT_PATH, 302);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
ownerAction("invite", "email", async ({ account, value, userEmail }) => {
|
|
149
|
+
if (!EMAIL_RE.test(value)) return "Enter a valid email address.";
|
|
150
|
+
if (await deps.members.getByEmail(value)) {
|
|
151
|
+
return "That email already belongs to an account.";
|
|
152
|
+
}
|
|
153
|
+
await deps.invites.create(account.id, value, normalizeEmail(userEmail));
|
|
154
|
+
return undefined;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
ownerAction("invites/revoke", "email", async ({ account, value }) => {
|
|
158
|
+
await deps.invites.revoke(account.id, value);
|
|
159
|
+
return undefined;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
ownerAction("members/remove", "email", async ({ account, value }) => {
|
|
163
|
+
await deps.members.remove(account.id, value);
|
|
164
|
+
return undefined;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
ownerAction("members/promote", "email", async ({ account, value }) => {
|
|
168
|
+
await deps.members.promote(account.id, value);
|
|
169
|
+
return undefined;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
ownerAction("members/demote", "email", async ({ account, value }) => {
|
|
173
|
+
await deps.members.demote(account.id, value);
|
|
174
|
+
return undefined;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
ownerAction("rename", "name", async ({ account, value }) => {
|
|
178
|
+
if (!value) return "Account name is required.";
|
|
179
|
+
if (value.length > MAX_NAME_LENGTH) {
|
|
180
|
+
return `Account name must be at most ${MAX_NAME_LENGTH} characters.`;
|
|
181
|
+
}
|
|
182
|
+
await deps.accounts.update(account.id, { name: value });
|
|
183
|
+
return undefined;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/admin-ui-accounts-pages.ts
|
|
3
|
+
* Render functions for the platform-admin Accounts pages (SSP-5.2). Pure
|
|
4
|
+
* string -> string; routes live in admin-ui-accounts.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AccountInvite } from "./account-invites.ts";
|
|
8
|
+
import type { AccountMember } from "./account-members.ts";
|
|
9
|
+
import type { AccountWithCounts } from "./accounts.ts";
|
|
10
|
+
import { renderAdminPage } from "./admin-ui-layout.ts";
|
|
11
|
+
import { escapeHtml, renderAdminToolbar } from "./admin-ui-styles.ts";
|
|
12
|
+
|
|
13
|
+
export const ADMIN_ACCOUNTS_PATH = "/admin/accounts";
|
|
14
|
+
|
|
15
|
+
export interface AccountAgentRef {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function dateOnly(d: Date | null | undefined): string {
|
|
21
|
+
return d ? new Date(d).toISOString().slice(0, 10) : "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function statusBadge(status: string): string {
|
|
25
|
+
const cls =
|
|
26
|
+
status === "active"
|
|
27
|
+
? "badge-green"
|
|
28
|
+
: status === "suspended"
|
|
29
|
+
? "badge-red"
|
|
30
|
+
: "badge-gray";
|
|
31
|
+
return `<span class="badge ${cls}">${escapeHtml(status)}</span>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface AccountsListPageOpts {
|
|
35
|
+
userEmail: string;
|
|
36
|
+
accounts: AccountWithCounts[];
|
|
37
|
+
/** accountId -> owner emails. */
|
|
38
|
+
owners: Map<string, string[]>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderAccountsListPage(opts: AccountsListPageOpts): string {
|
|
42
|
+
const rows =
|
|
43
|
+
opts.accounts.length === 0
|
|
44
|
+
? `<tr><td colspan="8" class="empty-state">No accounts.</td></tr>`
|
|
45
|
+
: opts.accounts
|
|
46
|
+
.map((a) => {
|
|
47
|
+
const owners = opts.owners.get(a.id) ?? [];
|
|
48
|
+
return `<tr>
|
|
49
|
+
<td><a href="${ADMIN_ACCOUNTS_PATH}/${escapeHtml(a.id)}" class="agent-link">${escapeHtml(a.name)}</a></td>
|
|
50
|
+
<td>${owners.length ? owners.map(escapeHtml).join(", ") : "—"}</td>
|
|
51
|
+
<td>${a.memberCount}</td>
|
|
52
|
+
<td>${a.agentCount} / ${a.maxAgents}</td>
|
|
53
|
+
<td>${statusBadge(a.status)}</td>
|
|
54
|
+
<td>${a.trialExpiresAt ? escapeHtml(dateOnly(a.trialExpiresAt)) : "—"}</td>
|
|
55
|
+
<td>${escapeHtml(dateOnly(a.createdAt))}</td>
|
|
56
|
+
<td><a href="${ADMIN_ACCOUNTS_PATH}/${escapeHtml(a.id)}" class="btn btn-secondary" style="font-size:12px;padding:4px 10px">Manage</a></td>
|
|
57
|
+
</tr>`;
|
|
58
|
+
})
|
|
59
|
+
.join("\n");
|
|
60
|
+
|
|
61
|
+
return renderAdminPage({
|
|
62
|
+
title: "Accounts — Shipwright Admin",
|
|
63
|
+
body: `${renderAdminToolbar(opts.userEmail, ADMIN_ACCOUNTS_PATH)}
|
|
64
|
+
<div class="vos-page">
|
|
65
|
+
<div class="page-header"><h1 class="page-title">Accounts</h1></div>
|
|
66
|
+
<div class="card">
|
|
67
|
+
<table class="data-table">
|
|
68
|
+
<thead><tr><th>Name</th><th>Owner</th><th>Members</th><th>Agents</th><th>Status</th><th>Trial expires</th><th>Created</th><th></th></tr></thead>
|
|
69
|
+
<tbody>${rows}</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
</div>
|
|
72
|
+
</div>`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface AccountDetailPageOpts {
|
|
77
|
+
userEmail: string;
|
|
78
|
+
account: AccountWithCounts;
|
|
79
|
+
agents: AccountAgentRef[];
|
|
80
|
+
members: AccountMember[];
|
|
81
|
+
invites: AccountInvite[];
|
|
82
|
+
error?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function postForm(
|
|
86
|
+
accountId: string,
|
|
87
|
+
action: string,
|
|
88
|
+
fields: Record<string, string>,
|
|
89
|
+
label: string,
|
|
90
|
+
danger = false,
|
|
91
|
+
): string {
|
|
92
|
+
const hidden = Object.entries(fields)
|
|
93
|
+
.map(
|
|
94
|
+
([k, v]) =>
|
|
95
|
+
`<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}" />`,
|
|
96
|
+
)
|
|
97
|
+
.join("");
|
|
98
|
+
return `<form method="POST" action="${ADMIN_ACCOUNTS_PATH}/${escapeHtml(accountId)}/${action}" style="display:inline;margin:0">${hidden}
|
|
99
|
+
<button type="submit" class="btn ${danger ? "btn-danger" : "btn-secondary"}" style="font-size:12px;padding:4px 10px">${label}</button>
|
|
100
|
+
</form>`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function renderAccountDetailPage(opts: AccountDetailPageOpts): string {
|
|
104
|
+
const { account } = opts;
|
|
105
|
+
const id = account.id;
|
|
106
|
+
const base = `${ADMIN_ACCOUNTS_PATH}/${escapeHtml(id)}`;
|
|
107
|
+
const errorHtml = opts.error
|
|
108
|
+
? `<div class="alert alert-error">${escapeHtml(opts.error)}</div>`
|
|
109
|
+
: "";
|
|
110
|
+
|
|
111
|
+
const statusAction =
|
|
112
|
+
account.status === "active"
|
|
113
|
+
? postForm(id, "suspend", {}, "Suspend", true)
|
|
114
|
+
: postForm(id, "reactivate", {}, "Reactivate");
|
|
115
|
+
|
|
116
|
+
const memberRows = opts.members
|
|
117
|
+
.map(
|
|
118
|
+
(m) => `<tr>
|
|
119
|
+
<td>${escapeHtml(m.email)}</td>
|
|
120
|
+
<td><span class="badge ${m.role === "owner" ? "badge-purple" : "badge-gray"}">${escapeHtml(m.role)}</span></td>
|
|
121
|
+
<td style="text-align:right">${[
|
|
122
|
+
m.role === "owner"
|
|
123
|
+
? postForm(id, "members/demote", { email: m.email }, "Demote")
|
|
124
|
+
: postForm(id, "members/promote", { email: m.email }, "Make owner"),
|
|
125
|
+
postForm(id, "members/remove", { email: m.email }, "Remove", true),
|
|
126
|
+
].join(" ")}</td>
|
|
127
|
+
</tr>`,
|
|
128
|
+
)
|
|
129
|
+
.join("\n");
|
|
130
|
+
|
|
131
|
+
const agentRows =
|
|
132
|
+
opts.agents.length === 0
|
|
133
|
+
? `<tr><td colspan="2" class="empty-state">No agents.</td></tr>`
|
|
134
|
+
: opts.agents
|
|
135
|
+
.map(
|
|
136
|
+
(a) =>
|
|
137
|
+
`<tr><td><a href="/admin/agents/${escapeHtml(a.id)}" class="agent-link">${escapeHtml(a.name)}</a></td><td style="text-align:right">${postForm(id, "agents/unassign", { agentId: a.id }, "Unassign", true)}</td></tr>`,
|
|
138
|
+
)
|
|
139
|
+
.join("\n");
|
|
140
|
+
|
|
141
|
+
const inviteRows =
|
|
142
|
+
opts.invites.length === 0
|
|
143
|
+
? `<tr><td colspan="2" class="empty-state">No pending invites.</td></tr>`
|
|
144
|
+
: opts.invites
|
|
145
|
+
.map(
|
|
146
|
+
(i) =>
|
|
147
|
+
`<tr><td>${escapeHtml(i.email)}</td><td>${escapeHtml(i.invitedBy)}</td></tr>`,
|
|
148
|
+
)
|
|
149
|
+
.join("\n");
|
|
150
|
+
|
|
151
|
+
return renderAdminPage({
|
|
152
|
+
title: `${account.name} — Accounts — Shipwright Admin`,
|
|
153
|
+
body: `${renderAdminToolbar(opts.userEmail, ADMIN_ACCOUNTS_PATH)}
|
|
154
|
+
<div class="vos-page">
|
|
155
|
+
<div class="page-header">
|
|
156
|
+
<h1 class="page-title">${escapeHtml(account.name)} ${statusBadge(account.status)}</h1>
|
|
157
|
+
${statusAction}
|
|
158
|
+
</div>
|
|
159
|
+
<p><a href="${ADMIN_ACCOUNTS_PATH}">← All accounts</a></p>
|
|
160
|
+
${errorHtml}
|
|
161
|
+
<div class="card">
|
|
162
|
+
<div class="card-title">Settings</div>
|
|
163
|
+
<p>Agents: <strong>${account.agentCount} / ${account.maxAgents}</strong></p>
|
|
164
|
+
<form method="POST" action="${base}/update" style="display:grid;gap:8px;max-width:360px;margin-top:12px">
|
|
165
|
+
<label>Name <input name="name" class="form-input" maxlength="100" required value="${escapeHtml(account.name)}" /></label>
|
|
166
|
+
<label>Max agents <input name="maxAgents" type="number" min="0" step="1" class="form-input" required value="${account.maxAgents}" /></label>
|
|
167
|
+
<label>Plan <input name="plan" class="form-input" value="${escapeHtml(account.plan ?? "")}" /></label>
|
|
168
|
+
<label>Trial expires <input name="trialExpiresAt" type="date" class="form-input" value="${escapeHtml(dateOnly(account.trialExpiresAt))}" /></label>
|
|
169
|
+
<div><button type="submit" class="btn btn-primary">Save</button></div>
|
|
170
|
+
</form>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="card">
|
|
173
|
+
<div class="card-title">Agents</div>
|
|
174
|
+
<table class="data-table"><tbody>${agentRows}</tbody></table>
|
|
175
|
+
<form method="POST" action="${base}/agents/assign" style="display:flex;gap:8px;margin-top:12px">
|
|
176
|
+
<input name="agentId" class="form-input" required placeholder="Agent ID" style="max-width:280px" />
|
|
177
|
+
<button type="submit" class="btn btn-primary">Assign agent</button>
|
|
178
|
+
</form>
|
|
179
|
+
<p class="help-text" style="font-size:12px;margin-top:8px">Assigning or unassigning an agent updates its scope immediately and re-applies its tenant pod settings. Historical task-store rows (tasks, PRs) are not moved to the new account.</p>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="card">
|
|
182
|
+
<div class="card-title">Members</div>
|
|
183
|
+
<table class="data-table">
|
|
184
|
+
<thead><tr><th>Email</th><th>Role</th><th></th></tr></thead>
|
|
185
|
+
<tbody>${memberRows}</tbody>
|
|
186
|
+
</table>
|
|
187
|
+
<form method="POST" action="${base}/members/add" style="display:flex;gap:8px;margin-top:12px">
|
|
188
|
+
<input name="email" type="email" class="form-input" required placeholder="user@example.com" style="max-width:280px" />
|
|
189
|
+
<select name="role" class="form-input" style="max-width:120px"><option value="member">member</option><option value="owner">owner</option></select>
|
|
190
|
+
<button type="submit" class="btn btn-primary">Add</button>
|
|
191
|
+
</form>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="card">
|
|
194
|
+
<div class="card-title">Pending invites</div>
|
|
195
|
+
<table class="data-table">
|
|
196
|
+
<thead><tr><th>Email</th><th>Invited by</th></tr></thead>
|
|
197
|
+
<tbody>${inviteRows}</tbody>
|
|
198
|
+
</table>
|
|
199
|
+
</div>
|
|
200
|
+
</div>`,
|
|
201
|
+
});
|
|
202
|
+
}
|