refactor(dashboard): extract Vue app and integrate production builds

This commit is contained in:
SToneX
2026-09-22 17:05:52 +08:00
parent eb1974481f
commit 698898cf14
120 changed files with 9239 additions and 6660 deletions
+4 -1
View File
@@ -11,8 +11,9 @@ settings, and gives you a sandboxed CLI that never touches `~/.treg/config.json`
| Service | Command (managed by the script) | Port |
|---|---|---|
| treg server | `uv run python -m treg --reload` + `TREG_EMAIL_DEV_MODE=true`, own sqlite `treg-dev.db` | 18790 |
| Dashboard Vite | `npm run dev` in `frontend/`, hot updates loaded by the Python entry | 5173 |
No infra deps (sqlite). Prerequisites: `tmux`, `uv` (the script runs `uv sync` if `.venv` is missing).
No infra deps (sqlite). Prerequisites: Node 22.12+, npm, `tmux`, `uv` (the script runs `uv sync` if `.venv` is missing).
## Subcommands
@@ -32,6 +33,8 @@ repo's CLI against localhost with `HOME=scripts/.dev-home`, so the real
`~/.treg/config.json` (usually pointing at production) is never overwritten.
Email OTP dev mode is on — codes appear in the login page / API response, no mail sender needed.
`TREG_DEV_DB=/absolute/path/to/dev.db` selects a separate database without resetting the default.
## Troubleshooting
- **Port 18790 in use, no session** → something else owns it: `lsof -i :18790`.
+87 -8
View File
@@ -23,18 +23,98 @@ Regenerate via `scripts/build-map.py`.
| `dsh/index.js` | interface/skill.md |
| `examples/proxy-demo/server.js` | architecture/local-proxy.md |
| `external:meetings/2026-06-30-jason-tools-registry.md` | foundation/charter.md, reference/glossary.md |
| `frontend/index.html` | interface/dashboard.md, interface/seo.md |
| `frontend/package.json` | interface/dashboard.md |
| `frontend/src/App.vue` | interface/dashboard.md, interface/seo.md |
| `frontend/src/api.ts` | interface/dashboard.md |
| `frontend/src/components/DashboardNavigation.vue` | interface/dashboard.md |
| `frontend/src/components/PublicNavigation.vue` | interface/dashboard.md, interface/seo.md |
| `frontend/src/components/SignInDialog.vue` | interface/dashboard.md |
| `frontend/src/components/SignedOutPage.vue` | interface/dashboard.md, interface/landing-sandbox.md |
| `frontend/src/dialogs/AcceptInvitesDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/AgentGuideDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/CallDetailsDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/ConnectTokenDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/ConnectionMethodDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/CopyToolDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/EditToolDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/ExtraCredentialDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/ImportSkillDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/RecipeDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/RequestToolDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/ResourcePickerDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/RunToolDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/ShareDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/TopUpDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/TryEndpointDialog.vue` | interface/dashboard.md |
| `frontend/src/dialogs/WelcomeDialog.vue` | interface/dashboard.md, interface/onboarding.md |
| `frontend/src/main.ts` | interface/dashboard.md |
| `frontend/src/pages/ActivityPage.vue` | interface/dashboard.md |
| `frontend/src/pages/AdminPage.vue` | interface/dashboard.md |
| `frontend/src/pages/CatalogPage.vue` | interface/dashboard.md |
| `frontend/src/pages/DetailPage.vue` | interface/dashboard.md |
| `frontend/src/pages/GettingStartedPage.vue` | interface/dashboard.md, interface/onboarding.md |
| `frontend/src/pages/HelpPage.vue` | interface/dashboard.md |
| `frontend/src/pages/PlatformPage.vue` | interface/dashboard.md |
| `frontend/src/pages/ProviderPage.vue` | architecture/instagram-oauth.md, interface/dashboard.md |
| `frontend/src/pages/ReferralsPage.vue` | interface/dashboard.md |
| `frontend/src/pages/SecretsPage.vue` | interface/dashboard.md |
| `frontend/src/pages/TeamPage.vue` | architecture/auth-secrets.md, interface/dashboard.md |
| `frontend/src/pages/ToolsPage.vue` | interface/dashboard.md |
| `frontend/src/state/activity.js` | interface/dashboard.md |
| `frontend/src/state/admin.js` | interface/dashboard.md |
| `frontend/src/state/agents.js` | interface/dashboard.md |
| `frontend/src/state/agentsComputed.js` | interface/dashboard.md |
| `frontend/src/state/analytics.js` | interface/dashboard.md |
| `frontend/src/state/billing.js` | interface/dashboard.md |
| `frontend/src/state/billingComputed.js` | interface/dashboard.md |
| `frontend/src/state/boot.js` | interface/dashboard.md, interface/landing-sandbox.md, interface/seo.md |
| `frontend/src/state/catalog.js` | interface/dashboard.md |
| `frontend/src/state/catalogComputed.js` | interface/dashboard.md |
| `frontend/src/state/connections.js` | architecture/instagram-oauth.md, interface/dashboard.md |
| `frontend/src/state/constants.js` | interface/dashboard.md |
| `frontend/src/state/context.ts` | interface/dashboard.md |
| `frontend/src/state/controller.js` | interface/dashboard.md |
| `frontend/src/state/data.js` | interface/dashboard.md |
| `frontend/src/state/details.js` | interface/dashboard.md |
| `frontend/src/state/detailsComputed.js` | interface/dashboard.md |
| `frontend/src/state/format.js` | interface/dashboard.md |
| `frontend/src/state/governance.js` | interface/dashboard.md |
| `frontend/src/state/help.js` | interface/dashboard.md |
| `frontend/src/state/keys.js` | architecture/auth-secrets.md, interface/dashboard.md |
| `frontend/src/state/lifecycle.js` | interface/dashboard.md |
| `frontend/src/state/navigation.js` | interface/dashboard.md |
| `frontend/src/state/onboarding.js` | interface/dashboard.md, interface/onboarding.md |
| `frontend/src/state/onboardingComputed.js` | interface/dashboard.md |
| `frontend/src/state/projects.js` | interface/dashboard.md |
| `frontend/src/state/referrals.js` | interface/dashboard.md |
| `frontend/src/state/secrets.js` | interface/dashboard.md |
| `frontend/src/state/session.js` | architecture/auth-secrets.md, interface/dashboard.md |
| `frontend/src/state/sessionComputed.js` | interface/dashboard.md |
| `frontend/src/state/sharing.js` | interface/dashboard.md |
| `frontend/src/state/skills.js` | interface/dashboard.md |
| `frontend/src/state/snippets.js` | interface/dashboard.md |
| `frontend/src/state/team.js` | interface/dashboard.md |
| `frontend/src/state/tools.js` | interface/dashboard.md |
| `frontend/src/state/tryTool.js` | interface/dashboard.md |
| `frontend/src/styles/base.css` | interface/dashboard.md |
| `frontend/vite.config.ts` | interface/dashboard.md |
| `hatch_build.py` | ops/deploy.md |
| `package.json` | interface/skill.md |
| `plugin/.codex-plugin/plugin.json` | interface/skill.md |
| `plugins/minimax/.minimax-plugin/plugin.json` | interface/skill.md |
| `plugins/treg/.cursor-plugin/plugin.json` | interface/skill.md |
| `pyproject.toml` | architecture/import-boundaries.md, ops/deploy.md |
| `scripts/backfill_call_archive_links.py` | architecture/archive.md |
| `scripts/build-dashboard.sh` | ops/deploy.md |
| `scripts/build-web.sh` | ops/deploy.md |
| `scripts/build_plugin.py` | interface/skill.md |
| `scripts/catalog_drift.py` | architecture/catalog.md |
| `scripts/catalog_ingest.py` | architecture/catalog.md, architecture/instagram-oauth.md |
| `scripts/catalog_validate.py` | architecture/catalog.md |
| `scripts/dev-local.sh` | ops/deploy.md |
| `scripts/dump_surface.py` | architecture/composition.md |
| `scripts/frontend-e2e-server.sh` | ops/deploy.md |
| `scripts/import_arena_verification.py` | interface/enrich-arena.md |
| `scripts/indexnow_submit.py` | interface/seo.md |
| `scripts/minimax_plugin.py` | interface/skill.md |
@@ -308,7 +388,6 @@ Regenerate via `scripts/build-map.py`.
| `src/treg/web/feedback.md` | architecture/feedback.md |
| `src/treg/web/grokbot.html` | interface/seo.md |
| `src/treg/web/gtag.js` | architecture/ads-conversions.md |
| `src/treg/web/index.html` | architecture/auth-secrets.md, architecture/instagram-oauth.md, interface/dashboard.md, interface/landing-sandbox.md, interface/onboarding.md, interface/seo.md |
| `src/treg/web/install.sh` | interface/landing-sandbox.md |
| `src/treg/web/jev.html` | interface/seo.md |
| `src/treg/web/landing.html` | interface/seo.md |
@@ -423,13 +502,13 @@ Regenerate via `scripts/build-map.py`.
|---|---|
| `architecture/ads-conversions.md` | `adsconv.py`, `signup.py`, `adtrack.js`, `gtag.js` |
| `architecture/archive.md` | `archive.py`, `hunter.yaml`, `results.py`, `0031_archive_result_admission.py`, `test_cache_result_admission.py`, `archive_bodies.py`, `config.py`, `object_store.py`, `0032_archive_body_storage.py`, `test_archive_r2.py`, `fake_object_store.py`, `smoke_archive_r2.py`, `0002_archive_tables.py`, `0003_callrecord_cached.py`, `0004_archivekey_request_shape.py`, `0011_callrecord_archive_link.py`, `service.py`, `settle.py`, `0039_archive_own_key_and_repeat_pricing.py`, `backfill_call_archive_links.py`, `api.py`, `bootstrap.py`, `admin.py`, `asynctasks.py` |
| `architecture/auth-secrets.md` | `injectors.py`, `ssrf.py`, `crypto.py`, `oauth.py`, `__init__.py`, `authorization.py`, `oauth_flow.py`, `refresh.py`, `oauth_exchange.py`, `oauth_refresh.py`, `oauth_providers.py`, `index.html`, `health.py`, `connect.py`, `connections.py`, `resources.py`, `__init__.py`, `bindings.py`, `bundles.py`, `api_keys.py`, `access.py`, `api_keys.py`, `test_api_keys.py`, `test_oauth_refresh.py`, `test_financialdatasets.py`, `test_key_providers.py`, `test_dashboard_markup.py`, `config.py` |
| `architecture/auth-secrets.md` | `injectors.py`, `ssrf.py`, `crypto.py`, `oauth.py`, `__init__.py`, `authorization.py`, `oauth_flow.py`, `refresh.py`, `oauth_exchange.py`, `oauth_refresh.py`, `oauth_providers.py`, `session.js`, `keys.js`, `TeamPage.vue`, `health.py`, `connect.py`, `connections.py`, `resources.py`, `__init__.py`, `bindings.py`, `bundles.py`, `api_keys.py`, `access.py`, `api_keys.py`, `test_api_keys.py`, `test_oauth_refresh.py`, `test_financialdatasets.py`, `test_key_providers.py`, `test_dashboard_markup.py`, `config.py` |
| `architecture/catalog.md` | `tavily.yaml`, `exa.yaml`, `anyapi.extended.yaml`, `trestleiq.yaml`, `financialdatasets.yaml`, `test_financialdatasets.py`, `quickenrich.yaml`, `influencersclub.yaml`, `quickenrich.extended.yaml`, `trykitt.yaml`, `contracts.yaml`, `millionverifier.yaml`, `adapters.yaml`, `prospeo.yaml`, `test_route_cost_ceiling.py`, `tomba.yaml`, `__init__.py`, `contracts.py`, `paths.py`, `plan.py`, `synthetic.py`, `route.py`, `test_routing.py`, `catalog-drift.yml`, `catalog_drift.py`, `catalog_ingest.py`, `catalog_validate.py`, `aliases.yaml`, `fx.yaml`, `cloro.yaml`, `aviato.yaml`, `crustdata.yaml`, `google-search-console.yaml`, `google-search-console.extended.yaml`, `google-tag-manager.yaml`, `google-tag-manager.extended.yaml`, `instagram.yaml`, `instagram.extended.yaml`, `justoneapi.extended.yaml`, `minimax.yaml`, `apify.yaml`, `brightdata.yaml`, `companyenrich.yaml`, `oceanio.yaml`, `akta.extended.yaml`, `dataforseo.yaml`, `dataforseo.extended.yaml`, `test_dataforseo_constraints.py`, `scrapecreators.yaml`, `scrapecreators.extended.yaml`, `serpapi.yaml`, `serpapi.extended.yaml`, `diffbot.yaml`, `diffbot.extended.yaml`, `tikhub.extended.yaml`, `lusha.extended.yaml`, `openrouter.yaml`, `openrouter.extended.yaml`, `replicate.yaml`, `replicate.extended.yaml`, `reapi.yaml`, `piapi.yaml`, `__init__.py`, `store.py`, `hunter.yaml`, `mcp.py`, `settlement.py`, `stats.py`, `catalog_observations.py`, `catalog_stats.py`, `0038_endpoint_day_stats.py`, `catalog.py`, `test_aigc_pr_b.py`, `test_catalog_api.py`, `test_catalog_validate.py` |
| `architecture/composition.md` | `bootstrap.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `call_surface.py`, `connect.py`, `mcp_oauth.py`, `session.py`, `admin.py`, `auth.py`, `billing.py`, `call.py`, `connections.py`, `onboard.py`, `orgs.py`, `resources.py`, `referrals.py`, `web.py`, `dump_surface.py`, `test_app_roles.py` |
| `architecture/data-model.md` | `alembic.ini`, `env.py`, `0001_baseline_current_schema.py`, `0002_archive_tables.py`, `0003_callrecord_cached.py`, `0004_archivekey_request_shape.py`, `0005_capacity_policy_snapshot.py`, `0006_overflow_route.py`, `0007_overflow_spend.py`, `0008_org_platform_overflow_disabled.py`, `0009_callrecord_hit.py`, `0017_async_task_record.py`, `0018_async_resource_ownership.py`, `0019_async_poll_failures.py`, `0020_callrecord_created_at_indexes.py`, `0021_ledgerentry_org_created_at_index.py`, `0022_org_spent_today_counter.py`, `0023_callrecord_org_user_created_at_index.py`, `0024_membership_calls_today_counter.py`, `0027_enrich_arena.py`, `0028_arena_insights.py`, `0029_arena_verification_snapshot.py`, `0011_callrecord_archive_link.py`, `0015_idempotentcall_membership_cascade.py`, `0034_managed_api_keys.py`, `0035_default_key_generation.py`, `0036_activity_key_indexes.py`, `0038_endpoint_day_stats.py`, `maintenance.py`, `sitetrack.js`, `models.py`, `0031_archive_result_admission.py`, `0032_archive_body_storage.py`, `0039_archive_own_key_and_repeat_pricing.py`, `0033_signup_promo_eligibility.py`, `0041_searchlog.py`, `timeutil.py`, `db.py`, `referrals.py`, `audit.py`, `analytics.py`, `bootstrap_handlers.py`, `ratestore.py`, `auth.py`, `test_postgres_reset.py`, `test_alembic_expand_safety.py`, `test_api_keys.py` |
| `architecture/feedback.md` | `feedback_contract.py`, `__init__.py`, `reports.py`, `reviews.py`, `hints.py`, `config.py`, `call.py`, `invite.py`, `kv.py`, `feedback.py`, `feedback.py`, `0025_feedback.py`, `0026_callreview.py`, `0030_feedback_handling.py`, `test_feedback_handling_schema.py`, `feedback.md`, `test_feedback.py`, `test_reviews.py`, `test_hints.py`, `test_kv.py` |
| `architecture/import-boundaries.md` | `pyproject.toml`, `ci.yml`, `__init__.py`, `__init__.py`, `access.py`, `authorize.py`, `idempotency.py`, `overflow.py`, `route.py`, `__init__.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `client_identity.py`, `__init__.py`, `__init__.py`, `access.py`, `budgets.py`, `publicdemo.py`, `teams.py`, `usage.py`, `__init__.py`, `__init__.py`, `authorization.py`, `oauth_flow.py`, `refresh.py`, `__init__.py`, `__init__.py`, `__init__.py`, `__init__.py`, `__init__.py`, `injectors.py`, `relay.py`, `__init__.py`, `limiter.py`, `test_call_architecture.py`, `test_import_lightness.py` |
| `architecture/instagram-oauth.md` | `catalog_ingest.py`, `access.py`, `resolve.py`, `service.py`, `instagram.yaml`, `instagram.extended.yaml`, `cli.py`, `store.py`, `authorization.py`, `oauth_flow.py`, `oauth_exchange.py`, `mcp.py`, `call.py`, `index.html`, `0010_oauth_authorization_method.py`, `test_instagram_oauth_architecture.py` |
| `architecture/instagram-oauth.md` | `catalog_ingest.py`, `access.py`, `resolve.py`, `service.py`, `instagram.yaml`, `instagram.extended.yaml`, `cli.py`, `store.py`, `authorization.py`, `oauth_flow.py`, `oauth_exchange.py`, `mcp.py`, `call.py`, `connections.js`, `ProviderPage.vue`, `0010_oauth_authorization_method.py`, `test_instagram_oauth_architecture.py` |
| `architecture/local-proxy.md` | `localproxy.py`, `server.js` |
| `architecture/local-run.md` | `localrun.py`, `egress.py`, `fsjail.py` |
| `architecture/mcp-oauth.md` | `auth.py`, `mcp.py`, `health.py`, `mcp_oauth.py`, `session.py`, `access.py`, `api_keys.py`, `api_keys.py`, `auth.py`, `claude-connector.html`, `connect-demo.html`, `CLAUDE-CONNECTOR-SUBMISSION.md`, `test_mcp.py`, `test_mcp_oauth.py`, `test_mcp_directory.py`, `test_marketplace_call.py` |
@@ -444,14 +523,14 @@ Regenerate via `scripts/build-map.py`.
| `interface/api.md` | `media.py`, `sitetrack.js`, `api.py`, `bootstrap_handlers.py`, `bootstrap_http.py`, `call_surface.py`, `caller_metadata.py`, `client_identity.py`, `auth.py`, `access.py`, `authorize.py`, `idempotency.py`, `intake.py`, `resolve.py`, `reserve.py`, `settle.py`, `evidence.py`, `service.py`, `types.py`, `relay.py`, `connect.py`, `onboard.py`, `referrals.py`, `signup.py`, `__init__.py`, `admin.py`, `auth.py`, `auth_helpers.py`, `billing.py`, `call.py`, `catalog.py`, `connections.py`, `onboard.py`, `orgs.py`, `api_keys.py`, `resources.py`, `referrals.py`, `signup_cookies.py`, `web.py`, `access.py`, `api_keys.py`, `teams.py`, `access.py`, `budgets.py`, `publicdemo.py`, `usage.py`, `mcp_oauth.py`, `session.py`, `timeutil.py`, `store.py`, `email.py`, `runner.py`, `ratestore.py` |
| `interface/catalog-review-proposal.md` | `store.py`, `capabilities.yaml` |
| `interface/cli.md` | `cli.py`, `test_released_cli_compat.py`, `test_cli_key_compatibility.py`, `auth_helpers.py`, `cli_analytics.py`, `convert.py`, `agents.py`, `api_keys.py`, `test_api_keys.py` |
| `interface/dashboard.md` | `sitetrack.js`, `index.html`, `agent-setup.js`, `dashboard.css`, `SOURCES.md`, `README.md`, `vue-3.5.41.global.prod.js`, `tutorial.js`, `tutorial.html`, `tour.js`, `index.html`, `api.py`, `web.py`, `session.py`, `api_keys.py`, `test_api_keys.py` |
| `interface/dashboard.md` | `sitetrack.js`, `index.html`, `package.json`, `vite.config.ts`, `App.vue`, `api.ts`, `DashboardNavigation.vue`, `PublicNavigation.vue`, `SignInDialog.vue`, `SignedOutPage.vue`, `AcceptInvitesDialog.vue`, `AgentGuideDialog.vue`, `CallDetailsDialog.vue`, `ConnectTokenDialog.vue`, `ConnectionMethodDialog.vue`, `CopyToolDialog.vue`, `EditToolDialog.vue`, `ExtraCredentialDialog.vue`, `ImportSkillDialog.vue`, `RecipeDialog.vue`, `RequestToolDialog.vue`, `ResourcePickerDialog.vue`, `RunToolDialog.vue`, `ShareDialog.vue`, `TopUpDialog.vue`, `TryEndpointDialog.vue`, `WelcomeDialog.vue`, `main.ts`, `ActivityPage.vue`, `AdminPage.vue`, `CatalogPage.vue`, `DetailPage.vue`, `GettingStartedPage.vue`, `HelpPage.vue`, `PlatformPage.vue`, `ProviderPage.vue`, `ReferralsPage.vue`, `SecretsPage.vue`, `TeamPage.vue`, `ToolsPage.vue`, `activity.js`, `admin.js`, `agents.js`, `agentsComputed.js`, `analytics.js`, `billing.js`, `billingComputed.js`, `boot.js`, `catalog.js`, `catalogComputed.js`, `connections.js`, `constants.js`, `context.ts`, `controller.js`, `data.js`, `details.js`, `detailsComputed.js`, `format.js`, `governance.js`, `help.js`, `keys.js`, `lifecycle.js`, `navigation.js`, `onboarding.js`, `onboardingComputed.js`, `projects.js`, `referrals.js`, `secrets.js`, `session.js`, `sessionComputed.js`, `sharing.js`, `skills.js`, `snippets.js`, `team.js`, `tools.js`, `tryTool.js`, `base.css`, `agent-setup.js`, `dashboard.css`, `SOURCES.md`, `README.md`, `vue-3.5.41.global.prod.js`, `tutorial.js`, `tutorial.html`, `tour.js`, `index.html`, `api.py`, `web.py`, `session.py`, `api_keys.py`, `test_api_keys.py` |
| `interface/enrich-arena.md` | `arena.py`, `arena.py`, `arena.py`, `models.py`, `0027_enrich_arena.py`, `teams.py`, `auth.py`, `bootstrap.py`, `enrich-arena.html`, `arena.js`, `bench.js`, `arena-bench.test.cjs`, `arena-template.test.cjs`, `arena.css`, `agent-setup.js`, `arena_verification_insights.py`, `0029_arena_verification_snapshot.py`, `import_arena_verification.py`, `test_arena_verification_insights.py`, `arena_insights.py`, `arena_insights.py`, `0028_arena_insights.py`, `test_arena_insights.py`, `apollo.svg`, `branddev.svg`, `companyenrich.svg`, `findymail.svg`, `hunter.svg`, `icypeas.svg`, `leadmagic.svg`, `leadsforge.svg`, `lusha.svg`, `pdl.svg`, `predictleads.svg`, `thecompaniesapi.svg`, `tomba.svg`, `sitetrack.js`, `test_enrich_arena.py`, `enrich-arena.test.cjs` |
| `interface/env-import.md` | `providers.py`, `skills.py` |
| `interface/landing-sandbox.md` | `sandbox.py`, `sandbox_identity.py`, `pubfeed.py`, `sandbox.py`, `__init__.py`, `sandbox.py`, `api.py`, `onboard.py`, `web.py`, `index.html`, `install.sh` |
| `interface/onboarding.md` | `auth.py`, `__init__.py`, `demo.py`, `cli.py`, `auth.py`, `onboard.py`, `index.html` |
| `interface/seo.md` | `api.py`, `web.py`, `agent_pages.py`, `robots.txt`, `catalog.css`, `usecase.css`, `index.html`, `landing.html`, `terms.html`, `usecase-seo.html`, `usecase-company.html`, `usecase-enrichment.html`, `usecase-social.html`, `usecase-ads.html`, `people-search.html`, `grokbot.html`, `fable-gtm.html`, `ugc.html`, `jev.html`, `jev_xboost.py`, `xboost-seed.json`, `triage.json`, `signals.json`, `astra.html`, `page.css`, `page.js`, `llms.txt`, `indexnow_submit.py`, `support.html`, `og-card.html` |
| `interface/landing-sandbox.md` | `sandbox.py`, `sandbox_identity.py`, `pubfeed.py`, `sandbox.py`, `__init__.py`, `sandbox.py`, `api.py`, `onboard.py`, `web.py`, `boot.js`, `SignedOutPage.vue`, `install.sh` |
| `interface/onboarding.md` | `auth.py`, `__init__.py`, `demo.py`, `cli.py`, `auth.py`, `onboard.py`, `onboarding.js`, `WelcomeDialog.vue`, `GettingStartedPage.vue` |
| `interface/seo.md` | `api.py`, `web.py`, `agent_pages.py`, `robots.txt`, `catalog.css`, `usecase.css`, `index.html`, `App.vue`, `PublicNavigation.vue`, `boot.js`, `landing.html`, `terms.html`, `usecase-seo.html`, `usecase-company.html`, `usecase-enrichment.html`, `usecase-social.html`, `usecase-ads.html`, `people-search.html`, `grokbot.html`, `fable-gtm.html`, `ugc.html`, `jev.html`, `jev_xboost.py`, `xboost-seed.json`, `triage.json`, `signals.json`, `astra.html`, `page.css`, `page.js`, `llms.txt`, `indexnow_submit.py`, `support.html`, `og-card.html` |
| `interface/shell.md` | `shell.py`, `cli.py` |
| `interface/skill.md` | `skill.md`, `SKILL.md`, `web.py`, `mcp_install.py`, `build_plugin.py`, `plugin.json`, `marketplace.json`, `plugin.json`, `plugin.json`, `package.json`, `cordis.patch.yml`, `index.js`, `plugin.json`, `minimax_plugin.py` |
| `ops/capacity.md` | `__init__.py`, `collectors.py`, `policy.py`, `sweep.py`, `view.py`, `routes.py`, `signatures.py`, `verify.py`, `marks.py`, `test_capacity_protect.py`, `limiter.py`, `overflow_spend.py`, `routes_view.py`, `overflow.py`, `0007_overflow_spend.py`, `test_capacity_overflow.py`, `test_capacity_overflow_spend.py`, `0008_org_platform_overflow_disabled.py`, `test_capacity_smoothing.py`, `overflow_seed.json`, `__init__.py`, `orthogonal.py`, `monid.py`, `catalogs.py`, `0006_overflow_route.py`, `test_capacity_overflow_routes.py`, `test_influencersclub_overflow.py`, `worker.py`, `provider_balances.py`, `0005_capacity_policy_snapshot.py`, `test_capacity_know.py`, `test_capacity_collectors.py`, `test_financialdatasets.py` |
| `ops/deploy.md` | `pyproject.toml`, `__main__.py`, `maintenance.py`, `env.py`, `0034_managed_api_keys.py`, `0035_default_key_generation.py`, `0036_activity_key_indexes.py`, `worker.py`, `selfhost.sh`, `config.py`, `db.py`, `email.py`, `audit.py`, `dev-local.sh`, `render.example.yaml` |
| `ops/deploy.md` | `pyproject.toml`, `hatch_build.py`, `build-dashboard.sh`, `build-web.sh`, `frontend-e2e-server.sh`, `__main__.py`, `maintenance.py`, `env.py`, `0034_managed_api_keys.py`, `0035_default_key_generation.py`, `0036_activity_key_indexes.py`, `worker.py`, `selfhost.sh`, `config.py`, `db.py`, `email.py`, `audit.py`, `dev-local.sh`, `render.example.yaml` |
| `reference/glossary.md` | `2026-06-30-jason-tools-registry.md` |
+51
View File
@@ -46,6 +46,15 @@ jobs:
if: steps.docs.outputs.only != 'true'
with:
python-version: "3.13"
- uses: actions/setup-node@v4
if: steps.docs.outputs.only != 'true'
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Build dashboard
if: steps.docs.outputs.only != 'true'
run: bash scripts/build-dashboard.sh
- name: Install dependencies
if: steps.docs.outputs.only != 'true'
run: uv sync --locked
@@ -85,6 +94,13 @@ jobs:
- uses: astral-sh/setup-uv@v7
with:
python-version: "3.13"
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Build dashboard
run: bash scripts/build-dashboard.sh
- name: Install dependencies
run: uv sync --locked
- name: Run Postgres tests serially
@@ -160,3 +176,38 @@ jobs:
# HEAD is the PR merge commit (both parents) or the pushed main commit. Scan all
# reachable history, including deleted secrets, without unrelated fetched branch tips.
run: gitleaks detect --source . --config .gitleaks.toml --log-opts="HEAD" --verbose --redact
frontend:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v7
with:
python-version: "3.13"
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: uv sync --locked
- run: bash scripts/build-dashboard.sh
- run: npm --prefix frontend test
- name: Install browser
working-directory: frontend
run: npx playwright install --with-deps chromium
- run: npm --prefix frontend run test:e2e
- name: Check distributable assets
run: |
uv build
python - <<'PYCODE'
from pathlib import Path
from zipfile import ZipFile
import re
with ZipFile(next(Path('dist').glob('*.whl'))) as wheel:
html = wheel.read('treg/web/dashboard/index.html').decode()
assets = re.findall(r'/app/ui/(assets/[^"\s]+)', html)
assert assets, 'Dashboard entry must reference compiled assets'
for asset in assets:
assert wheel.read('treg/web/dashboard/' + asset)
PYCODE
+6
View File
@@ -88,3 +88,9 @@ tests/screenshots/
# Arena production statistics belong in the database, never a bundled snapshot.
/src/treg/web/enrich-arena/insights.json
# Frontend dependencies, build output and browser test artifacts.
node_modules/
/src/treg/web/dashboard/
/frontend/test-results/
/frontend/playwright-report/
+5 -2
View File
@@ -152,8 +152,11 @@ xdist is pulled via `--with`, not the lockfile — same as CI. The Postgres CI j
`[server]` extra, the certificate authority is `[proxy]`. Never import a heavy dependency at the
top of a CLI-path module; the "Lightweight CLI modules" import-linter contract lists them and
fails the build.
- **The dashboard** (`src/treg/web/index.html`) is a single-file Vue app with no build step, so a
broken view name fails silently. Verify in a browser.
- **The dashboard** lives in `frontend/` (Vue components, TypeScript entry/transport, Vite).
Build with `bash scripts/build-dashboard.sh`; generated assets in `src/treg/web/dashboard/`
ship with Python. Run `npm --prefix frontend test` and `npm --prefix frontend run test:e2e`.
Existing Options API use cases live in `frontend/src/state/`; preserve their session and
navigation behavior when narrowing component state. Never put dashboard logic back into HTML.
- **Schema.** Alembic owns it (`src/treg/alembic/versions/`); every schema change is a revision.
Startup only verifies the revision and refuses to boot when behind; migrations run only via
`python -m treg upgrade`.
+13
View File
@@ -7,6 +7,7 @@ Thanks for your interest!
```bash
git clone https://github.com/superdesigndev/treg
cd treg
bash scripts/build-dashboard.sh # Node 22.12+ and npm; compile the browser app
uv sync # install deps (uv >= 0.12, pinned in pyproject - https://docs.astral.sh/uv/)
uv run --with pytest-xdist pytest -n auto -q # daily local default (same shape as CI)
```
@@ -16,6 +17,18 @@ sqlite). The `TREG_*` knobs for persistence / a real deployment are documented i
**Configuration** section (and `docs/context/ops/deploy.md`).
A one-command local stack is in `scripts/dev-local.sh` (`up` / `logs` / `cli` / `reset`).
It starts Python on :18790 and Vite on :5173; open `http://localhost:18790/app`.
`TREG_DEV_DB=/absolute/path/to/dev.db` selects a separate local database without resetting another.
The Dashboard source is in `frontend/src/`: `.vue` pages, components and dialogs, feature use cases
in `state/`, and a typed JSON client in `api.ts`. `frontend/index.html` is only the document entry.
Run `npm --prefix frontend test` for transport tests and `npm --prefix frontend run test:e2e` for
browser flows against a disposable SQLite server. Install Chromium first with
`cd frontend && npx playwright install chromium`.
Before `uv build`, run `bash scripts/build-dashboard.sh`. The wheel and sdist include the resulting
assets; installing a published package needs no Node runtime. Editable Python installs do not
require a frontend build, so CLI and background-worker development remains independent.
## Project layout
+1
View File
@@ -292,6 +292,7 @@ scripts/dev-local.sh reset # wipe the dev DB + CLI sandbox for a fresh sta
Or run the server directly, without tmux:
```bash
bash scripts/build-dashboard.sh # Node 22.12+ and npm; build the Dashboard
uv sync # create the venv from uv.lock (pulls the server deps for dev)
uv run python -m treg upgrade # prepare schema + run idempotent release tasks without serving
uv run python -m treg # serve on 0.0.0.0:18790 (add --reload for dev)
+3 -1
View File
@@ -22,13 +22,15 @@ services:
# tested. A plain `pip install ".[server]"` resolves afresh on every build and picks up whatever
# a dependency published since; that is how a breaking upstream release reaches production
# without any commit. `--locked` fails the build when the lock is stale instead of resolving.
buildCommand: uv sync --locked --no-dev --extra server --active
buildCommand: bash scripts/build-web.sh
preDeployCommand: python -m treg upgrade
startCommand: python -m treg
healthCheckPath: /meta
envVars:
# Render adds uv to the Python runtime when uv.lock is present; its default is older than the
# `required-version` in pyproject.toml, so name one it accepts.
- key: NODE_VERSION
value: "22.22.0"
- key: UV_VERSION
value: "0.12.3"
- key: TREG_DATABASE_URL
+2 -2
View File
@@ -42,7 +42,7 @@ covers (frontmatter `sources:`). Regenerate this index with
| [The API — the only brain (FastAPI)](interface/api.md) | shipped | media.py, sitetrack.js, api.py, bootstrap_handlers.py, … |
| [Catalog browse review — categories, platform placement, and domain sections](interface/catalog-review-proposal.md) | reference | store.py, capabilities.yaml |
| [The CLI (treg) + skill scaffolding](interface/cli.md) | shipped | cli.py, test_released_cli_compat.py, test_cli_key_compatibility.py, auth_helpers.py, … |
| [The web dashboard (served from FastAPI)](interface/dashboard.md) | shipped | sitetrack.js, index.html, agent-setup.js, dashboard.css, … |
| [The web dashboard (served from FastAPI)](interface/dashboard.md) | shipped | sitetrack.js, index.html, package.json, vite.config.ts, … |
| [Enrich Arena — paid comparisons, one-click feedback, and visible waterfalls](interface/enrich-arena.md) | shipped | arena.py, arena.py, arena.py, models.py, … |
| [Import — scan a .env AND/OR a skills dir, auto-register as tools + bundles](interface/env-import.md) | in-progress | providers.py, skills.py |
| [Landing sandbox backend - front-end entry removed](interface/landing-sandbox.md) | shipped | sandbox.py, sandbox_identity.py, pubfeed.py, sandbox.py, … |
@@ -56,7 +56,7 @@ covers (frontmatter `sources:`). Regenerate this index with
| Fragment | Status | Covers |
|---|---|---|
| [Provider capacity — knowing what treg's own vendor accounts have left](ops/capacity.md) | shipped | __init__.py, collectors.py, policy.py, sweep.py, … |
| [Running & deploying the server](ops/deploy.md) | shipped | pyproject.toml, __main__.py, maintenance.py, env.py, … |
| [Running & deploying the server](ops/deploy.md) | shipped | pyproject.toml, hatch_build.py, build-dashboard.sh, build-web.sh, … |
## Reference
+3 -1
View File
@@ -13,7 +13,9 @@ sources:
- src/treg/infra/oauth_exchange.py
- src/treg/infra/oauth_refresh.py
- src/treg/oauth_providers.py
- src/treg/web/index.html
- frontend/src/state/session.js
- frontend/src/state/keys.js
- frontend/src/pages/TeamPage.vue
- src/treg/health.py
- src/treg/application/connect.py
- src/treg/routers/connections.py
+2 -1
View File
@@ -34,7 +34,8 @@ The standalone [Enrich Arena](../interface/enrich-arena.md) pages (`/enrich-aren
`/enrich-arena/leaderboard`) and `/arena/*` routes are control-role
surfaces. Paid interactive runs use the ordinary call application internally. Shutdown drains their
in-process owners before closing the shared upstream client.
The shared `/agent-setup.js` browser asset also belongs to the control role.
The shared `/agent-setup.js` browser asset and the compiled Dashboard assets at
`/app/ui/assets/{name}` also belong to the control role.
`bootstrap.create_app(role)` is the FastAPI composition root. `api.py` hosts the ordered route table,
attaches concern routers at compatibility-sensitive registration points, and calls the factory once at
+2 -1
View File
@@ -15,7 +15,8 @@ sources:
- src/treg/infra/oauth_exchange.py
- src/treg/mcp.py
- src/treg/routers/call.py
- src/treg/web/index.html
- frontend/src/state/connections.js
- frontend/src/pages/ProviderPage.vue
- src/treg/alembic/versions/0010_oauth_authorization_method.py
- tests/test_instagram_oauth_architecture.py
related:
+103 -26
View File
@@ -3,7 +3,82 @@ title: The web dashboard (served from FastAPI)
status: shipped
sources:
- src/treg/web/sitetrack.js
- src/treg/web/index.html
- frontend/index.html
- frontend/package.json
- frontend/vite.config.ts
- frontend/src/App.vue
- frontend/src/api.ts
- frontend/src/components/DashboardNavigation.vue
- frontend/src/components/PublicNavigation.vue
- frontend/src/components/SignInDialog.vue
- frontend/src/components/SignedOutPage.vue
- frontend/src/dialogs/AcceptInvitesDialog.vue
- frontend/src/dialogs/AgentGuideDialog.vue
- frontend/src/dialogs/CallDetailsDialog.vue
- frontend/src/dialogs/ConnectTokenDialog.vue
- frontend/src/dialogs/ConnectionMethodDialog.vue
- frontend/src/dialogs/CopyToolDialog.vue
- frontend/src/dialogs/EditToolDialog.vue
- frontend/src/dialogs/ExtraCredentialDialog.vue
- frontend/src/dialogs/ImportSkillDialog.vue
- frontend/src/dialogs/RecipeDialog.vue
- frontend/src/dialogs/RequestToolDialog.vue
- frontend/src/dialogs/ResourcePickerDialog.vue
- frontend/src/dialogs/RunToolDialog.vue
- frontend/src/dialogs/ShareDialog.vue
- frontend/src/dialogs/TopUpDialog.vue
- frontend/src/dialogs/TryEndpointDialog.vue
- frontend/src/dialogs/WelcomeDialog.vue
- frontend/src/main.ts
- frontend/src/pages/ActivityPage.vue
- frontend/src/pages/AdminPage.vue
- frontend/src/pages/CatalogPage.vue
- frontend/src/pages/DetailPage.vue
- frontend/src/pages/GettingStartedPage.vue
- frontend/src/pages/HelpPage.vue
- frontend/src/pages/PlatformPage.vue
- frontend/src/pages/ProviderPage.vue
- frontend/src/pages/ReferralsPage.vue
- frontend/src/pages/SecretsPage.vue
- frontend/src/pages/TeamPage.vue
- frontend/src/pages/ToolsPage.vue
- frontend/src/state/activity.js
- frontend/src/state/admin.js
- frontend/src/state/agents.js
- frontend/src/state/agentsComputed.js
- frontend/src/state/analytics.js
- frontend/src/state/billing.js
- frontend/src/state/billingComputed.js
- frontend/src/state/boot.js
- frontend/src/state/catalog.js
- frontend/src/state/catalogComputed.js
- frontend/src/state/connections.js
- frontend/src/state/constants.js
- frontend/src/state/context.ts
- frontend/src/state/controller.js
- frontend/src/state/data.js
- frontend/src/state/details.js
- frontend/src/state/detailsComputed.js
- frontend/src/state/format.js
- frontend/src/state/governance.js
- frontend/src/state/help.js
- frontend/src/state/keys.js
- frontend/src/state/lifecycle.js
- frontend/src/state/navigation.js
- frontend/src/state/onboarding.js
- frontend/src/state/onboardingComputed.js
- frontend/src/state/projects.js
- frontend/src/state/referrals.js
- frontend/src/state/secrets.js
- frontend/src/state/session.js
- frontend/src/state/sessionComputed.js
- frontend/src/state/sharing.js
- frontend/src/state/skills.js
- frontend/src/state/snippets.js
- frontend/src/state/team.js
- frontend/src/state/tools.js
- frontend/src/state/tryTool.js
- frontend/src/styles/base.css
- src/treg/web/agent-setup.js
- src/treg/web/media/redesign/dashboard.css
- src/treg/web/media/redesign/SOURCES.md
@@ -108,34 +183,36 @@ generated tool name in the second, then health/capabilities and actions. Method-
discovery is unchanged. A direct identity miss shows `setup required`; it does not show the
connection as working and no direct tool exists.
A single-file Vue 3 dashboard in `src/treg/web/index.html`, served **same-origin** by the API
(`GET /app` → `FileResponse`, `dashboard()` in `routers.web`, via `_WEB_DIR`). Same origin = no CORS and it
ships with the server (Render/Fly). The authenticated redesign follows the root `design.md`; older Ledger references in
`docs/style-board.html` / `docs/DASHBOARD-PLAN.md` describe the previous visual system.
The Vue 3 Dashboard is authored in `frontend/` and compiled with Vite. `frontend/index.html`
contains only the document entry; templates live in `.vue` pages, dialogs and shared navigation.
`state/` separates the existing Options API use cases by feature. `context.ts` keeps their
per-application state available to extracted components during this incremental migration; it is
not a singleton, and this boundary is not yet a fully typed domain store. The TypeScript entry,
JSON transport and development configuration are checked with `vue-tsc` before every build.
History navigation retains existing hashes, catalog URLs and shared links in `state/navigation.js`,
`state/catalog.js`, `state/details.js` and `state/boot.js`.
### Vue is vendored, not fetched from a CDN
There is no bundler, so Vue arrives as a plain `<script src>` — but from **`/vendor/`**, served off
`src/treg/web/vendor/` by an `_ImmutableStatic` mount in `bootstrap.py`, never from unpkg. It used to come
from `unpkg.com/vue@3`, and a visitor whose network could not reach unpkg got a **blank signed-in
dashboard with no error** ([#137](https://github.com/superdesigndev/treg/issues/137): mainland-China
`ERR_CONNECTION_CLOSED`, then `Vue is not defined`). The landing has no external scripts at all, so
the symptom read as "sign-in broke the site" when it was only "the dashboard needs one more origin".
`GET /app` serves the compiled document same-origin from the Python package, preserving local
sign-in and parked OAuth authorization. Catalog and shared-link handlers modify that same document's
metadata as before. `_app_version()` hashes the built entry, whose asset filenames change with
bundle content. HTML is revalidated; `/app/ui/assets/{name}` serves immutable hashed assets and
returns 404 for missing files. Assets remain a control-role surface.
Two rules follow, and both are load-bearing:
`bash scripts/build-dashboard.sh` installs the npm lockfile and builds into the gitignored
`src/treg/web/dashboard/` directory. Hatch includes it in distributions and rejects missing builds;
Node is not needed when installing a published wheel. `scripts/dev-local.sh up` starts both Python
and Vite, using a local-only development entry for hot updates. See `CONTRIBUTING.md`.
- **Pin the version in the filename** (`vue-3.5.41.global.prod.js`) and verify new bytes against a
second CDN before committing them — see `src/treg/web/vendor/README.md`. A floating `vue@3` tag is
arbitrary future code running in an authenticated session; that is why it is gone.
- **Nothing in the dashboard's critical path may be third-party.** Still CDN-hosted and *not*
critical: the `@lobehub` agent icons (`agentIcon`/`agentIconInv`) and Google Fonts — those degrade
to broken images and system fonts rather than a blank page.
### Browser dependencies
A **loader guard** sits right after the script tag. `[v-cloak]{display:none}` hides the un-compiled
template until Vue mounts, which is precisely what made #137 silent — so the guard checks whether
`#app` is still cloaked ~1.5s after `load` and, if it is, replaces the blank with a readable message,
a reload button, and the issues link. Anything that stops Vue mounting now says so on screen.
Vue is pinned in the npm lockfile and bundled from the same origin, so a blocked CDN cannot
prevent startup. The shared onboarding widgets in `/agent-setup.js` still serve both Dashboard and
Arena; their templates use Vue's bundled compiler. The existing vendored global Vue remains for
standalone pages. Agent icons and Google Fonts remain optional external presentation assets.
The unmounted entry displays a loading message and a reload link rather than hiding a raw template.
The authenticated redesign follows the root `design.md`.
`index.html`'s closing `<script src="/sitetrack.js">` (also on `landing.html`, every `usecase-*.html`,
`frontend/index.html`'s `<script src="/sitetrack.js">` (also on `landing.html`, every `usecase-*.html`,
`resources.html`, `tutorial.html`) sets the first-touch `treg_utm` cookie and initialises PostHog with
pageviews on; `initAnalytics()` in the SPA defers to it (`window.__phInit`) and only identifies, keeping
its inline init as the fallback for a stale bundle. Landing-page visitors used to be invisible to
@@ -923,7 +1000,7 @@ near-white background, bright cyan URLs, and a dark copy pill. That is also why
"terminal surfaces stay dark in both themes" rule is gone: in light mode a code block is now a light
block with dark ink, which is what makes the ramp legible.
Two token sets carry it (index.html §3.8, mirrored in tutorial.html, which has its own copy of the
Two token sets carry it (`frontend/src/styles/base.css`, mirrored in tutorial.html, which has its own copy of the
sheet): `--code-bg` / `--code-ink` / `--code-line` / `--code-btn` for the surface, and
`--sx-cmd` / `--sx-var` / `--sx-str` / `--sx-flag` / `--sx-cmt` / `--sx-punct` for the ramp. Every
light value clears **4.5:1** on `--code-bg` (measured worst case across all pages: 4.67 light, 5.62
@@ -977,7 +1054,7 @@ the prose walkthrough is `docs/TUTORIAL.md`. Editing steps means editing `tutori
**Two focused tutorials as cards** — **Import & shell** (`importShell`, auto-import + shell mode + the
local-run sandbox) and **Team access control** (`access`, per-member tool access + the local-run dial)
are cards on the tutorial chooser (`view==='help'`), rendered by **one shared stepper template** in `index.html`
are cards on the tutorial chooser (`view==='help'`), rendered by **one shared stepper template** in the Dashboard components
(`helpMode === 'import-shell' || 'access'`), with its own `xtut*`-prefixed state/computed/method names
(`xtut.i`, `xtutSteps`, `xtutStep`, `xtutTitle`, `xtutGo`) so they never collide with the CLI tutorial's
`tut*` names. Two extra persona chips: `you` (green) and `sam` (amber). Each also has a **prose twin**
+4 -3
View File
@@ -11,7 +11,8 @@ sources:
- src/treg/api.py
- src/treg/routers/onboard.py
- src/treg/routers/web.py
- src/treg/web/index.html
- frontend/src/state/boot.js
- frontend/src/components/SignedOutPage.vue
- src/treg/web/install.sh
related:
- interface/dashboard.md
@@ -28,7 +29,7 @@ related:
> explanation, footer CTA, invite and share gates, OAuth entry, and sign-in modal remain.
The sections below document backend behavior that is still shipped but has no visitor-facing mint
path in `index.html`. Provisioning, export, samples, and garbage collection remain in
path in the Dashboard components. Provisioning, export, samples, and garbage collection remain in
`application/onboard/sandbox.py`; the call-side sandbox engine remains in `sandbox.py`; and the routes
remain in `routers/onboard.py`. Their removal is intentionally deferred to the backend follow-up.
@@ -79,7 +80,7 @@ or deletes of the seeded `stripe` tool and its `STRIPE_KEY` while the wire is on
fully editable). `is_live_tool` lives in `sandbox.py`; `visitor_name` and its wordlists (`ADJECTIVES`/
`ANIMALS`) live in the neutral `sandbox_identity.py` leaf. `mint()` returns the visitor name;
`POST /demo/sandbox` adds `"live"` and `GET /demo/sandbox/live` (`demo_sandbox_live`) reports `{live, visitor}`.
Both routes remain pending backend removal, but `index.html` no longer calls either one or holds a
Both routes remain pending backend removal, but the Dashboard components no longer calls either one or holds a
sandbox token.
## The public payments feed (`application/onboard/pubfeed.py`)
+4 -2
View File
@@ -8,7 +8,9 @@ sources:
- src/treg/cli.py
- src/treg/routers/auth.py
- src/treg/routers/onboard.py
- src/treg/web/index.html
- frontend/src/state/onboarding.js
- frontend/src/dialogs/WelcomeDialog.vue
- frontend/src/pages/GettingStartedPage.vue
related:
- interface/api.md
- interface/cli.md
@@ -140,7 +142,7 @@ callable tool exists → `_demo_call_log`), then `_demo_next_steps`.
After a first **human** `treg login`, `_maybe_offer_onboarding` prompts `[Y/n]` then `_pick_path` +
`_dispatch_onboard` — **TTY-only / CI-safe**; a decline posts `/onboard/skip` so it never re-asks.
## Dashboard face (`web/index.html`)
## Dashboard face (`frontend/src/dialogs/WelcomeDialog.vue`)
The old docked "Getting started" stepper (`onb.*` state, `.onb-panel`/`.onb-push`/`.onb-shift`) is
**removed** — its content had drifted from the product and it kept re-appearing after signup. First-run
+10 -8
View File
@@ -8,7 +8,10 @@ sources:
- src/treg/web/robots.txt
- src/treg/web/catalog.css
- src/treg/web/usecase.css
- src/treg/web/index.html
- frontend/index.html
- frontend/src/App.vue
- frontend/src/components/PublicNavigation.vue
- frontend/src/state/boot.js
- src/treg/web/landing.html
- src/treg/web/terms.html
- src/treg/web/usecase-seo.html
@@ -181,9 +184,9 @@ competing providers merged onto it** (Majestic $0.0008 · Serpstat $0.0025 · SE
the comparison *is* the product — while the hand-built page listed each endpoint separately. Same
data, different axis, two things to maintain.
So `/catalog` and `/catalog/<slug>` now serve **`index.html`**, and the Vue app renders the same
So `/catalog` and `/catalog/<slug>` serve the **compiled Dashboard entry**, and the Vue app renders the same
platform views a member sees. This works because the catalog API is unauthenticated; `publicCatalog`
in `index.html` is the flag, set from `catalogFromPath()` before the `/auth/me` check so the first
in `frontend/src/state/data.js` is the flag, set from `catalogFromPath()` before the `/auth/me` check so the first
paint is already in public mode.
What public mode changes, and why each one:
@@ -245,8 +248,7 @@ skips `Other` outright — so the sitemap would publish `/catalog/<slug>` while
links to nothing. `test_no_shelf_is_published_that_the_app_grid_hides` fails the build if that
happens.
**UI changes to the shared views reach the public pages automatically** — it is the same
`index.html`. Three things do NOT follow along:
**UI changes to the shared views reach the public pages automatically** — they use the same Dashboard components. Three things do NOT follow along:
1. **Anything reading member-only state.** `providers`, `connCount`, `billing` and `sessionMode` are
all empty without a session, so a new element built on them renders blank publicly. Three helpers
@@ -283,13 +285,13 @@ asserted to appear in its body. Edit one, edit the other, same commit.
**The catalog page and the app must ask for the same population.** See `include_hidden` above.
**A promo banner on `index.html` is a catalog-page edit.** `/catalog` and `/catalog/<slug>` render
from `index.html`, so anything added to that file lands on all ~80 crawlable shelves unless it is
**A promo banner in `App.vue` is a catalog-page edit.** `/catalog` and `/catalog/<slug>` render
from the same Vue app, so anything added to its shell lands on all ~80 crawlable shelves unless it is
gated. The one banner this app has carried — the Product Hunt launch strip, since removed — sat
inside the Vue app behind `v-if="…&& !publicCatalog"` for exactly this reason; anything similar needs
the same gate, plus a test that the catalog's `#prerender` block never carries it. Note also that
`landing.html` **is** `{BASE}`-substituted
and `index.html` **is not** (`dashboard()` returns a plain `FileResponse`), so a placeholder that is
and `index.html` **is not** (`dashboard()` returns the compiled document without that substitution), so a placeholder that is
safe in one half ships literally in the other — hardcode absolute URLs on the app side.
**Prices need `_usd_short`, not `%g`.** `%g` flips to scientific notation below `1e-4`, and a shelf
+15 -3
View File
@@ -3,6 +3,10 @@ title: Running & deploying the server
status: shipped
sources:
- pyproject.toml
- hatch_build.py
- scripts/build-dashboard.sh
- scripts/build-web.sh
- scripts/frontend-e2e-server.sh
- src/treg/__main__.py
- src/treg/maintenance.py
- src/treg/alembic/env.py
@@ -224,12 +228,20 @@ database is local SQLite. Hosted deployments must still leave it false.
## Web service and generic Render example
`GET /` serves the single-file dashboard from `src/treg/web/index.html`. The package includes the
whole `web/` directory, so tutorials, agent files and installer assets ship with the server wheel.
`GET /app` serves the Vite-built Vue application from `src/treg/web/dashboard/index.html`.
The frontend is authored in `frontend/` within the same repository. `GET /` retains the existing
landing behavior. Dashboard assets, tutorials, agent files and installer assets ship with the wheel.
Run `bash scripts/build-dashboard.sh` before building a distributable Python package. Hatch's
build hook rejects a wheel or sdist without the dashboard entry and includes the generated assets;
editable installs remain Python-only. Node and npm are build tools, not runtime services.
`TREG_FRONTEND_DEV=true` serves the authored entry with Vite scripts on local port 5173 and is
accepted only with SQLite and a loopback public URL. `scripts/dev-local.sh up` manages both processes.
[`deploy/render.example.yaml`](../../../deploy/render.example.yaml) is a generic self-hosting example.
It creates one web service and one PostgreSQL database, builds with
`uv sync --locked --no-dev --extra server --active`, runs `python -m treg upgrade` before serving,
`bash scripts/build-web.sh` (frontend build followed by the locked Python install), runs
`python -m treg upgrade` before serving,
starts `python -m treg`, and checks `/meta`. Copy it into the operator's own deployment repository and
change resource names, region, plans, public URL and integrations.
+43
View File
@@ -0,0 +1,43 @@
# Dashboard
This app lives in the public treg repository and is served by the existing Python Web service.
There is no separate production frontend server.
- `src/App.vue`: application shell and conditional page/dialog mounts.
- `src/pages/`: catalog, getting started, activity, tools, team, referrals and help.
- `src/components/` and `src/dialogs/`: shared navigation and overlays.
- `src/state/`: existing Options API use cases, grouped by feature, plus initial state and boot.
- `src/api.ts`: same-origin JSON transport, session expiry and edge-encoding behavior.
- `src/styles/`: base styling. Redesign styles and artwork are shared from `src/treg/web/media/redesign/`.
This is an incremental extraction. The old use cases still share per-application state through
`state/context.ts`; their JavaScript and the shared onboarding widgets are not fully typed.
New isolated components should use typed props and events. Existing hash navigation and deep links
remain in the navigation/catalog/details modules; this change does not replace their URL contract.
There is no duplicated legacy Dashboard HTML implementation.
## Develop
Install Node 22.12+ and npm, then run `scripts/dev-local.sh up` from the repository root.
Open `http://localhost:18790/app`; the Python response loads Vite modules from :5173 for hot updates.
The local-only `TREG_FRONTEND_DEV` switch cannot be used with PostgreSQL or a public hostname.
## Validate and package
From the repository root:
```sh
bash scripts/build-dashboard.sh
npm --prefix frontend test
cd frontend && npx playwright install chromium && cd ..
npm --prefix frontend run test:e2e
uv build
```
Browser tests start their own server on :18791 with a disposable database and no dotenv file.
`PLAYWRIGHT_CHANNEL=chrome` can use an installed Chrome for local checks.
Builds generate `src/treg/web/dashboard/`, which is ignored by Git and included in wheels/sdists.
Do not edit generated files. Distributable package builds fail if these assets are absent; editable
Python installs and background workers do not require Node. The Web build script is
`scripts/build-web.sh`, which compiles the app and retains the locked Python installation.
+83
View File
@@ -0,0 +1,83 @@
import { expect, test, type Page } from '@playwright/test'
async function signIn(page: Page) {
await page.goto('/app?ref=frontend-test')
await page.getByPlaceholder('you@work.com').fill(`browser-${Date.now()}@example.com`)
await page.getByRole('button', { name: 'Email me a sign-in code' }).click()
const code = await page.getByText(/dev code \d{6}/).innerText()
await page.getByPlaceholder('6-digit code').fill(code.match(/\d{6}/)![0])
await page.getByRole('dialog', { name: 'Sign in' }).getByRole('button', { name: 'Sign in', exact: true }).click()
await page.getByPlaceholder('Team name, e.g. Superdesign').fill('Browser test team')
await page.getByRole('button', { name: 'Create team →', exact: true }).click()
await expect(page.getByText('Which agent are you using?', { exact: true })).toBeVisible()
await page.getByRole('link', { name: 'Skip', exact: true }).click()
await expect(page.getByRole('navigation', { name: 'Primary navigation' })).toBeVisible()
}
test('sign in, create team, switch pages, refresh and navigate back', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
await signIn(page)
const navigation = page.getByRole('navigation', { name: 'Primary navigation' })
for (const name of ['Catalog', 'Your own tools', 'Activity', 'Team']) {
await navigation.getByRole('button', { name, exact: true }).click()
await expect(navigation.getByRole('button', { name, exact: true })).toHaveAttribute('aria-current', 'page')
}
await page.reload()
await expect(navigation.getByRole('button', { name: 'Team', exact: true })).toHaveAttribute('aria-current', 'page')
await page.goBack()
await expect(navigation.getByRole('button', { name: 'Activity', exact: true })).toHaveAttribute('aria-current', 'page')
await page.goForward()
await expect(navigation.getByRole('button', { name: 'Team', exact: true })).toHaveAttribute('aria-current', 'page')
await page.locator('.rd-account-menu summary').click()
await page.locator('.rd-account-menu').getByRole('button', { name: 'Billing', exact: true }).click()
await expect(page).toHaveURL(/#orgs$/)
expect(errors).toEqual([])
})
test('onboarding controls and images work on mobile and dark theme', async ({ page }, testInfo) => {
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
await signIn(page)
await page.getByRole('button', { name: 'Getting started', exact: true }).click()
await page.setViewportSize({ width: 390, height: 844 })
await expect(page.locator('.rd-start')).toBeVisible()
const trigger = page.locator('[aria-controls="rd-agent-options"]')
await trigger.click()
await page.locator('#rd-agent-options').getByRole('button', { name: 'Codex', exact: true }).click()
await expect(trigger).toContainText('Codex')
await page.getByRole('button', { name: 'Show key', exact: true }).click()
await expect(page.getByRole('button', { name: 'Hide key', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Hide key', exact: true }).click()
await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', {
configurable: true, value: { writeText: () => Promise.reject(new Error('denied')) },
}))
await page.locator('.rd-setup-panel').first().getByRole('button', { name: 'Copy', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('Could not copy')
await page.getByRole('button', { name: 'Dismiss', exact: true }).click()
await page.locator('.rd-account-menu summary').click()
await page.getByRole('button', { name: 'Dark appearance' }).click()
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
await page.waitForFunction(() => [...document.querySelectorAll<HTMLImageElement>('.rd-task-image, .rd-try .try-ico')].every(img => img.complete && img.naturalWidth > 0))
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.screenshot({ path: testInfo.outputPath('mobile-dark.png'), fullPage: true })
expect(errors).toEqual([])
})
test('public catalog and shared deep links remain available without a session', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
await page.goto('/catalog')
await expect(page.locator('.pubnav')).toBeVisible()
await expect(page.getByRole('navigation', { name: 'Primary navigation' })).toHaveCount(0)
await page.getByRole('button', { name: 'Start free', exact: true }).click()
await expect(page.getByRole('dialog', { name: 'Sign in' })).toBeVisible()
await page.goto('/catalog/google')
await expect(page.locator('.plat-head')).toBeVisible()
await page.reload()
await expect(page.locator('.plat-head')).toBeVisible()
await page.goto('/app/tools/shared-example')
await expect(page.getByRole('heading', { name: /shared-example/ })).toBeVisible()
await expect(page.getByRole('dialog', { name: 'Sign in' })).toBeVisible()
expect(errors).toEqual([])
})
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>treg</title>
<!-- The dashboard is an authenticated app: every view needs a session and supports shared deep links. Nothing here is indexable, and a crawler that got in would index a sign-in prompt. -->
<meta name="robots" content="noindex, follow"/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Google+Sans+Flex:opsz,wght@6..144,400..700&family=Geist+Pixel&family=DM+Mono:ital,wght@0,400;0,500;1,400&family=Inter:wght@400;450;500;600&display=swap" rel="stylesheet">
<script src="/tutorial.js"></script>
<script src="/dashboard-tour/tour.js"></script>
<script src="/adtrack.js"></script>
</head>
<body>
<div id="app"><p role="status">Loading treg…</p><p>If this page does not load, <a href="">reload</a> to get the latest version.</p></div>
<script src="/sitetrack.js"></script>
<script src="/agent-setup.js"></script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1497
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "treg-dashboard",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"test": "vitest run",
"check": "vue-tsc --noEmit",
"test:e2e": "playwright test"
},
"dependencies": {
"vue": "3.5.41"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"@types/node": "26.6.2",
"@vitejs/plugin-vue": "6.0.9",
"typescript": "5.9.3",
"vite": "8.3.0",
"vitest": "5.0.1",
"vue-tsc": "3.3.11"
},
"engines": {
"node": ">=22.12.0"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
workers: 1,
timeout: 45000,
use: {
baseURL: 'http://127.0.0.1:18791',
viewport: { width: 1440, height: 1000 },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
...(process.env.PLAYWRIGHT_CHANNEL ? { channel: process.env.PLAYWRIGHT_CHANNEL } : {}),
},
webServer: {
command: 'bash ../scripts/frontend-e2e-server.sh',
url: 'http://127.0.0.1:18791/meta',
reuseExistingServer: false,
timeout: 90000,
},
})
+253
View File
@@ -0,0 +1,253 @@
<script>
import controller from './state/controller.js'
import CatalogPage from './pages/CatalogPage.vue'
import ProviderPage from './pages/ProviderPage.vue'
import PlatformPage from './pages/PlatformPage.vue'
import ToolsPage from './pages/ToolsPage.vue'
import DetailPage from './pages/DetailPage.vue'
import SecretsPage from './pages/SecretsPage.vue'
import TeamPage from './pages/TeamPage.vue'
import ActivityPage from './pages/ActivityPage.vue'
import AdminPage from './pages/AdminPage.vue'
import GettingStartedPage from './pages/GettingStartedPage.vue'
import ReferralsPage from './pages/ReferralsPage.vue'
import HelpPage from './pages/HelpPage.vue'
import SignedOutPage from './components/SignedOutPage.vue'
import PublicNavigation from './components/PublicNavigation.vue'
import DashboardNavigation from './components/DashboardNavigation.vue'
import ConnectTokenDialog from './dialogs/ConnectTokenDialog.vue'
import TopUpDialog from './dialogs/TopUpDialog.vue'
import AgentGuideDialog from './dialogs/AgentGuideDialog.vue'
import ConnectionMethodDialog from './dialogs/ConnectionMethodDialog.vue'
import ResourcePickerDialog from './dialogs/ResourcePickerDialog.vue'
import ExtraCredentialDialog from './dialogs/ExtraCredentialDialog.vue'
import EditToolDialog from './dialogs/EditToolDialog.vue'
import AcceptInvitesDialog from './dialogs/AcceptInvitesDialog.vue'
import WelcomeDialog from './dialogs/WelcomeDialog.vue'
import CopyToolDialog from './dialogs/CopyToolDialog.vue'
import ImportSkillDialog from './dialogs/ImportSkillDialog.vue'
import RequestToolDialog from './dialogs/RequestToolDialog.vue'
import ShareDialog from './dialogs/ShareDialog.vue'
import RecipeDialog from './dialogs/RecipeDialog.vue'
import RunToolDialog from './dialogs/RunToolDialog.vue'
import CallDetailsDialog from './dialogs/CallDetailsDialog.vue'
import TryEndpointDialog from './dialogs/TryEndpointDialog.vue'
import SignInDialog from './components/SignInDialog.vue'
export default { ...controller, components: { ...controller.components, CatalogPage, ProviderPage, PlatformPage, ToolsPage, DetailPage, SecretsPage, TeamPage, ActivityPage, AdminPage, GettingStartedPage, ReferralsPage, HelpPage, SignedOutPage, PublicNavigation, DashboardNavigation, ConnectTokenDialog, TopUpDialog, AgentGuideDialog, ConnectionMethodDialog, ResourcePickerDialog, ExtraCredentialDialog, EditToolDialog, AcceptInvitesDialog, WelcomeDialog, CopyToolDialog, ImportSkillDialog, RequestToolDialog, ShareDialog, RecipeDialog, RunToolDialog, CallDetailsDialog, TryEndpointDialog, SignInDialog } }
</script>
<template>
<div>
<div :class="{redesign:authed && !publicCatalog}">
<!-- LANDING (logged-out) -->
<SignedOutPage v-if="!authed && !publicCatalog" />
<template v-else>
<a href="#maincontent" class="skip">Skip to content</a>
<!-- A public catalog visitor is reading a WEBSITE, not operating an app: the workspace chrome
(org switcher, global tool search, member nav) is furniture for a job they have not started.
They get the marketing site's nav instead, so /catalog reads as part of treg.to rather than
as a dashboard someone forgot to lock. -->
<PublicNavigation v-if="publicCatalog" />
<DashboardNavigation v-else />
<img v-if="authed && !publicCatalog && view==='start'" class="rd-background" src="/media/redesign/ascii-background.jpg" alt="" aria-hidden="true">
<a v-if="authed && !publicCatalog" class="rd-referral" href="#referrals" @click.prevent="go('referrals')" :aria-current="view==='referrals'?'page':null"><span aria-hidden="true">↗</span> Refer a friend</a>
<div v-if="startCopyError" class="rd-copy-error" role="alert">{{startCopyError}}<br><button class="btn sm" @click="startCopyError=''">Dismiss</button></div>
<span class="rd-sr-only" role="status">{{startCopied ? 'Copied to clipboard' : ''}}</span>
<div class="layout" :class="{solo:publicCatalog}">
<main id="maincontent" tabindex="-1">
<div v-if="!publicCatalog && (view==='tools'||view==='connections')" class="rd-view-search search"><img src="/media/redesign/search.svg" alt=""><input :ref="el => setElement('search', el)" v-model="q" :placeholder="view==='connections'?'Search the catalog…':'Search your own tools…'" aria-label="Search"></div>
<div v-if="err" class="banner">{{err}}</div>
<div v-if="pendingInvites.length" class="banner" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<span>You've been invited:</span>
<span v-for="inv in pendingInvites" :key="inv.id" style="display:inline-flex;align-items:center;gap:6px">
<b>{{inv.name}}</b> <span class="role" :class="inv.role">{{inv.role}}</span>
<button class="btn sm primary" @click="acceptInvite(inv)">Accept</button>
</span>
</div>
<!-- TOOLS -->
<CatalogPage v-if="view==='connections'" />
<!-- MARKETPLACE: one integration -->
<ProviderPage v-if="view==='provider' && mkProvider" />
<!-- MARKETPLACE: one platform, from the endpoint catalog -->
<PlatformPage v-if="view==='platform'" />
<ToolsPage v-if="view==='tools'" />
<!-- DETAIL (shareable deep links: /app/skills/<name> + /app/tools/<name>) -->
<DetailPage v-if="view==='detail' && detail" />
<!-- SECRETS -->
<SecretsPage v-if="view==='secrets'" />
<!-- ORGS -->
<TeamPage v-if="view==='orgs'" />
<!-- ACTIVITY -->
<ActivityPage v-if="view==='activity'" />
<!-- ADMIN -->
<AdminPage v-if="view==='admin'" />
<!-- GETTING STARTED -->
<GettingStartedPage v-if="view==='start'" />
<!-- REFERRALS — a person's link and everyone who used it. A top-level view (never nested):
a view inside a view renders nowhere, and the nav button would look dead. -->
<ReferralsPage v-if="view==='referrals'" />
<!-- HELP -->
<HelpPage v-if="view==='help'" />
</main>
</div>
<div v-if="keyMenu" class="key-actions-menu" role="menu" :style="{top:keyMenu.top+'px',right:keyMenu.right+'px'}" @click.stop>
<button v-if="keyMenu.key.can_rename" role="menuitem" @click="editKey=keyMenu.key.id; editKeyName=keyMenu.key.name; keyMenu=null">Rename</button>
<button v-if="keyMenu.key.can_disable" role="menuitem" @click="requestKeyAction(keyMenu.key,'disable')">Disable</button>
<button v-if="keyMenu.key.can_enable" role="menuitem" @click="requestKeyAction(keyMenu.key,'enable')">Enable</button>
<button v-if="keyMenu.key.can_revoke" class="danger" role="menuitem" @click="requestKeyAction(keyMenu.key,'revoke')">Revoke</button>
<button v-if="keyMenu.key.can_hide && keyMenu.key.state==='revoked'" role="menuitem" @click="requestKeyAction(keyMenu.key,'hide')">Hide</button>
</div>
<div v-if="keyConfirm" class="scrim" role="dialog" aria-modal="true" aria-labelledby="key-confirm-title" @click.self="keyConfirm=null">
<div class="modal" style="width:min(470px,94vw);padding:18px 20px">
<h3 id="key-confirm-title" style="margin:0">{{keyConfirm.action==='rotate'?'Rotate':keyConfirm.action==='disable'?'Disable':keyConfirm.action==='revoke'?'Revoke':'Hide'}} “{{keyConfirm.key.name}}”?</h3>
<p v-if="keyConfirm.action==='rotate'" class="sub" style="margin:12px 0 0"><template v-if="keyConfirm.key.kind==='default_human'">This team's current Getting Started token will stop working immediately. The replacement will be shown next and remain revealable on Getting Started.</template><template v-else>The current key will stop working immediately. The replacement will be shown next so you can update every client using it.</template></p>
<p v-else-if="keyConfirm.action==='disable'" class="sub" style="margin:12px 0 0">Calls using this key will stop until you enable it again. Its value will not change.</p>
<p v-else-if="keyConfirm.action==='revoke'" class="sub" style="margin:12px 0 0"><template v-if="keyConfirm.key.kind==='agent'">This removes the agent from the team and revokes all its keys. Historical Activity will remain available.</template><template v-else>This key will stop working permanently. Historical Activity will remain available.</template></p>
<p v-else class="sub" style="margin:12px 0 0">This revoked key will disappear from the API Keys list. Historical Activity will remain available.</p>
<div class="row-actions" style="display:flex;justify-content:flex-end;margin-top:20px"><button class="btn sm" @click="keyConfirm=null">Cancel</button><button class="btn sm" :class="{danger:keyConfirm.action!=='hide'}" :disabled="keyBusy" @click="confirmKeyAction">{{keyBusy?'…':'Confirm '+keyConfirm.action}}</button></div>
</div>
</div>
<!-- REGISTER SKILL (bundle) -->
<!-- Marketplace dialogs. App-ROOT level, like every other dialog: nested inside the
view==='connections' template they simply did not render on an integration page, so
Connect looked dead and the modal appeared on the list view once you navigated back. -->
<ConnectTokenDialog v-if="tokenAsk" />
<!-- TOP UP. Four presets + Other, the bonus each earns, and auto top-up as a toggle that is
ON by default for a team with no mandate yet. The toggle IS the consent control: its label
is the PSD2/SCA mandate text (amount, threshold), and Pay records it through
/billing/autotopup BEFORE opening Checkout — so the card Checkout saves arms auto top-up
from the setup webhook with numbers a human agreed to. Off = plain one-off top-up. -->
<TopUpDialog v-if="topupOpen&&billing" />
<AgentGuideDialog v-if="capAsk" />
<ConnectionMethodDialog v-if="methodAsk" />
<ResourcePickerDialog v-if="resPick" />
<ExtraCredentialDialog v-if="newSkill" />
<!-- ADD / EDIT TOOL -->
<EditToolDialog v-if="newTool" />
<!-- JOIN BY CODE -->
<div class="scrim" role="dialog" aria-modal="true" v-if="showJoin" @click.self="showJoin=false">
<div class="modal" style="width:min(460px,92vw)"><div class="hd"><b>Join with an invite code</b><button class="btn sm" @click="showJoin=false" aria-label="Close">✕</button></div>
<div style="padding:18px"><p class="sub" style="margin-top:0">Paste the one-time code an admin gave you. It must match your email (<span class="mono">{{me}}</span>). Invites addressed to you also appear automatically as a banner.</p>
<div class="field"><input v-model="joinCode" placeholder="one-time invite code" @keyup.enter="joinByCode"/></div>
<button class="btn primary" @click="joinByCode" :disabled="joinBusy">{{joinBusy?'Joining…':'Join'}}</button>
<div v-if="joinErr" class="banner" style="margin-top:12px">{{joinErr}}</div>
</div></div>
</div>
<!-- INVITED: accept pending invites (multi-select, all checked, the clicked link's team first).
Opens on first run (no team yet) AND whenever an invite link lands (?invite_org=), even for
users already in other teams. Decline → create-your-own (first run) or just close. -->
<AcceptInvitesDialog v-if="inviteChoice && pendingInvites.length" />
<!-- FIRST-RUN WELCOME: name your team → pick your agent → the setup line (the primary onboarding path) -->
<WelcomeDialog v-if="welcome.on" />
<!-- CREATE TEAM -->
<div class="scrim" role="dialog" aria-modal="true" v-if="newOrg" @click.self="newOrg=false">
<div class="modal" style="width:min(440px,92vw)"><div class="hd"><b>Create a team</b><button class="btn sm" @click="newOrg=false" aria-label="Close">✕</button></div>
<div style="padding:18px"><p class="sub" style="margin-top:0">You'll be its owner - invite teammates after.</p>
<div class="field"><input v-model="newOrgName" placeholder="Team name, e.g. Superdesign" @keyup.enter="createOrg"/></div>
<button class="btn primary" @click="createOrg" :disabled="orgBusy">{{orgBusy?'Creating…':'Create'}}</button>
<div v-if="orgErr" class="banner" style="margin-top:12px">{{orgErr}}</div>
</div></div>
</div>
<!-- ADD ORG -->
<div class="scrim" role="dialog" aria-modal="true" v-if="addOrg" @click.self="addOrg=false">
<div class="modal" style="width:min(440px,92vw)"><div class="hd"><b>Add an organization</b><button class="btn sm" @click="addOrg=false" aria-label="Close">✕</button></div>
<div style="padding:18px"><p class="sub" style="margin-top:0">Paste that org's token (each org has its own).</p>
<div class="field"><input v-model="tokenInput" type="password" placeholder="X-Treg-Token"/></div>
<button class="btn primary" @click="addToken(tokenInput, true)" :disabled="busy">{{busy?'Checking…':'Add'}}</button>
<div v-if="loginErr" class="banner" style="margin-top:12px">{{loginErr}}</div>
</div></div>
</div>
<!-- COPY -->
<CopyToolDialog v-if="copyTool" />
<!-- INSTALL A RECIPE (recipe-only bundle: how to install/use, no proxy call) -->
<div class="scrim" role="dialog" aria-modal="true" v-if="copyRecipe" @click.self="copyRecipe=null">
<div class="modal"><div class="hd"><b>Install “{{copyRecipe.name}}”</b><button class="btn sm ico" @click="copyRecipe=null" aria-label="Close">✕</button></div>
<div style="padding:16px 18px">
<div class="tabs"><button v-for="t in ['cURL','CLI','Claude Code']" :key="t" :class="{active:recipeTab===t}" @click="recipeTab=t">{{t}}</button></div>
<p class="explain">A recipe is know-how (a <span class="mono">SKILL.md</span>), not an API - you don't call it, you <b>install</b> it into <span class="mono">.claude/skills/</span> so an agent can use it.</p>
<pre class="code" v-html="recipeSnip.html"></pre>
<div style="margin-top:12px"><button class="btn primary" @click="copy(recipeSnip.text)">⧉ {{copied?'Copied!':'Copy'}}</button></div>
</div></div>
</div>
<!-- AGENT SETUP GUIDE (an instruction to paste into a coding agent) -->
<div class="scrim" role="dialog" aria-modal="true" v-if="agentGuide" @click.self="agentGuide=null">
<div class="modal" style="width:min(680px,95vw)"><div class="hd"><b>{{agentGuide==='admin'?'Sync your skills &amp; secrets':'Use your team’s shared tools'}}</b><button class="btn sm ico" @click="agentGuide=null" aria-label="Close">✕</button></div>
<div style="padding:16px 18px">
<p class="explain">Paste this into your coding agent (Claude Code / Codex / Gemini). One line — the agent reads llms.txt and does the rest: installs the CLI, signs in as you, and makes its first call. No API keys land on your machine.</p>
<pre class="code" style="white-space:pre-wrap;max-height:44vh;overflow:auto">{{agentPromptText}}</pre>
<div style="margin-top:12px;display:flex;gap:10px;align-items:center"><button class="btn primary" @click="copyAgentPrompt">⧉ {{agentGuideCopied?'Copied!':'Copy'}}</button></div>
</div></div>
</div>
<!-- LIST AS VENDOR (an instruction the vendor pastes into THEIR coding agent; it raises the PR) -->
<ImportSkillDialog v-if="vendorAsk" />
<!-- REQUEST A TOOL (a "the catalog doesn't have X" report — saved server-side, steers what gets keyed next) -->
<RequestToolDialog v-if="reqAsk" />
<!-- SHARE A DETAIL PAGE (invite someone new; they land right here after sign-in) -->
<ShareDialog v-if="share.on && detail" />
<!-- VIEW A RECIPE (the SKILL.md content) -->
<RecipeDialog v-if="viewRecipe" />
<!-- TRY -->
<!-- USE drawer: one entry point, two verbs — API call (proxy) / CLI run (server) -->
<RunToolDialog v-if="tryTool" />
<!-- Try a MARKETPLACE endpoint right here. Same relay as any call — served by the org's own
key when one exists, else by a verified public upstream route when declared, else treg's
key billed to the team balance — and logged in Activity exactly like a CLI call. -->
<!-- Activity → one call. The archive's copy of what was asked and what came back (metered
platform calls only — own-key and own-tool calls are relayed without being stored). -->
<CallDetailsDialog v-if="callView" />
<TryEndpointDialog v-if="epTry" />
<!-- access reminder toast: fired when a new tool is registered while some members have customized access -->
<div v-if="newVersion" class="tut-notice" style="position:fixed;bottom:18px;right:18px;max-width:360px;z-index:200;box-shadow:0 6px 20px rgba(0,0,0,.25);background:var(--card)">
A new version of the dashboard is available.
<div style="margin-top:8px;display:flex;gap:8px"><button class="btn sm" @click="reloadApp()">Refresh now</button><button class="btn sm" @click="newVersion=false">Later</button></div>
</div>
<div v-if="accessNote" class="tut-notice" style="position:fixed;bottom:18px;right:18px;max-width:360px;z-index:200;box-shadow:0 6px 20px rgba(0,0,0,.25);background:var(--card)">
{{accessNote}}
<div style="margin-top:8px;display:flex;gap:8px"><button class="btn sm" @click="go('org')">Open Team</button><button class="btn sm" @click="accessNote=''">Dismiss</button></div>
</div>
</template>
<!-- SIGN-IN MODAL — a SIBLING of both branches. It used to live inside the logged-out
landing, which meant the public catalog (which renders the app shell, not that branch)
had no way to sign anyone in and every CTA had to navigate away to find one. -->
<SignInDialog />
</div>
</div>
</template>
+61
View File
@@ -0,0 +1,61 @@
/** Same-origin JSON transport. Credentials and team selection are supplied by the session. */
export interface RequestOptions extends Omit<RequestInit, 'headers'> {
headers?: Record<string, string>
encode?: boolean
}
export class ApiError extends Error {
constructor(public status: number, public detail: unknown = undefined) {
super(typeof detail === 'string' ? detail : `HTTP ${status}`)
}
}
function encodeBody(body: string): string {
const bytes = new TextEncoder().encode(body)
// Avoid argument spreading: imported skill folders can exceed the engine's argument limit.
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary)
}
export async function requestJson<T = unknown>(
path: string,
opts: RequestOptions,
headers: Record<string, string>,
expired: () => void,
sessionMode: () => boolean,
): Promise<T> {
const { encode, ...request } = opts
const init: RequestInit = {
credentials: 'include',
...request,
headers: { ...headers, ...opts.headers },
}
if (encode && typeof opts.body === 'string') {
init.body = encodeBody(opts.body)
init.headers = { ...headers, ...opts.headers, 'X-Treg-Body-Encoding': 'base64' }
}
let response = await fetch(path, init)
if (response.status === 403 && typeof opts.body === 'string' && !encode &&
response.headers.get('content-type')?.includes('html')) {
try {
response = await fetch(path, {
...init,
body: encodeBody(opts.body),
headers: { ...headers, ...opts.headers, 'X-Treg-Body-Encoding': 'base64' },
})
} catch {
throw new ApiError(403, 'The edge blocked this request and the encoded retry failed - try a smaller folder')
}
}
if (response.status === 401 && sessionMode() && !path.startsWith('/auth/')) {
expired()
// Navigation replaces the application; do not run downstream success handlers meanwhile.
return new Promise(() => {})
}
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new ApiError(response.status, body?.detail)
}
return response.json()
}
@@ -0,0 +1,59 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<header class="rd-top" >
<div class="rd-identity">
<a class="rd-brand" href="#start" @click.prevent="go('start')" aria-label="treg home"><img src="/media/redesign/treg-mark.png" alt=""><span>treg</span></a>
<div class="orgblock" v-if="authed">
<div class="orgmain" :ref="el => setElement('orgmain', el)" @click="toggleOrgMenu" role="button" tabindex="0" @keydown.enter="toggleOrgMenu" @keydown.space.prevent="toggleOrgMenu" aria-haspopup="true" :aria-expanded="orgMenu" aria-label="Teams">
<span class="role" :class="activeRole">{{activeRole}}</span>
<b class="orgname">{{activeName}}</b>
<span class="orgcaret">▾</span>
</div>
<div class="dropdown" v-if="orgMenu" :style="orgMenuStyle" @click.stop>
<div class="grp" style="margin:4px 10px 6px">Your teams</div>
<div class="orgrow" v-for="o in myOrgs" :key="o.slug" :class="{off:!connected(o.slug)}">
<div class="orgrow-main">
<div class="orgrow-name">{{o.name}}</div>
<div class="orgrow-meta"><span class="role" :class="o.role">{{o.role}}</span><span v-if="isPersonal(o)" class="chip">personal</span><span v-if="o.demo" class="chip demo">demo</span></div>
</div>
<button class="orgrow-btn" @click.stop="orgSettings(o)" title="Team settings" aria-label="Team settings">⚙</button>
<span v-if="o.slug===activeSlugNow" class="orgrow-active">✓ active</span>
<button v-else-if="sessionMode || connected(o.slug)" class="orgrow-btn" @click.stop="switchTo(o)">Switch</button>
<button v-else class="orgrow-btn" @click.stop="addOrg=true; orgMenu=false">Token</button>
</div>
<div class="orgrow-sep"></div>
<button type="button" class="row" v-if="sessionMode" @click="newOrg=true; newOrgName=''; orgMenu=false"><span>+ New team</span></button>
<button type="button" class="row" v-if="sessionMode" @click="showJoin=true; joinCode=''; joinErr=''; orgMenu=false"><span>⤷ Join by code</span></button>
<button type="button" class="row" v-if="!sessionMode" @click="addOrg=true; orgMenu=false"><span>+ Add team with token</span></button>
</div>
</div>
</div>
<nav class="rd-navs" aria-label="Primary navigation"><button v-if="authed" class="rd-nav" :class="{active:view==='start'}" :aria-current="(view==='start')?'page':null" @click="go('start')"><img src="/media/redesign/nav-getting-started.svg" alt="">Getting started</button>
<button v-if="canRegister" class="rd-nav" :class="{active:view==='connections'}" :aria-current="(view==='connections')?'page':null" @click="go('connections')"><img src="/media/redesign/nav-catalog.svg" alt="">Catalog</button>
<button v-if="authed" class="rd-nav" :class="{active:view==='tools'||view==='secrets'}" :aria-current="(view==='tools'||view==='secrets')?'page':null" @click="go('tools')"><img src="/media/redesign/nav-vault.svg" alt="">Your own tools</button>
<button v-if="authed" class="rd-nav" :class="{active:view==='activity'}" :aria-current="(view==='activity')?'page':null" @click="go('activity')"><img src="/media/redesign/nav-activity.svg" alt="">Activity</button>
<button v-if="authed" class="rd-nav" :class="{active:view==='orgs'}" :aria-current="(view==='orgs')?'page':null" @click="go('orgs')"><img src="/media/redesign/nav-team.svg" alt="">Team</button></nav>
<div class="rd-account">
<div class="rd-social"><a href="https://github.com/superdesigndev/treg" target="_blank" rel="noopener" aria-label="GitHub"><img src="/media/redesign/social-github.svg" alt=""></a><a href="https://discord.gg/6mQYYfFMAn" target="_blank" rel="noopener" aria-label="Discord"><img src="/media/redesign/social-discord.svg" alt=""></a></div>
<button v-if="billing" class="rd-balance" @click="orgTab='billing'; go('orgs')"><span>Balance</span><b>{{money(billing.balance_micro)}}</b></button>
<details class="rd-account-menu" :ref="el => setElement('accountMenu', el)">
<summary :aria-label="'Account: '+me"><span class="rd-avatar">{{initials}}</span></summary>
<div class="rd-account-panel">
<p class="rd-email">{{me}}</p>
<button @click="toggleTheme">{{theme==='dark'?'Light appearance':'Dark appearance'}}</button>
<button v-if="billing" @click="orgTab='billing'; go('orgs'); elements.accountMenu.open=false">Billing</button>
<button v-if="isAdmin" @click="go('admin'); elements.accountMenu.open=false">Admin</button>
<a href="/tutorial">Tutorial</a>
<a href="https://x.com/treg_ai" target="_blank" rel="noopener">Follow on X</a>
<button @click="logout">Sign out</button>
</div>
</details>
</div>
</header>
</template>
@@ -0,0 +1,18 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="pubnav" >
<a class="brand" href="/">▚ treg</a>
<nav class="pubnav-links">
<a href="/catalog" aria-current="page">Catalog</a>
<a href="/tutorial">Tutorial</a>
<a href="/docs">API</a>
<a class="hidem" href="https://github.com/superdesigndev/treg" target="_blank" rel="noopener">GitHub ↗</a>
<a @click="openSignin()">Sign in</a>
<button class="btn primary sm" @click="openSignin()">Start free</button>
</nav>
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="lc-scrim" :class="{open:demo.signin}" @click.self="demo.signin=false">
<div class="lc-modal" role="dialog" aria-modal="true" aria-label="Sign in">
<button class="cls" @click="demo.signin=false" aria-label="Close">✕</button>
<div style="font-size:26px">▚</div>
<h2 style="margin:8px 0 2px;font-family:var(--mono)">{{oauthSignin?'Sign in to continue connecting Treg':(invitePrefill?'Accept your invite':(shareGate?'Sign in to view it':(publicCatalog?'Start calling':'Make it yours')))}}</h2>
<p v-if="oauthSignin" class="sub">After sign-in, review the requested access before you approve it.</p>
<p v-else-if="invitePrefill" class="sub">Sign in with <b>{{invitePrefill}}</b> and you'll drop straight into the team.</p>
<p v-else-if="shareGate" class="sub">You'll land on “{{shareGate.name}}” right after.</p>
<!-- The default line is the SANDBOX's ("bring it into a real account"), which is nonsense to
someone who arrived on a catalog page from a search result and has no sandbox. -->
<p v-else-if="publicCatalog" class="sub">Verify your new account and create a team to call these tools. <b>$1.00 of credit once</b> on an eligible team, no card.</p>
<p v-else class="sub">Sign in to connect your agent and manage your team.</p>
<button v-if="meta.github" class="btn primary" style="width:100%;padding:11px;margin-bottom:8px" @click="githubLogin">Continue with GitHub</button>
<button v-if="meta.google" class="btn" style="width:100%;padding:11px;margin-bottom:8px" @click="googleLogin">Continue with Google</button>
<div v-if="!emailStage">
<div class="field"><input v-model="emailInput" type="email" placeholder="you@work.com" @keyup.enter="emailStart"/></div>
<button class="btn" style="width:100%" @click="emailStart" :disabled="busy">{{busy?'Sending…':'Email me a sign-in code'}}</button>
</div>
<div v-else>
<p class="sub" style="font-size:11px;margin:2px 0 8px">Code sent to <b>{{emailInput}}</b><span v-if="devCode"> · dev code <b class="mono">{{devCode}}</b></span> · <a href="#" @click.prevent="emailStage=false">change</a></p>
<div class="field"><input v-model="codeInput" inputmode="numeric" placeholder="6-digit code" @keyup.enter="emailVerify"/></div>
<button class="btn primary" style="width:100%" @click="emailVerify" :disabled="busy">{{busy?'Verifying…':'Sign in'}}</button>
</div>
<details v-if="!oauthSignin" style="margin-top:12px;text-align:left">
<summary class="sub" style="cursor:pointer;text-align:center">or paste an org token (agents &amp; CLI)</summary>
<div class="field" style="margin-top:8px"><input v-model="tokenInput" type="password" placeholder="X-Treg-Token" @keyup.enter="addToken(tokenInput)"/></div>
<button class="btn" style="width:100%" @click="addToken(tokenInput)" :disabled="busy">{{busy?'Checking…':'Sign in with token'}}</button>
</details>
<div v-if="loginErr" class="banner" style="margin-top:12px">{{loginErr}}</div>
</div>
</div>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="lp">
<!-- SHARE-LINK GATE: a /app/skills|tools/<name> deep link while logged out gets a focused
sign-in page (no sandbox demo, no tour) — the marketing landing is for the front door. -->
<template v-if="shareGate">
<header class="lp-nav">
<div class="brand">▚ treg</div>
<nav class="lp-links"><a href="/tutorial">Docs</a><button class="iconbtn" @click="toggleTheme" :aria-label="theme==='dark'?'Light theme':'Dark theme'">{{theme==='dark'?'◐':'◑'}}</button></nav>
</header>
<section class="lp-hero" style="min-height:55vh">
<div class="lp-eyebrow">shared with you</div>
<h1 class="lp-h1">{{shareGate.name}} <span style="color:var(--accent)">· {{shareGate.kind}}</span></h1>
<p class="lp-lead">A teammate shared this {{shareGate.kind}} with you. Sign in and you'll land right on its page — no API key ever touches your machine.</p>
<div class="lp-cta"><button class="btn primary lg" @click="demo.signin=true">Sign in to view</button></div>
</section>
</template>
<template v-else>
<header class="lp-nav">
<div class="brand">▚ treg</div>
<nav class="lp-links">
<a href="/tutorial">Docs</a><a href="/llms.txt">llms.txt</a>
<button class="iconbtn" @click="toggleTheme" :aria-label="theme==='dark'?'Light theme':'Dark theme'">{{theme==='dark'?'◐':'◑'}}</button>
<button class="btn sm" @click="demo.signin=true">Sign in</button>
</nav>
</header>
<section class="lp-hero">
<div class="lp-eyebrow">the tool catalog for your agent</div>
<h1 class="lp-h1">Give your agent the tools for the job, <span style="color:var(--accent)">without buying the seats</span>.</h1>
<p class="lp-lead">2,800+ endpoints across 59 providers — SEO and backlinks, social and trends,
people and company enrichment, ads — <b>priced per call, from a cent</b>, with no provider signup.
Plus your own team's keys and skills, callable by every teammate's agent, with the credential
never leaving the server.</p>
<div class="lc-chips"><span>one key, every provider</span><span>price shown before the call</span><span>your own key is never metered</span></div>
<div class="lp-cta">
<button class="btn primary lg" @click="demo.signin=true">Get started, free</button>
<a class="btn lg" href="/tutorial">Read the docs →</a>
</div>
</section>
<section class="lp-section">
<div class="lp-head"><h2>Three ways a key leaks. treg closes all three.</h2>
<p>The same secret, needed in more and more places - and every place is a copy that can leak.</p></div>
<div class="lc-tri">
<div class="lc-pain"><div class="t">Key sprawl</div><div class="p">The same key pasted into every project, <code>.env</code>, and machine. One leak exposes them all.</div><div class="f"><b>treg →</b> one place. The key is injected at call time and lives nowhere else.</div></div>
<div class="lc-pain"><div class="t">Team access</div><div class="p">Give a teammate access and they hold your key - forever, everywhere they go.</div><div class="f"><b>treg →</b> they get a token, not the key. Revoke it and they're out.</div></div>
<div class="lc-pain"><div class="t">Agent skills</div><div class="p">A skill needs credentials to run. Sharing the skill shouldn't mean shipping the secret.</div><div class="f"><b>treg →</b> the skill calls through treg. Your agent runs it and never sees the key.</div></div>
</div>
</section>
<section class="lp-section" style="text-align:center">
<div class="lp-head" style="margin:0 auto"><h2>Share your team's keys and skills, <span style="color:var(--accent)">without sharing your keys</span>.</h2>
<p>Your team's tools and your agents' skills, all one revocable token away, and no key ever on a machine — alongside the 2,800+ catalog tools you don't need a key for at all.</p></div>
<div class="lp-cta" style="margin-top:24px">
<button class="btn primary lg" @click="demo.signin=true">Get started, free</button>
<a class="btn lg" href="/tutorial">Read the docs →</a>
</div>
</section>
<footer class="lp-foot">treg · a credential-injecting proxy · <a href="/tutorial">tutorial</a> · <a href="/llms.txt">llms.txt</a></footer>
</template>
</div>
</template>
@@ -0,0 +1,24 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" >
<div class="modal" style="width:min(470px,94vw)">
<div style="padding:26px 26px 22px">
<div style="color:var(--accent);font-size:15px;letter-spacing:.5px;margin-bottom:12px">▚ treg</div>
<h2 style="margin:0 0 8px;font-size:20px">You're invited 👋</h2>
<p class="sub" style="margin:0 0 16px">{{pendingInvites.length===1 ? 'Accept to call the team\'s tools with' : 'You have '+pendingInvites.length+' pending invites — pick the teams to join and call their tools with'}} <b>no API keys on your machine</b>.</p>
<label v-for="inv in sortedInvites" :key="inv.id" style="display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;margin-bottom:8px;cursor:pointer">
<input type="checkbox" v-model="inviteSel[inv.id]" :disabled="inviteBusy" style="accent-color:var(--accent)"/>
<span style="flex:1;min-width:0"><b>{{inv.name}}</b> <span class="role" :class="inv.role">{{inv.role}}</span><br/>
<span class="sub" style="font-size:12px">invited by {{inv.invited_by||'a teammate'}}</span></span>
</label>
<div v-if="inviteErr" class="banner" style="margin:4px 0 8px">{{inviteErr}}</div>
<button class="btn primary" style="width:100%;margin-top:2px" @click="acceptSelectedInvites" :disabled="inviteBusy || !selectedInvites.length">{{inviteBusy?'Joining…':(selectedInvites.length===1?'Accept & join '+selectedInvites[0].name+' →':'Accept & join '+selectedInvites.length+' teams →')}}</button>
<button class="btn" style="width:100%;margin-top:10px" @click="declineInvite" :disabled="inviteBusy">{{inviteFirstRun ? 'Create my own team instead' : 'Not now'}}</button>
</div>
</div>
</div>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="capAsk=null">
<div class="modal" style="padding:16px">
<h3 style="margin:0 0 6px">Connect {{capAsk.provider.display_name}}</h3>
<p class="sub" style="margin:0 0 14px">What should your agent be allowed to do with this account?
Widening it later means going through the provider's consent screen again.</p>
<p v-if="capAsk.provider.consent_notice" class="mk-notice" style="margin:0 0 14px">{{capAsk.provider.consent_notice}}</p>
<!-- The price BEFORE the consent screen, not on the invoice: an oauth-billed provider (X)
charges treg's app per use, so calls on this connection are metered from the balance. -->
<p v-if="capAsk.provider.metered" class="mk-notice" style="margin:0 0 14px">Calls on this connection are
metered from your team balance — {{capAsk.provider.display_name}} bills per use
(reads ~${{capAsk.provider.billed_rates.read_per_result_usd}}/result,
posts ${{capAsk.provider.billed_rates.write_per_call_usd}},
${{capAsk.provider.billed_rates.write_with_link_usd}} when the post links out).
Connecting your own developer app instead is never metered.</p>
<div class="ttable-wrap"><table class="ttable">
<tr v-for="cap in capOptions(capAsk)" :key="cap">
<!-- Deliberately NOT .tn: that class is nowrap, which is right for a name column
but here the help text shares the cell. Unwrapped, a long capability
description widens the table past the modal and .ttable-wrap's overflow:hidden
clips the Choose button clean off — the choice becomes unclickable. -->
<td style="white-space:normal"><b>{{capLabel(cap,capAsk)}}</b>
<span v-if="capInReview(cap,capAsk)" class="chip warn" style="margin-left:6px">In review</span>
<span class="sub" style="display:block;font-size:11px;margin-top:3px">{{capHelp(cap,capAsk)}}</span></td>
<td class="tx"><button class="btn sm" :class="{primary:cap===capDefault(capAsk)}"
@click="chooseCapability(cap)">Choose</button></td>
</tr>
</table></div>
<div style="margin-top:12px;text-align:right"><button class="btn sm" @click="capAsk=null">Cancel</button></div>
</div>
</div>
</template>
@@ -0,0 +1,58 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="callView=null" style="place-items:stretch;justify-items:end">
<div class="drawer" style="width:min(680px,96vw)"><div class="hd" style="padding:15px 18px;border-bottom:1px solid var(--line)"><b>{{callView.endpoint_id||callView.tool||'Call'}}</b><button class="btn sm" @click="callView=null" aria-label="Close">✕</button></div>
<div class="bd" style="padding:16px 18px;overflow:auto">
<div class="kv">
<div><b>When</b>{{when(callView.created_at)}}</div>
<div><b>Status</b><span class="badge" :class="callView.status_code<400?'ok':'invalid'">{{callView.status_code}}</span></div>
<div v-if="callView.provider"><b>Provider</b>{{callView.provider}}</div>
<div v-if="callView.credential_tier"><b>Served on</b>{{servedOn(callView.credential_tier)}}</div>
<div v-if="callView.cached"><b>Answer</b><span class="chip" title="served from the archive instead of the vendor">cached</span></div>
<div v-if="callView.call_ref"><b>Call id</b><span class="mono" style="font-size:11px">{{callView.call_ref}}</span></div>
</div>
<!-- A generation task's result, shown rather than linked: the video or image the caller paid
for is the point of the call, and the archived body is only the submission receipt. -->
<template v-if="callView.task">
<h3 style="margin:10px 0 6px;font-size:13px;display:flex;align-items:center;gap:8px">Result
<span class="chip" :title="taskStateTitle(callView.task)">{{taskStateLabel(callView.task)}}</span>
<a v-if="callView.task.result_url" :href="callView.task.result_url" target="_blank" rel="noopener" style="margin-left:auto;font-size:12px">open ↗</a>
</h3>
<template v-if="callView.task.result_url">
<video v-if="isVideoUrl(callView.task.result_url)" :src="callView.task.result_url" controls playsinline style="width:100%;max-height:60vh;background:#000;border-radius:8px"></video>
<img v-else :src="callView.task.result_url" alt="generated result" style="max-width:100%;max-height:60vh;border-radius:8px;display:block"/>
<p class="sub" style="margin:6px 0 12px">{{callView.task.ttl_note?'The provider keeps this file for '+callView.task.ttl_note+' — download it to keep it.':'Time-limited link — download it to keep it.'}}</p>
</template>
<p v-else-if="callView.task.fetch_command" class="sub" style="margin:0 0 12px">Retrieve it from the CLI: <span class="mono">{{callView.task.fetch_command}}</span></p>
<p v-else-if="callView.task.error" class="sub" style="margin:0 0 12px;color:var(--red)">{{callView.task.error}}</p>
<p v-else-if="callView.task.status==='pending'" class="sub" style="margin:0 0 12px">Still generating — the result appears here when the provider finishes.</p>
</template>
<p v-if="callView.loading" class="sub">Loading…</p>
<p v-else-if="callView.error" class="sub" style="color:var(--red)">{{callView.error}}</p>
<template v-else>
<p v-if="callView.note" class="sub" style="margin:4px 0 12px">{{callView.note}}</p>
<template v-if="callView.request">
<h3 style="margin:10px 0 6px;font-size:13px">Request</h3>
<p class="explain" style="margin:0 0 6px"><span class="mono">{{callView.request.method}} {{callView.request.url}}</span></p>
<pre v-if="callView.request.body_text" style="max-height:24vh">{{pretty(callView.request.body_text)}}</pre>
</template>
<template v-if="callView.response">
<h3 style="margin:14px 0 6px;font-size:13px;display:flex;align-items:center;gap:8px">Response
<span class="muted" style="font-weight:400;font-size:11px">{{callView.response.status_code}} · {{callView.response.media_type||'—'}} · {{fmtBytes(callView.response.size_bytes)}} · fetched {{when(callView.response.fetched_at)}}</span>
<button v-if="callView.response.body_text" class="btn sm" style="margin-left:auto" @click="copyCallBody()">{{callCopied||'Copy'}}</button>
</h3>
<template v-if="callView.response.body_text">
<pre style="max-height:60vh">{{callBodyShown}}</pre>
<p v-if="callBodyTruncated" class="sub" style="margin:6px 0 0"><a href="#" @click.prevent="callViewFull=true">Show full response ({{fmtBytes(callView.response.body_text.length)}})</a></p>
</template>
<p v-else-if="callView.stored" class="sub">The stored answer is not text — copy is unavailable for binary bodies.</p>
</template>
</template>
</div>
</div>
</div>
</template>
@@ -0,0 +1,36 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="tokenAsk=null">
<div class="modal" style="padding:16px">
<h3 style="margin:0 0 6px"><span class="plogo-tile"><img class="plogo" :src="'/logos/'+tokenAsk.provider.service+'.svg'" alt="" aria-hidden="true" @error="$event.target.style.visibility='hidden'"></span>Connect {{tokenAsk.provider.display_name}}</h3>
<p class="sub" style="margin:0 0 12px">You bring your own {{tokenAsk.provider.auth_kind==='key'?'API key':'bot'}}, so it stays yours — treg
holds it server-side and injects it on every call.</p>
<a v-if="tokenAsk.provider.setup_url" class="btn sm primary" :href="tokenAsk.provider.setup_url"
target="_blank" rel="noopener">{{tokenAsk.provider.setup_action_label||'Create the app'}}</a>
<ol style="margin:12px 0 0;padding-left:20px;color:var(--muted);font-size:12.5px;line-height:1.7">
<li v-for="(st,i) in (tokenAsk.provider.setup_steps||[])" :key="i">{{st}}</li>
</ol>
<div style="margin-top:12px">
<label class="labelcls" style="display:block;font-size:11px;color:var(--muted);margin-bottom:4px">{{tokenAsk.provider.token_label||'Token'}}</label>
<input class="bindinput" style="width:100%" type="password" autocomplete="off"
:placeholder="tokenAsk.provider.token_placeholder" v-model="tokenAsk.token"
@keyup.enter="submitToken"/>
</div>
<p v-if="tokenAsk.provider.setup_note" class="sub" style="margin:10px 0 0;font-size:11.5px">{{tokenAsk.provider.setup_note}}</p>
<p v-if="tokenAsk.provider.probe_cost_micro" class="mk-notice" style="margin:10px 0 0">
Verifying this key makes one provider-billed test call costing {{money(tokenAsk.provider.probe_cost_micro)}}.
treg will not reuse this paid request for health checks.
</p>
<div v-if="tokenAsk.err" class="banner" style="margin-top:10px">{{tokenAsk.err}}</div>
<div style="margin-top:14px;text-align:right;display:flex;gap:8px;justify-content:flex-end">
<button class="btn sm" @click="tokenAsk=null">Cancel</button>
<button class="btn sm primary" :disabled="!tokenAsk.token.trim()||tokenAsk.busy" @click="submitToken">
{{tokenAsk.busy?'Verifying…':'Connect'}}</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,35 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" aria-labelledby="method-ask-title" @click.self="methodAsk=null">
<div class="modal" style="padding:16px;width:min(560px,94vw)">
<h3 id="method-ask-title" style="margin:0 0 6px">Connect {{methodAsk.provider.display_name}}</h3>
<p class="sub" style="margin:0">Choose how this account should connect. You can add the other method separately later.</p>
<div class="method-grid" role="radiogroup" :aria-label="'How to connect '+methodAsk.provider.display_name">
<label v-for="method in methodAsk.provider.authorization_methods" :key="method.name"
:class="['method-card',{on:methodAsk.selected===method.name,off:!method.configured}]">
<input type="radio" name="authorization-method" :value="method.name" v-model="methodAsk.selected"
:disabled="!method.configured"/>
<span class="method-copy">
<span class="method-title">
<b>{{method.display_name}}</b>
<span v-if="isRecommendedMethod(methodAsk.provider,method)" class="chip ok">Recommended</span>
<span v-if="method.in_review" class="chip warn">In review</span>
<span v-if="!method.configured" class="chip warn">Not configured</span>
</span>
<span class="sub">{{method.description}}</span>
<span v-if="method.consent_notice && methodAsk.selected===method.name" class="sub">{{method.consent_notice}}</span>
</span>
</label>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="btn sm" @click="methodAsk=null">Cancel</button>
<button class="btn sm primary" :disabled="connBusy || !selectedMethod(methodAsk) || !selectedMethod(methodAsk).configured"
@click="continueMethod()">Continue</button>
</div>
</div>
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="copyTool=null">
<div class="modal"><div class="hd"><b>Use “{{copyTool.name}}”</b><button class="btn sm ico" @click="copyTool=null" aria-label="Close">✕</button></div>
<div style="padding:16px 18px">
<div class="tabs"><button v-for="t in snippetTabs" :key="t" :class="{active:snippetTab===t}" @click="snippetTab=t">{{t}}</button></div>
<p class="explain">One call to the proxy - your key is injected server-side. <b>PATH</b> is the <span class="mono">{{copyTool.host}}</span> path you'd normally call.</p>
<div v-if="copyTool.examples && copyTool.examples.length">
<div class="lbl">Examples - click to drop into the snippet</div>
<div class="exrow"><span v-for="(ex,exi) in copyTool.examples" :key="exi" class="exchip" :class="{on:exPath===ex.path}" @click="pickEx(ex)"><span class="m">{{ex.method||'GET'}}</span>{{ex.note||ex.path}}</span></div>
</div>
<pre class="code" v-html="snip.html"></pre>
<div style="margin-top:12px;display:flex;gap:10px;align-items:center"><button class="btn primary" @click="copy(snip.text)">⧉ {{copied?'Copied!':'Copy'}}</button><span class="sub" style="font-size:12px;margin:0"><template v-if="myToken">Your token is included - paste and run.</template><template v-else><span class="mono">$TREG_TOKEN</span> is a placeholder - set it to your token.</template></span></div>
</div></div>
</div>
</template>
+75
View File
@@ -0,0 +1,75 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="newTool=false;toolErr=''">
<div class="modal" style="width:min(620px,95vw)"><div class="hd"><b>{{(tForm.id?'Edit ':'Add ')+(tForm.mode==='cli'?'CLI':'endpoint')}}</b><button class="btn sm" @click="newTool=false;toolErr=''" aria-label="Close">✕</button></div>
<div style="padding:16px 18px">
<template v-if="tForm.mode!=='cli'">
<p class="explain">An endpoint = an upstream base URL + one or more credential bindings (how treg injects each key). Need a key? Close this and open <b>⚿ Secrets</b> first.</p>
<div class="frow"><label>Name</label><input v-model="tForm.name" :disabled="!!tForm.id" placeholder="e.g. openai"/></div>
<div class="frow"><label>Base URL</label><input v-model="tForm.base_url" placeholder="https://api.openai.com"/></div>
<div class="lbl" style="margin-top:12px;display:flex;align-items:center">Bindings - how each key is injected <button class="btn sm" @click="addBinding" style="margin-left:auto">+ binding</button></div>
<div class="bindrow" v-for="(b,i) in tForm.bindings" :key="i">
<select v-model="b.secret_id" class="msel">
<option v-if="!secrets.length" :value="null" disabled>- add a secret -</option>
<option v-for="s in secrets" :key="s.id" :value="s.id">{{s.name}}</option>
</select>
<select v-model="b.injector" class="msel"><option>env</option><option>oauth</option></select>
<select v-model="b.location" class="msel"><option value="header">header</option><option value="query">query</option></select>
<input v-model="b.name" placeholder="field (Authorization)" class="bindinput"/>
<input v-model="b.format" placeholder="Bearer {secret}" class="bindinput"/>
<input v-if="b.injector==='oauth'" v-model="b.secret_field" placeholder="token" class="bindinput" style="max-width:80px;min-width:70px"/>
<button v-if="tForm.bindings.length>1" class="btn sm" @click="removeBinding(i)" aria-label="Remove binding">✕</button>
</div>
<p class="sub" style="font-size:11px"><span class="mono">{secret}</span> becomes the credential - e.g. header <span class="mono">Authorization: Bearer {secret}</span>, or query <span class="mono">api_key={secret}</span>. Every binding applies on each call (multi-credential upstreams).</p>
<div class="frow" v-if="projects.length"><label>Project</label>
<select v-model="tForm.project" class="msel">
<option :value="null">— team-wide (everyone) —</option>
<option v-for="p in projects" :key="p.id" :value="p.slug">{{p.name}}</option>
</select></div>
<p v-if="projects.length" class="sub" style="font-size:11px;margin-top:-4px">Optional. Team-wide is the default. A project only narrows things for members you have scoped to it.</p>
</template>
<template v-else>
<p class="explain">Members run <span class="mono">treg run {{tForm.cli.bin||'&lt;cli&gt;'}} -- &lt;args&gt;</span> — treg injects the key into that run's environment only; it never lands on their machine.</p>
<div class="frow"><label>CLI command</label><input v-model="tForm.cli.bin" :disabled="!!tForm.id" placeholder="e.g. gh" style="font-family:var(--mono)"/></div>
<div class="frow"><label>Package</label><input v-model="tForm.cli.package" placeholder="how to install it, e.g. brew install gh (optional)"/></div>
<div class="frow"><label>API base URL</label><input v-model="tForm.base_url" placeholder="https://api.github.com — the provider behind the CLI"/></div>
<div class="lbl" style="margin-top:14px;display:flex;align-items:center">Binding key - injected as an env var, per run <button class="btn sm" @click="tForm.cli.inject.push({via:'env',name:'',secret_id:null})" style="margin-left:auto">+ env var</button></div>
<div class="bindrow" v-for="(inj,i) in tForm.cli.inject" :key="'inj'+i">
<select v-model="inj.secret_id" class="msel">
<option v-if="!secrets.length" :value="null" disabled>- add a secret -</option>
<option v-for="s in secrets" :key="s.id" :value="s.id">{{s.name}}</option>
</select>
<input v-model="inj.name" placeholder="env var, e.g. GH_TOKEN" class="bindinput" style="font-family:var(--mono)"/>
<button v-if="tForm.cli.inject.length>1" class="btn sm" @click="tForm.cli.inject.splice(i,1)" aria-label="Remove env var">✕</button>
</div>
<div class="frow" v-if="projects.length"><label>Project</label>
<select v-model="tForm.project" class="msel">
<option :value="null">— team-wide (everyone) —</option>
<option v-for="p in projects" :key="p.id" :value="p.slug">{{p.name}}</option>
</select>
</div>
<label class="cliopt"><span class="tswitch"><input type="checkbox" v-model="tForm.cli.enabled"/><span class="knob"></span></span><span style="flex:1;min-width:0"><b>Local runs</b><span class="sub" style="display:block;margin-top:2px">{{tForm.cli.enabled?'on — members can treg run '+(tForm.cli.bin||'it')+' with the key injected':'off — every run is refused'}}</span></span></label>
<div class="lbl" style="margin-top:14px;display:flex;align-items:center">Deny list - matching runs are refused before the key is injected <button class="btn sm" @click="tForm.cli.deny.push('')" style="margin-left:auto">+ pattern</button></div>
<p class="sub" style="font-size:11px;margin:4px 0 8px">Each row is a regex, matched against the whole command line AND each argument — block subcommands that execute arbitrary code (<span class="mono">(^|\s)run(\s|$)</span>) or flags that redirect the key (<span class="mono">--publish-url\b</span>).</p>
<div class="bindrow" v-for="(p,i) in tForm.cli.deny" :key="'dn'+i">
<input v-model="tForm.cli.deny[i]" placeholder="regex, e.g. (^|\s)run(\s|$)" class="bindinput" style="font-family:var(--mono)"/>
<button class="btn sm" @click="tForm.cli.deny.splice(i,1)" aria-label="Remove pattern">✕</button>
</div>
<p class="sub" v-if="!tForm.cli.deny.length && !(tForm.cli.deny_defaults && catalogExtra.length)" style="font-size:11px;margin:4px 0 8px;color:var(--warn,#e0a458)">No guardrails — any {{tForm.cli.bin||'CLI'}} invocation gets the key.</p>
<label class="cliopt" v-if="catalogExtra.length"><span class="tswitch"><input type="checkbox" v-model="tForm.cli.deny_defaults"/><span class="knob"></span></span><span style="flex:1;min-width:0"><b>Catalog defaults</b><span class="sub" style="display:block;margin-top:2px">treg's built-in deny patterns for {{tForm.cli.bin}} — shown below, maintained centrally</span></span></label>
<template v-if="tForm.cli.deny_defaults && catalogExtra.length">
<div class="bindrow" v-for="(cp,ci) in catalogExtra" :key="'cd'+ci">
<input :value="cp" disabled class="bindinput" style="font-family:var(--mono);opacity:.55"/>
<span class="chip" title="From the treg catalog — applies to every org's instance of this CLI; maintained centrally">catalog</span>
</div>
</template>
</template>
<div v-if="toolErr" class="banner">{{toolErr}}</div>
<button class="btn primary" style="margin-top:6px" @click="saveTool" :disabled="toolBusy">{{toolBusy?'Saving…':(tForm.id?'Save changes':(tForm.mode==='cli'?'Create CLI':'Create endpoint'))}}</button>
</div></div>
</div>
</template>
@@ -0,0 +1,60 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="newSkill=false">
<div class="modal" style="width:min(680px,95vw)"><div class="hd"><b>Add a skill</b><button class="btn sm ico" @click="newSkill=false" aria-label="Close">✕</button></div>
<div style="padding:16px 18px">
<template v-if="skillMode==='folder'">
<p class="explain">Pick a skill folder (or a folder of skills). treg reads each <span class="mono">SKILL.md</span> and detects whether it's a recipe, a tool, or needs credentials - exactly like <span class="mono">treg upload skills</span> - and shows you before anything is registered.</p>
<label class="skill-drop" v-if="!detected">
<input type="file" webkitdirectory directory multiple @change="onSkillFolder" style="display:none"/>
<div class="sd-ic">📁</div>
<div><b>Choose a skill folder</b><div class="sub" style="margin:2px 0 0">reads SKILL.md, treg.json, .env + .secret files - nothing is registered until you confirm</div><div class="sub" style="margin:4px 0 0">Hidden folder like <span class="mono">.claude</span>? In the picker press <span class="mono">⌘⇧.</span> (Mac) or enable "show hidden files" to reveal it.</div></div>
</label>
<div v-if="skillBusy && !detected" class="sub">Analyzing…</div>
<template v-if="detected">
<div class="sub" style="margin:0 0 8px">{{detected.length}} skill(s) found:</div>
<div class="skill-item" v-for="s in detected" :key="s.name" :class="{off:!skillSel[s.name]}">
<label class="si-head">
<input type="checkbox" v-model="skillSel[s.name]" :disabled="s.already"/>
<b>{{s.name}}</b>
<span class="chip" :class="s.kind==='recipe_only'?'':'oauth'">{{s.kind==='recipe_only'?'recipe':'tool'}}</span>
<span v-if="s.already" class="chip">already registered</span>
</label>
<div class="si-body">
<div class="sub" style="margin:0">{{skillSummary(s)}}</div>
<div v-for="sec in s.secrets" :key="sec.name" class="si-sec">
<span class="mono">{{sec.name}}</span>
<span v-if="sec.present" class="si-ok">✓ from {{sec.source}} {{sec.ref}}</span>
<template v-else>
<span class="si-warn">needs {{sec.source}} {{sec.ref}}</span>
<input v-if="sec.source==='env'" v-model="skillVals[sec.ref]" type="password" :placeholder="'value for '+sec.ref" class="si-input"/>
</template>
</div>
<div v-for="g in s.gaps" :key="g" class="si-warn">⚠ {{g}}</div>
<div v-if="s.cli" class="sub" style="margin:0">{{skillCliNote(s)}}</div>
</div>
</div>
</template>
<div v-if="skillResults" style="margin-top:10px">
<div v-for="r in skillResults" :key="r.name" class="sub" style="margin:2px 0"><span :class="r.ok?'si-ok':'si-warn'">{{r.ok?'✓':'✕'}}</span> {{r.name}}<span v-if="!r.ok"> - {{r.error}}</span></div>
</div>
<div v-if="skillErr" class="banner" style="margin-top:10px">{{skillErr}}</div>
<div style="margin-top:14px;display:flex;gap:10px;align-items:center">
<button v-if="detected" class="btn primary" @click="importSkills" :disabled="skillBusy">{{skillBusy?'Registering…':'Register selected'}}</button>
<button v-if="detected" class="btn" @click="detected=null;skillFiles=[];skillResults=null;skillErr=''">Choose another</button>
<a href="#" class="adv-link" @click.prevent="skillMode='json'">Advanced: paste JSON</a>
</div>
</template>
<template v-else>
<p class="explain">Raw bundle payload (recipe + secrets + tools); bindings reference a secret by its <span class="mono">local_name</span>. <a href="#" class="adv-link" @click.prevent="skillMode='folder'">back to folder import</a></p>
<textarea v-model="skillJson" spellcheck="false" style="width:100%;height:260px;background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:12px;font-family:var(--mono);font-size:12px;resize:vertical"></textarea>
<div v-if="skillErr" class="banner" style="margin-top:10px">{{skillErr}}</div>
<button class="btn primary" style="margin-top:10px" @click="addSkill" :disabled="skillBusy">{{skillBusy?'Registering…':'Register skill'}}</button>
</template>
</div></div>
</div>
</template>
@@ -0,0 +1,20 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="vendorAsk=false">
<div class="modal" style="width:min(620px,95vw)"><div class="hd"><b>List your API in this catalog</b><button class="btn sm ico" @click="vendorAsk=false" aria-label="Close">✕</button></div>
<div style="padding:16px 18px">
<p class="explain">Sell an API? This is an <b>instruction for your coding agent</b>, not for you — paste it into Claude Code, Codex or any coding agent. The agent reads the hosted instructions, prepares the listing files, and opens a pull request on the treg repo — with your contact email so we can arrange live verification.</p>
<div class="lbl">Agent instruction — paste into your coding agent</div>
<pre class="code" style="white-space:pre-wrap">{{vendorPromptText}}</pre>
<div style="margin-top:12px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<button class="btn primary" style="display:inline-flex;align-items:center;gap:7px" @click="copyVendorPrompt">
<img :src="agentIconInv('claudecode-color')" alt="Claude Code" style="width:15px;height:15px" onerror="this.style.display='none'"/><img :src="agentIconInv('codex-color')" alt="Codex" style="width:15px;height:15px" onerror="this.style.display='none'"/>{{vendorCopied?'Copied!':'Copy agent instruction'}}</button>
<a class="sub" style="font-size:12px;margin:0" :href="proxy+'/vendor-listing'" target="_blank" rel="noopener">Read the instructions yourself →</a>
</div>
</div></div>
</div>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="viewRecipe=null">
<div class="modal" style="width:min(760px,95vw)"><div class="hd"><b>{{viewRecipe.name}} <span class="sub" style="font-weight:400">· SKILL.md</span></b><button class="btn sm ico" @click="viewRecipe=null" aria-label="Close">✕</button></div>
<div style="padding:16px 18px">
<textarea v-if="canRegister" v-model="viewRecipe.recipe" spellcheck="false" style="width:100%;height:52vh;background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:12px;font-family:var(--mono);font-size:12.5px;line-height:1.5;resize:vertical"></textarea>
<pre v-else class="code" style="white-space:pre-wrap;word-break:break-word;max-height:62vh">{{viewRecipe.recipe}}</pre>
<div v-if="err" class="banner" style="margin-top:10px">{{err}}</div>
<div style="margin-top:12px;display:flex;gap:10px;align-items:center">
<button v-if="canRegister" class="btn primary" @click="saveRecipe" :disabled="viewRecipe.recipe===viewRecipe.orig">{{recipeSaved?'✓ Saved':'Save changes'}}</button>
<button class="btn" @click="copy(viewRecipe.recipe)">⧉ {{copied?'Copied!':'Copy content'}}</button>
<span v-if="canRegister && viewRecipe.recipe!==viewRecipe.orig" class="si-warn" style="margin:0">unsaved changes</span>
</div>
</div></div>
</div>
</template>
@@ -0,0 +1,36 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="reqAsk=false;reqErr=''">
<div class="modal" style="width:min(520px,95vw)"><div class="hd"><b>Request a tool</b><button class="btn sm ico" @click="reqAsk=false;reqErr=''" aria-label="Close">✕</button></div>
<div style="padding:16px 18px">
<template v-if="!reqDone">
<p class="explain" style="margin-top:0">Missing a provider or capability? Requests go straight to the catalog team — the most-asked-for tools get added first.</p>
<div class="lbl" style="margin-top:14px">What's missing</div>
<div class="field" style="margin:0"><input v-model="reqForm.capability" maxlength="200" placeholder="e.g. Ahrefs backlinks, flight prices, HN comments" @keyup.enter="submitToolRequest"/></div>
<div class="lbl" style="margin-top:14px">Details <span style="text-transform:none;letter-spacing:0;color:var(--muted2)">— optional</span></div>
<div class="field" style="margin:0"><textarea v-model="reqForm.note" maxlength="2000" rows="3" placeholder="What you'd use it for, or a link to the provider's docs" style="resize:vertical;min-width:0"></textarea></div>
<template v-if="!me">
<div class="lbl" style="margin-top:14px">Contact <span style="text-transform:none;letter-spacing:0;color:var(--muted2)">— optional, to hear when it lands</span></div>
<div class="field" style="margin:0"><input v-model="reqForm.contact" maxlength="200" placeholder="you@work.com"/></div>
</template>
<div v-if="reqErr" class="banner" style="margin-top:12px">{{reqErr}}</div>
<div style="display:flex;align-items:center;gap:12px;margin-top:18px">
<button class="btn primary" @click="submitToolRequest" :disabled="reqBusy">{{reqBusy?'Sending…':'Send request'}}</button>
<span class="sub" style="font-size:11.5px;margin:0">Takes ten seconds — no signup needed.</span>
</div>
</template>
<template v-else>
<div style="text-align:center;padding:18px 6px 8px">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="var(--accent, currentColor)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
<p style="margin:12px 0 4px;font-weight:600">Got it — thanks.</p>
<p class="sub" style="margin:0 0 16px">Requests are reviewed as they come in; the most-asked-for tools get added first.</p>
<button class="btn" @click="reqAsk=false">Close</button>
</div>
</template>
</div></div>
</div>
</template>
@@ -0,0 +1,28 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="resPick=null">
<div class="modal" style="padding:16px">
<h3 style="margin:0 0 10px">Choose {{article(resPick.label)}} {{resPick.label}}</h3>
<p class="sub" style="margin:0 0 10px">The {{resPick.label}} your agent uses by default.
It can still use another one per call — this just saves it guessing.</p>
<div v-if="resPick.loading" class="ttable-wrap">
<div style="padding:22px;text-align:center;color:var(--muted);font-family:var(--mono);font-size:12.5px">
<span class="spin" aria-hidden="true"></span> Asking the provider which {{resPick.plural}} you can use…
</div>
</div>
<div v-else-if="resPick.err" class="banner" style="margin:0 0 10px">{{resPick.err}}</div>
<p class="sub" v-else-if="!resPick.rows.length" style="margin:0 0 10px">No {{resPick.plural}} found — this account may not have access to any.</p>
<div class="ttable-wrap" v-else><table class="ttable">
<tr v-for="r in resPick.rows" :key="r.id">
<td class="tn"><b>{{r.label||r.id}}</b><span class="sub" style="display:block;font-size:11px">{{r.id}}</span></td>
<td class="tx"><button class="btn sm" :class="{primary:r.id===resPick.selected}" @click="chooseResource(r)">{{r.id===resPick.selected?'✓ Current':'Use this'}}</button></td>
</tr>
</table></div>
<div style="margin-top:12px;text-align:right"><button class="btn sm" @click="resPick=null">Close</button></div>
</div>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="tryTool=null" style="place-items:stretch;justify-items:end">
<div class="drawer"><div class="hd" style="padding:15px 18px;border-bottom:1px solid var(--line)"><b>Use “{{tryTool.name}}”</b><button class="btn sm" @click="tryTool=null" aria-label="Close">✕</button></div>
<div style="padding:16px 18px;overflow:auto">
<div v-if="canCall(tryTool) && canRun(tryTool)" class="exrow" style="margin-bottom:12px">
<span class="exchip" :class="{on:useMode==='call'}" @click="useMode='call'"><span class="m">HTTP</span>API call</span>
<span class="exchip" :class="{on:useMode==='run'}" @click="useMode='run'"><span class="m">CLI</span>run on server</span>
</div>
<template v-if="useMode==='call'">
<p class="explain">Enter the API path for <span class="mono">{{tryTool.host}}</span> (everything after the domain) and Send - it runs through the proxy with the key injected. You never send the key.</p>
<div v-if="tryTool.examples && tryTool.examples.length">
<div class="lbl">Examples - click to fill</div>
<div class="exrow"><span v-for="(ex,exi) in tryTool.examples" :key="exi" class="exchip" :class="{on:tryPath===ex.path}" @click="tryPath=ex.path; tryMethod=ex.method||'GET'"><span class="m">{{ex.method||'GET'}}</span>{{ex.note||ex.path}}</span></div>
</div>
<div class="field"><select v-model="tryMethod"><option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option></select><input v-model="tryPath" placeholder="path e.g. me" @keyup.enter="runTry"/></div>
<textarea v-if="tryMethod!=='GET'" v-model="tryBody" placeholder='request body (e.g. {"key":"value"})' rows="3" style="width:100%;margin-top:8px;background:var(--bg);border:1px solid var(--line);color:var(--ink);border-radius:var(--rb);padding:8px;font-family:var(--mono);font-size:12.5px"></textarea>
<div class="muted" style="font-size:12px;margin:6px 0 12px">→ {{tryMethod}} {{tryTool.base_url}}/{{tryPath}}</div>
<button class="btn primary" @click="runTry" :disabled="trying">{{trying?'Sending…':'▶ Send'}}</button>
<div v-if="tryResp!==null" style="margin-top:14px"><div class="muted" style="font-size:12px;margin-bottom:6px">Response <span class="badge" :class="(tryStatus>0&&tryStatus<400)?'ok':'invalid'">{{tryStatus||'ERR'}}</span> · {{tryMs}}ms · key injected by registry</div><pre>{{tryResp}}</pre></div>
</template>
<template v-if="useMode==='run'">
<p class="explain">Runs <span class="mono">{{(tryTool.cli&&tryTool.cli.bin)||tryTool.name}}</span> on the registry server with the credential injected there - the key never reaches your machine. Same as <span class="mono">treg run --server {{tryTool.name}}</span>.</p>
<div class="field"><input v-model="runArgsStr" :placeholder="'CLI arguments, e.g. get /v1/balance'" @keyup.enter="doRun" style="width:100%"/></div>
<div class="muted" style="font-size:12px;margin:6px 0 12px">→ {{(tryTool.cli&&tryTool.cli.bin)||tryTool.name}} {{runArgsStr}}</div>
<button class="btn primary" @click="doRun" :disabled="running">{{running?'Running…':'❯ Run'}}</button>
<div v-if="runOut" style="margin-top:14px">
<div class="muted" style="font-size:12px;margin-bottom:6px">Result <span class="badge" :class="runOut.exit_code===0?'ok':'invalid'">exit {{runOut.exit_code}}</span> · {{runOut.duration_ms}}ms<span v-if="runOut.timed_out"> · timed out</span> · key injected server-side</div>
<pre v-if="runOut.stdout">{{runOut.stdout}}</pre>
<pre v-if="runOut.stderr" style="opacity:.75">{{runOut.stderr}}</pre>
<pre v-if="runOut.detail" style="opacity:.75">{{runOut.detail}}</pre>
</div>
</template>
</div></div>
</div>
</template>
+41
View File
@@ -0,0 +1,41 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="share.on=false">
<div class="modal" style="width:min(560px,95vw)"><div class="hd"><b>Share “{{detail.name}}”</b><button class="btn sm ico" @click="share.on=false" aria-label="Close">✕</button></div>
<div style="padding:16px 18px">
<template v-if="share.sent">
<p class="explain">✓ Invite sent to <b>{{share.sent.email}}</b> — the email's button signs them in and lands them right on this page.</p>
<p class="sub" style="margin:0 0 8px">Or DM them this link — it opens the sign-in with their email prefilled; once they sign in, the invite accepts itself and this page opens:</p>
<pre class="code" style="white-space:pre-wrap;word-break:break-all">{{shareInviteUrl}}</pre>
<div style="margin-top:12px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<button class="btn primary" @click="copyDetail(shareInviteUrl,'sharecopy')">⧉ {{detailCopied==='sharecopy'?'Copied!':'Copy invite link'}}</button>
<span class="sub" style="margin:0;font-size:12px">backup code: <span class="mono">{{share.sent.code}}</span> (<span class="mono">treg org join</span>)</span>
</div>
</template>
<template v-else>
<p class="explain">Already on the team? Just send them the page link (⧉ Copy link). This invites someone <b>new</b> — after one click in the email they land on this exact page.</p>
<div v-if="share.member" class="tut-notice" style="margin:0 0 10px"><b>{{share.member.email}}</b> is already a member — just send them the link, no invite needed.</div>
<div class="field" style="margin-bottom:10px"><input v-model="share.email" type="email" placeholder="teammate@company.com" @keyup.enter="sendShare"/></div>
<div style="margin-bottom:10px">
<label style="display:flex;gap:6px;align-items:center;font-size:12.5px">role
<select v-model="share.role" style="background:var(--panel2);border:1px solid var(--line);color:var(--ink);border-radius:6px;padding:4px 8px;font-family:var(--mono)">
<option value="viewer">viewer (use, not edit)</option><option value="member">member</option><option v-if="isOwner" value="admin">admin</option>
</select>
</label>
</div>
<div style="margin-bottom:10px">
<label style="display:flex;gap:8px;align-items:center;font-size:12.5px;cursor:pointer">
<input type="checkbox" v-model="share.full"/> Share full access
</label>
</div>
<p class="sub" style="margin:0 0 12px;font-size:12px">Full access = they see and can use everything the team has registered. Uncheck to scope them to just {{detail.kind==='skill'?'this skill':'this tool'}} — they'll only see and call what you're sharing. Local CLI runs stay off either way.</p>
<div v-if="share.err" class="banner" style="margin:0 0 10px">{{share.err}}</div>
<button class="btn primary" @click="sendShare" :disabled="share.busy">{{share.busy?'Sending…':'Send invite'}}</button>
</template>
</div></div>
</div>
</template>
+73
View File
@@ -0,0 +1,73 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="topupOpen=false">
<div class="modal" style="padding:18px 20px;width:min(620px,94vw)">
<div style="display:flex;justify-content:space-between;align-items:center">
<h3 style="margin:0">Top up credits</h3>
<button class="btn sm" @click="topupOpen=false" aria-label="Close">✕</button>
</div>
<p class="sub" style="margin:6px 0 14px">Choose an amount. Bigger top-ups earn bonus credit; you can always add more later.</p>
<div class="fundgrid" style="max-width:none;grid-template-columns:repeat(5,minmax(80px,1fr))">
<button v-for="p in billing.topup.presets" :key="p" class="fundcard" :class="{sel:topupPick===p}" :disabled="billingBusy" @click="topupPick=p">
<b>${{p}}</b>
<!-- The bonus is named ON the qualifying buttons: the amount is chosen here, and a
preset that quietly forfeits a bonus is the whole failure this is meant to prevent.
The referral bonus (refPresetBonus) stacks on the tier bonus. -->
<span v-if="tierBonus(p)" style="color:var(--green)">+{{money(tierBonus(p))}} bonus</span>
<span v-else-if="refPresetBonus(p)" style="color:var(--green)">+{{money(refPresetBonus(p))}} bonus</span>
</button>
<button class="fundcard" :class="{sel:topupPick==='other'}" :disabled="billingBusy" @click="pickOther()">
<b>Other</b>
</button>
</div>
<div v-if="topupPick==='other'" style="margin-top:12px">
<div style="display:flex;align-items:center;border:1px solid var(--line);border-radius:var(--rb);background:var(--bg);padding:0 14px">
<span style="font-family:var(--mono);font-size:22px;color:var(--muted)">$</span>
<input type="number" :min="billing.topup.min_usd" :max="billing.topup.max_usd" step="1" v-model.number="topupOther" placeholder="0" autocomplete="off" data-lpignore="true"
style="flex:1;border:0;background:transparent;color:var(--ink);font-family:var(--mono);font-size:22px;font-weight:700;padding:12px 8px;outline:none;min-width:0"/>
</div>
<p class="sub" style="margin:6px 0 0">Whole dollars, ${{billing.topup.min_usd}}–${{billing.topup.max_usd.toLocaleString()}}.</p>
</div>
<!-- Auto top-up. Read-only line when the team already has a mandate; otherwise the toggle. -->
<div style="margin-top:14px;border:1px solid var(--line);border-radius:var(--rb);padding:10px 12px;background:var(--panel2)">
<template v-if="billing.autotopup.enabled||billing.autotopup.consented_at">
<b style="font-size:12.5px">Auto top-up is {{billing.autotopup.enabled?'on':'set up'}}.</b>
<span class="sub" style="margin:0"> Manage or turn it off from the billing page.</span>
</template>
<template v-else>
<label style="display:flex;gap:10px;align-items:flex-start;cursor:pointer">
<span class="tswitch" style="margin-top:2px"><input type="checkbox" v-model="topupAuto"/><span class="knob"></span></span>
<span>
<b style="font-size:12.5px">Auto top-up {{topupAuto?'on':'off'}}</b>
<!-- The mandate. Not decoration: an off-session charge with no recorded agreement to
THESE numbers is an unauthorized charge under PSD2/SCA. The numbers are the
server defaults ($20 when below $5), shown here in full; the billing page's
Edit panel changes them. Not the top-up amount: a $200 buyer does not want
$200 refills. -->
<span class="sub" style="display:block;margin:2px 0 0">
<template v-if="topupAuto">I authorize treg to charge my saved card <b>${{autoAmount}}</b> automatically whenever my balance drops below <b>${{autoThreshold}}</b>. Cancel any time from the billing page.</template>
<template v-else>Calls fail with a 402 once the balance runs out. Turn this on to keep agents running without watching it.</template>
</span>
</span>
</label>
</template>
</div>
<div style="margin-top:14px;border:1px solid var(--line);border-radius:var(--rb);padding:10px 12px;font-family:var(--mono);font-size:12.5px">
<div style="display:flex;justify-content:space-between"><span>Credit added</span><span v-if="!topupUsd">—</span><span v-else><b>{{money(topupUsd*1e6+topupBonusMicro)}}</b><span v-if="topupBonusMicro" class="sub" style="margin:0"> ({{money(topupUsd*1e6)}} + <span style="color:var(--green)">{{money(topupBonusMicro)}} bonus</span>)</span></span></div>
<div style="display:flex;justify-content:space-between;border-top:1px solid var(--line);margin-top:8px;padding-top:8px;font-weight:700"><span>Total due</span><span>{{topupUsd?money(topupUsd*1e6):'—'}}</span></div>
</div>
<p v-if="topupErr" class="sub" style="color:var(--red);margin:10px 0 0">{{topupErr}}</p>
<p class="sub" style="margin:10px 0 0">You'll pay on Stripe's secure page. The balance updates the moment the payment lands.</p>
<div style="margin-top:14px;display:flex;justify-content:flex-end;gap:8px">
<button class="btn" @click="topupOpen=false" :disabled="billingBusy">Cancel</button>
<button class="btn primary" :disabled="billingBusy||!topupValid" @click="payTopup()">{{billingBusy?'Opening Stripe…':(topupUsd?'Pay $'+topupUsd+' now':'Pay now')}}</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,94 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" @click.self="epTry=null" style="place-items:stretch;justify-items:end">
<div class="drawer"><div class="hd" style="padding:15px 18px;border-bottom:1px solid var(--line)"><b>Try “{{epTry.id}}”</b><button class="btn sm" @click="epTry=null" aria-label="Close">✕</button></div>
<div class="bd" style="padding:16px 18px;overflow:auto">
<p class="explain"><span class="mono">{{epTry.method||'GET'}} {{epTryDisplayPath}}</span><br>{{epTry.summary}}</p>
<p class="sub" v-if="epTryAccess" style="margin:0 0 12px">
<template v-if="epTryAccess.tier==='anonymous'">◎ {{epTryAccess.detail||'No provider key needed — the verified public upstream route is free.'}}</template>
<template v-else-if="epTryAccess.tier==='platform'">⚡ {{epTryAccess.detail||'No key needed — served on treg\'s key, billed to the team balance.'}}</template>
<template v-else-if="epTryAccess.tier==='tool'||epTryAccess.tier==='credential'">🔑 Served with your team's own {{epTry.provider_display||epTry.provider}} credential — billed by the provider, not the team balance.</template>
<template v-else>{{epTryAccess.detail||'Not callable from this team yet.'}}</template>
</p>
<div v-if="epTryShowAuthSelector" class="field auth-method-field" style="max-width:520px;margin-bottom:16px">
<label>Authorization</label>
<select v-model="epTryAuthMethod" @change="loadEpTryAccess">
<option v-for="m in epTryAuthMethods" :key="m" :value="m">{{authorizationMethodLabel(epTry.provider,m)}}</option>
</select>
</div>
<div class="seg" style="margin-bottom:16px">
<button :class="{on:epTryTab==='agent'}" @click="epTryTab='agent'">AI Agent</button>
<button :class="{on:epTryTab==='cli'}" @click="epTryTab='cli'">CLI</button>
<button :class="{on:epTryTab==='api'}" @click="epTryTab='api'">API</button>
<button :class="{on:epTryTab==='manual'}" @click="epTryTab='manual'">Manual</button>
</div>
<!-- AI AGENT — one-line setup (token embedded here only), then the prompt to run this endpoint -->
<template v-if="epTryTab==='agent'">
<div class="lbl">1 · Set up with your agent</div>
<p class="sub" style="margin:4px 0 8px">One line — the agent reads llms.txt, installs the CLI and signs in as you. Token &amp; team are baked in.</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart(epTrySetupLine,'ep-setup')">{{startCopied==='ep-setup'?'✓ copied':'copy'}}</button><pre style="white-space:pre-wrap;word-break:break-all">{{epTrySetupLine}}</pre></div>
<div class="lbl" style="margin-top:20px">2 · Ask your agent to use this</div>
<p class="sub" style="margin:4px 0 8px">Paste this next — the agent calls the endpoint through treg, no key on its machine.</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart(epTryAgentUse,'ep-use')">{{startCopied==='ep-use'?'✓ copied':'copy'}}</button><pre style="white-space:pre-wrap">{{epTryAgentUse}}</pre></div>
</template>
<!-- CLI — install, sign in, inspect, run this exact endpoint -->
<template v-else-if="epTryTab==='cli'">
<div class="lbl">1 · Install the CLI &amp; sign in</div>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('curl -fsSL '+proxy+'/install.sh | sh\ntreg login','ep-cli1')">{{startCopied==='ep-cli1'?'✓ copied':'copy'}}</button><pre>curl -fsSL {{proxy}}/install.sh | sh
treg login</pre></div>
<div class="lbl" style="margin-top:18px">2 · See its params &amp; price</div>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('treg catalog get '+epTry.id,'ep-cli2')">{{startCopied==='ep-cli2'?'✓ copied':'copy'}}</button><pre>treg catalog get {{epTry.id}}</pre></div>
<div class="lbl" style="margin-top:18px">3 · Call it — {{epTryAccess&&epTryAccess.tier==='anonymous'?'no provider key is needed':'the key is injected server-side'}}</div>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart(epTryCliCall,'ep-cli3')">{{startCopied==='ep-cli3'?'✓ copied':'copy'}}</button><pre style="white-space:pre-wrap;word-break:break-all">{{epTryCliCall}}</pre></div>
</template>
<!-- API — the raw /call/ passthrough with the token header -->
<template v-else-if="epTryTab==='api'">
<p class="sub" style="margin:0 0 8px">One endpoint, one token. {{epTryAccess&&epTryAccess.tier==='anonymous'?'This verified public upstream route needs no provider key.':'treg injects the credential server-side.'}} treg relays the response verbatim.</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart(epTryCurl,'ep-api')">{{startCopied==='ep-api'?'✓ copied':'copy'}}</button><pre style="white-space:pre-wrap;word-break:break-all">{{epTryCurl}}</pre></div>
<p class="sub" v-if="!myToken" style="margin:8px 0 0;font-size:12px"><span class="mono">$TREG_TOKEN</span> is a placeholder — set it to your API token (copy it on Getting started).</p>
</template>
<!-- MANUAL — the live test form -->
<template v-else>
<!-- Not callable from here (no key connected / no treg price): don't strand the user on a
dead Run button — point them at the tab that DOES work. -->
<div v-if="epTryAccess && epTryAccess.tier!=='anonymous' && epTryAccess.tier!=='platform' && epTryAccess.tier!=='tool' && epTryAccess.tier!=='credential'"
class="banner" style="margin:0">
{{epTryAccess.missing_message || (mkOauth(epTry.provider) ? 'Can\'t run this here yet — connect '+(epTry.provider_display||epTry.provider)+' first, then Run.' : 'This endpoint needs a key. Use the AI Agent / CLI / API tab, or bring your own.')}}
<div style="margin-top:10px">
<button v-if="mkOauth(epTry.provider)" class="btn sm primary" @click="openProvider(epTry.provider); epTry=null">{{epTryAccess.action_label||endpointConnectLabel(epTry)}}</button>
<button v-else-if="mkKnown(epTry.provider)" class="btn sm primary" @click="goByok(epTry.provider)">🔑 Bring your own key</button>
</div>
</div>
<template v-else>
<div class="field" v-for="p in epTryVisibleParams" :key="p.name" style="max-width:520px">
<label class="mono" style="min-width:140px">{{p.name}}<span v-if="p.required" style="color:var(--accent)"> *</span></label>
<input v-model="p.value" :placeholder="p.required?'required':'optional'"/>
</div>
<p v-if="!epTryVisibleParams.length && (epTry.method||'GET')==='GET'" class="sub" style="margin:0 0 12px">No parameters — just run it.</p>
<div v-if="(epTry.method||'GET')!=='GET' && epTryBody!==''" class="field" style="max-width:520px;align-items:flex-start">
<label style="min-width:140px">Body (JSON)</label>
<textarea v-model="epTryBody" rows="6" style="width:100%;font-family:var(--mono);font-size:12px"></textarea>
</div>
<button class="btn primary" :disabled="epTryBusy" @click="runEpTry">{{epTryBusy?'Running…':'❯ Run'}}</button>
</template>
<div v-if="epTryResp" style="margin-top:14px">
<div class="muted" style="font-size:12px;margin-bottom:6px">Result
<span class="badge" :class="epTryStatus>=200&&epTryStatus<300?'ok':'invalid'">{{epTryStatus}}</span>
· {{epTryMs}}ms<span v-if="epTryCost!=null"> · charged {{money(epTryCost)}}<span v-if="epTryCost===0 && epTryStatus>=200 && epTryStatus<300" :title="epTryAccess&&epTryAccess.tier==='anonymous'?'This verified public upstream route used no provider key and did not charge the team balance.':'The provider reported charging nothing for this call — usually its own cache serving a repeat lookup (look for cached: true in the response). You are billed exactly what the provider billed treg.'"> {{epTryAccess&&epTryAccess.tier==='anonymous'?'(free — no provider key ⓘ)':'(free — provider charged 0 ⓘ)'}}</span></span>
· logged in Activity like any call</div>
<pre style="max-height:340px;overflow:auto">{{epTryResp}}</pre>
</div>
</template>
</div></div>
</div>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="scrim" role="dialog" aria-modal="true" >
<div class="modal" :style="{width: welcome.step===0?'min(470px,94vw)':(welcome.step===3?'min(680px,94vw)':'min(560px,94vw)')}">
<div style="padding:26px 26px 22px">
<template v-if="welcome.step===0">
<div style="color:var(--accent);font-size:15px;letter-spacing:.5px;margin-bottom:12px">▚ treg</div>
<h2 style="margin:0 0 8px;font-size:20px">Welcome{{me?', '+me.split('@')[0]:''}} 👋</h2>
<p class="sub" style="margin:0 0 18px">Create your team — it's where you keep API keys and skills so your teammates and their agents can call them <b>without holding the keys</b>. You can invite people and add secrets right after.</p>
<div class="field"><input v-model="welcome.name" placeholder="Team name, e.g. Superdesign" @keyup.enter="welcomeCreate"/></div>
<button class="btn primary" style="width:100%;margin-top:4px" @click="welcomeCreate" :disabled="welcome.busy">{{welcome.busy?'Creating…':'Create team →'}}</button>
</template>
<template v-else-if="welcome.step===1">
<div style="color:var(--accent);font-size:15px;letter-spacing:.5px;margin-bottom:12px">▚ treg</div>
<h2 style="margin:0 0 8px;font-size:20px">Which agent are you using?</h2>
<p class="sub" style="margin:0 0 18px">Choose your agent for the best setup instructions.</p>
<treg-agent-picker v-model="welcome.agent" :icon="agentIcon"></treg-agent-picker>
<div class="wc-foot">
<a href="#" class="sub" @click.prevent="welcomeFinish">Skip</a>
<button class="btn primary" @click="track('onboarding_agent_picked',{agent:welcome.agent}); welcome.step=2">Next →</button>
</div>
</template>
<template v-else-if="welcome.step===2">
<treg-setup-instructions :agent="welcomeAgent" :icon="agentIcon" :command="welcomeSetupCmd" :team="activeSlugNow || '<team-slug>'" :token="myToken" :show-token="startTokenShow" :copied="startCopied==='wc'" @copy="copyStart($event,'wc')" @toggle-token="startTokenShow=!startTokenShow" @plugin="track('onboarding_plugin_install_clicked',{agent:welcome.agent})"></treg-setup-instructions>
<div class="wc-foot">
<a href="#" class="sub" @click.prevent="welcome.step=1">← Back</a>
<button class="btn primary" @click="welcome.step=3">Next →</button>
</div>
</template>
<template v-else>
<treg-try-it-out :copied="startCopied.startsWith('wtry-')?startCopied.slice(5):''" @example="track('tryit_prompt_copied',{key:$event.k,cat:$event.cat,from:'onboarding'}); copyStart($event.prompt,'wtry-'+$event.k)" @provider="welcomeTryProvider"></treg-try-it-out>
<div class="wc-foot">
<a href="#" class="sub" @click.prevent="welcomeFinish">Skip</a>
<button class="btn primary" @click="track('tryit_browse_catalog',{from:'onboarding'}); welcome.on=false; go('connections')">Browse all catalog →</button>
</div>
</template>
<div v-if="welcome.err" class="banner" style="margin-top:12px">{{welcome.err}}</div>
</div>
</div>
</div>
</template>
+11
View File
@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles/base.css'
import '../../src/treg/web/media/redesign/dashboard.css'
const app = createApp(App)
const setup = (window as unknown as { TregAgentSetup: Record<string, object> }).TregAgentSetup
app.component('TregTryItOut', setup.TryItOut!)
app.component('TregAgentPicker', setup.AgentPicker!)
app.component('TregSetupInstructions', setup.SetupInstructions!)
app.mount('#app')
+98
View File
@@ -0,0 +1,98 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<h1>Activity - {{activeName}}</h1>
<!-- One page, two readings of the same traffic: the RAW FEED (every call, newest first)
and the ROLLUPS (who/what/when, and spend per caller tag). They were separate sidebar
entries, which made you leave one to answer a question about the other. -->
<div class="tabs" style="margin:10px 0 4px">
<button :class="{active:actTab==='feed'}" @click="actTab='feed'">Activity</button>
<button v-if="canAdmin" :class="{active:actTab==='usage'}" @click="actTab='usage'; loadUsage()">Usage</button>
<button v-if="actTab==='feed' && activityRows.length" class="act-toggle" @click="actOkOnly=!actOkOnly" :title="actOkOnly?'Include failed and refused calls':'Hide failed and refused calls'">{{actOkOnly?'Show all ('+activityRows.length+')':'Successes only'}}</button>
</div>
<template v-if="actTab==='feed'">
<p class="sub">{{actOkOnly?'Successful calls and runs in this org.':'Every proxy call and server CLI run in this org.'}}</p>
<p v-if="activityCachedCount" class="sub">{{activityCachedCount}} of {{activityCallCount}} loaded {{activityCallCount===1?'call was':'calls were'}} served from the archive.</p>
<div class="field" style="max-width:460px;margin-bottom:10px"><select class="msel" v-model="activityKey" @change="loadCalls"><option value="">All API keys</option><option v-for="k in apiKeys" :key="k.id" :value="String(k.id)">{{k.identity}} — {{k.name}}</option></select></div>
<table><tr><th>When</th><th>Who</th><th>Key</th><th v-if="anyTagged">Tagged</th><th>Tool</th><th>Action</th><th>Status</th><th style="text-align:right">Cost</th></tr>
<tr v-for="a in activityShown" :key="a.kind+'-'+a.id" :class="{'act-row':a.kind==='call'}" @click="a.kind==='call'&&openCall(a)" :title="a.kind==='call'?(a.has_result?'Show request and response':'Show call details'):''"><td class="muted">{{when(a.created_at)}}</td><td>{{activityWho(a)}}<span v-if="activityOwner(a)" class="chip" style="margin-left:6px" :title="'Agent owner: '+activityAgentKey(a).created_by">owner: {{activityOwner(a)}}</span><span v-if="a.client && a.client!=='cli'" class="chip" style="margin-left:6px" :title="'reported by the runtime — attribution, not authentication'">via {{a.client}}</span></td><td><span v-if="a.api_key_name" class="chip">{{a.api_key_name}}<span v-if="a.api_key_prefix" class="muted mono"> · {{a.api_key_prefix}}</span></span><span v-else class="muted">—</span></td><td v-if="anyTagged"><template v-if="a.tags"><span v-for="(v,k) in a.tags" :key="k" class="chip" style="margin-right:4px" :title="'X-Treg-Meta '+k+'='+v">{{k}}={{v}}</span></template><span v-else class="muted">—</span></td><td>{{a.tool}}</td><td><span v-if="a.kind==='run'" class="chip" style="margin-right:6px" :title="a.where==='local'?'ran on this member\'s machine':'ran on the registry server'">{{a.where||'run'}}</span>{{a.action}}<!-- A generation task's artifact, once it succeeded: the provider's time-limited URL, or the CLI command that retrieves it (treg never downloads media). --><template v-if="a.task"><a v-if="a.task.result_url" :href="a.task.result_url" target="_blank" rel="noopener" style="margin-left:8px" :title="taskArtifactTitle(a.task)" @click.stop>result ↗</a><span v-else-if="a.task.fetch_command" class="chip" style="margin-left:8px" :title="'retrieve it from the CLI: '+a.task.fetch_command">result via CLI</span><span v-if="a.task.result_url||a.task.fetch_command" class="muted" style="margin-left:6px;font-size:.85em">{{a.task.ttl_note?'expires in '+a.task.ttl_note:'time-limited link'}}</span></template></td><td><span class="badge" :class="a.ok?'ok':'invalid'">{{a.status}}</span><span v-if="a.task" class="chip" style="margin-left:6px" :title="taskStateTitle(a.task)">{{taskStateLabel(a.task)}}</span><span v-if="a.has_result" class="act-view">result ›</span></td><td style="text-align:right;white-space:nowrap" class="muted" :title="a.held?'reserved - settles when the task finishes, refunded if it fails':(a.tier==='platform'?'charged to team balance':(a.cost!=null?'estimated — billed to your own provider key':''))"><span v-if="a.held" style="font-size:.85em">hold </span>{{a.cost!=null?money(a.cost):'—'}}<span v-if="a.cached" class="chip cached" style="margin-left:6px" title="Served from treg's archive instead of calling the provider.">Cached</span></td></tr></table>
<p v-if="!loading && !activityRows.length" class="sub">No activity yet.</p>
<p v-else-if="!loading && !activityShown.length" class="sub">No successful calls yet — <a href="#" @click.prevent="actOkOnly=false">show all {{activityRows.length}}</a>.</p>
</template>
<template v-if="canAdmin && actTab==='usage'">
<div class="tut-head" style="align-items:flex-end">
<div><p class="sub" style="margin:0">Calls &amp; runs per member over the last {{usageDays}} days. Counts only - no request or response content is stored.</p></div>
<select class="msel" :value="usageDays" @change="usageDays=+$event.target.value; loadUsage()"><option :value="7">7 days</option><option :value="30">30 days</option><option :value="90">90 days</option></select>
</div>
<div v-if="usage" style="margin-top:16px">
<div class="statgrid">
<div class="stat"><div class="n">{{usage.totals.total}}</div><div class="l">total events</div></div>
<div class="stat"><div class="n">{{usage.totals.call}}</div><div class="l">API calls</div></div>
<div class="stat"><div class="n">{{usage.totals.local_run}}</div><div class="l">local runs</div></div>
<div class="stat"><div class="n">{{usage.totals.server_run}}</div><div class="l">server runs</div></div>
</div>
<div class="lbl" style="margin-top:20px">By member</div>
<table v-if="usage.by_user.length">
<tr><th>Member</th><th style="text-align:right">API</th><th style="text-align:right">Local</th><th style="text-align:right">Server</th><th style="text-align:right">Total</th></tr>
<tr v-for="u in usage.by_user" :key="u.user_email">
<td>{{short(u.user_email)}}</td><td style="text-align:right" class="muted">{{u.call}}</td>
<td style="text-align:right" class="muted">{{u.local_run}}</td><td style="text-align:right" class="muted">{{u.server_run}}</td>
<td style="text-align:right"><b>{{u.total}}</b></td>
</tr>
</table>
<p v-else class="sub">No usage in this window.</p>
<div v-if="usage.by_tool.length" class="lbl" style="margin-top:20px">Top tools</div>
<table v-if="usage.by_tool.length">
<tr><th>Tool</th><th style="text-align:right">Events</th></tr>
<tr v-for="t in usage.by_tool" :key="t.name"><td>{{t.name}}</td><td style="text-align:right">{{t.total}}</td></tr>
</table>
<div v-if="usage.by_day.length" class="lbl" style="margin-top:20px">Per day</div>
<table v-if="usage.by_day.length">
<tr><th>Day</th><th style="text-align:right">Events</th></tr>
<tr v-for="d in usage.by_day" :key="d.day"><td class="muted">{{d.day}}</td><td style="text-align:right">{{d.total}}</td></tr>
</table>
<!-- SPEND BY CALLER TAG — one section per key the team has ACTUALLY SENT, not a picker.
A dropdown hides the other dimensions behind an interaction, and the whole point of
tagging by both customer and workspace is seeing them side by side. The list comes from
/tag-keys (observed), so a team that never tags sees nothing here at all.
Money here comes from the LEDGER, unlike the per-member/tool/day counts below, which
come from the audit table and are allowed to lose rows. This is what a reselling team
invoices from, so it has to be complete. -->
<template v-for="key in tagKeys" :key="key">
<div v-if="tagUsage[key]" style="margin-top:20px">
<div class="lbl" style="margin:0">By {{key}} <span class="chip" style="margin-left:6px" title="a caller tag from X-Treg-Meta">X-Treg-Meta</span></div>
<p class="sub" style="margin:4px 0 8px;font-size:12px">
What each <b>{{key}}</b> spent, from the ledger. A call is credited in full to every
tag it carries, so these sections are different slices of the same money — never add
two of them together.
</p>
<table v-if="tagUsage[key].rows.length">
<tr><th>{{key}}</th><th style="text-align:right">Calls</th><th style="text-align:right">Spend</th></tr>
<tr v-for="r in tagUsage[key].rows" :key="r.value">
<td><span class="chip">{{r.value}}</span></td>
<td style="text-align:right" class="muted">{{r.calls}}</td>
<td style="text-align:right"><b>{{money(r.charged_micro)}}</b></td>
</tr>
<tr v-if="tagUsage[key].unattributed_micro">
<td class="muted" title="calls carrying no value for this tag — shown, never dropped">untagged</td>
<td style="text-align:right" class="muted">—</td>
<td style="text-align:right" class="muted">{{money(tagUsage[key].unattributed_micro)}}</td>
</tr>
</table>
<p v-else class="sub">Nothing tagged <b>{{key}}</b> in this window.</p>
</div>
</template>
</div>
<p v-else class="sub">Loading…</p>
</template>
</template>
+47
View File
@@ -0,0 +1,47 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<h1>Platform admin</h1>
<p class="sub">Cross-tenant control (super-admin). Suspend locks members out; delete cascades.</p>
<div class="statgrid" v-if="adminStats"><div class="stat"><div class="n">{{adminStats.totals.users}}</div><div class="l">users</div></div><div class="stat"><div class="n">{{adminStats.totals.orgs}}</div><div class="l">orgs</div></div><div class="stat"><div class="n">{{adminStats.totals.tools}}</div><div class="l">tools</div></div><div class="stat"><div class="n">{{adminStats.totals.calls}}</div><div class="l">calls</div></div><div class="stat"><div class="n">{{adminStats.calls.success_rate==null?'-':Math.round(adminStats.calls.success_rate*100)+'%'}}</div><div class="l">success rate</div></div></div>
<div class="grp" style="margin:0 0 8px">All organizations</div>
<table v-if="adminOrgs.length">
<tr><th>Org</th><th>Members</th><th>Tools</th><th>Status</th><th></th></tr>
<tr v-for="o in adminOrgs" :key="o.id">
<td><b>{{o.name}}</b></td><td>{{o.members}}</td><td>{{o.tools}}</td>
<td><span class="badge" :class="o.suspended?'invalid':'ok'">{{o.suspended?'suspended':'active'}}</span></td>
<td style="text-align:right;white-space:nowrap">
<button class="btn sm" @click="admSuspendOrg(o)" :disabled="adminBusy">{{o.suspended?'Unsuspend':'Suspend'}}</button>
<button class="btn sm" :class="{danger:confirmAdmOrg===o.id}" @click="admDeleteOrg(o)" style="margin-left:6px">{{confirmAdmOrg===o.id?'Confirm':'Delete'}}</button>
</td>
</tr>
</table>
<div class="grp" style="margin:22px 0 8px">All users</div>
<table v-if="adminUsers.length">
<tr><th>Email</th><th>Orgs</th><th>Flags</th><th></th></tr>
<tr v-for="u in adminUsers" :key="u.id">
<td>{{u.email}}<span v-if="u.email===me" class="chip" style="margin-left:6px">you</span></td>
<td class="muted">{{u.orgs.length}}</td>
<td>
<span v-if="u.is_superadmin" class="badge ok">super-admin</span>
<span v-if="u.suspended" class="badge invalid" style="margin-left:4px">suspended</span>
<span v-if="!u.is_superadmin && !u.suspended" class="muted">-</span>
</td>
<td style="text-align:right;white-space:nowrap">
<template v-if="u.email!==me">
<button class="btn sm" @click="admGrant(u)" :disabled="adminBusy">{{u.is_superadmin?'Revoke admin':'Make admin'}}</button>
<button class="btn sm" @click="admSuspendUser(u)" :disabled="adminBusy" style="margin-left:6px">{{u.suspended?'Unsuspend':'Suspend'}}</button>
<button class="btn sm" :class="{danger:confirmAdmUser===u.id}" @click="admDeleteUser(u)" style="margin-left:6px">{{confirmAdmUser===u.id?'Confirm':'Delete'}}</button>
</template>
<span v-else class="muted">- you -</span>
</td>
</tr>
</table>
</template>
+167
View File
@@ -0,0 +1,167 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="tut-head">
<div><h1>2,800+ tools for agents</h1><p class="sub" style="margin:0">Connect an account once. treg holds the credential server-side and injects it on every call — nothing lands on your machine.</p></div>
<!-- (The balance pill lives in the side nav, above the account block.) -->
<div class="tut-actions">
<button class="btn sm" @click="openToolRequest()" title="Missing a tool or provider? Tell us — requests steer what gets added next"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><line x1="12" y1="7" x2="12" y2="13"/><line x1="9" y1="10" x2="15" y2="10"/></svg>Request a tool</button>
<button class="btn sm" @click="vendorAsk=true" title="Sell an API? Get it listed in this catalog"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.83z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>List as vendor</button>
<!-- The one header action a paying visitor is actually looking for: where do I put MY
key. It only switches tabs, but naming it makes the Platform tab findable. -->
<button class="btn sm primary" @click="publicCatalog ? openSignin() : goByok()" title="Register your own provider key — your key wins over treg's and those calls are never metered"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777Zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>Bring your own key</button>
</div>
</div>
<div v-if="connErr" class="banner" style="margin-top:12px">{{connErr}}</div>
<div v-for="c in needSecondCred" :key="'n'+c.id" class="banner" style="margin-top:12px">
<div><b>{{c.name}}</b> is connected, but can't call the API on its own yet. {{c.extra_credential_note}}</div>
<div style="display:flex;gap:8px;margin-top:10px;align-items:center;flex-wrap:wrap">
<input class="bindinput" style="flex:1;min-width:220px" type="password"
:placeholder="c.extra_credential_label||'Second credential'"
v-model="extraCred[c.id]" @keyup.enter="saveExtraCred(c)"/>
<button class="btn sm primary" :disabled="!extraCred[c.id] || extraBusy===c.id" @click="saveExtraCred(c)">
{{extraBusy===c.id?'Saving…':'Save & finish setup'}}
</button>
</div>
</div>
<div v-if="staleConns.length" class="banner" style="margin-top:12px">
<b>{{staleConns.length}} connection{{staleConns.length===1?'':'s'}} need reconnecting.</b>
treg can't renew {{staleConns.length===1?'it':'them'}} automatically — without a fresh consent
{{staleConns.length===1?'it':'they'}} will stop working:
<span class="mono">{{staleConns.map(c=>c.name).join(', ')}}</span>
</div>
<!-- The marketplace has two axes, and the tab bar is the choice between them: every tab
but the last asks WHICH DATA you want (platform tiles, grouped by category), while
"Platform" is the original integration shelf — which ACCOUNT you hold. Data-first is
the default because that is the question an agent actually arrives with. -->
<div class="mk-tabs-wrap" v-if="plats.list.length">
<div class="mk-tabs" role="tablist" aria-label="Catalog">
<button v-for="t in mkTabs" :key="t.key" role="tab" :aria-selected="mkTabActive===t.key"
:class="{on:mkTabActive===t.key}" @click="mkTab=t.key">{{t.label}} <span>{{t.n}}</span></button>
</div>
</div>
<!-- Platform tiles: the endpoint catalog's own axis. Absent — silently — on a server
whose build has no /catalog, which then only has the Platform tab to show. -->
<template v-if="mkTabActive!=='platform'">
<div class="tgroup shelf" v-for="g in platCatGroups" :key="g.category">
<div class="sec-head">
<h2 class="sec-h"><b>{{g.category}}</b><span class="sec-n">{{g.total}}</span></h2>
<span class="sec-hint" v-if="g.hint">{{g.hint}}</span>
</div>
<div class="pt-grid">
<!-- The card is a NAME and two facts, nothing more: a description paragraph made
every card tall enough that a shelf of twelve became a scroll, and it repeated
what the name and category already said. The summary survives as the hover
title, so nothing is lost for the one visitor who wants it. -->
<button v-for="pl in g.items" :key="pl.slug" class="pt-card"
:title="pl.summary ? pl.label+' — '+pl.summary : pl.label"
:aria-label="'Open '+pl.label" @click="openPlatform(pl.slug)">
<div class="pt-top">
<!-- A platform's OWN mark, not its providers': the card is the platform. Anything
we haven't drawn falls back to a generated initial tile, not a broken image. -->
<span class="pt-logo" :class="{gen:platLogoBad[pl.slug]}"
:style="platLogoBad[pl.slug] ? {background:platTileBg(pl.slug)} : null">
<img v-if="!platLogoBad[pl.slug]" :src="'/logos/platforms/'+pl.slug+'.svg'" alt=""
aria-hidden="true" @error="platLogoBad[pl.slug]=true">
<span v-else class="pt-i">{{platInitial(pl)}}</span>
</span>
<span style="min-width:0">
<span class="pt-name">{{platShort(pl.label)}}</span>
<span class="pt-cat">{{pl.category}}</span>
</span>
<!-- The corner answers the only question that changes what you do next: can I
call this today, or is there a signup between me and it? Which provider
serves it is a decision for the platform page, not a browse-time fact. -->
<!-- Connection state is a MEMBER fact. On a public catalog URL there is no
team to be connected, so the badge would read "not connected" on all 80
tiles — a wall of red herrings for someone just browsing what exists. -->
<span class="pt-conn" v-if="!publicCatalog">
<span v-if="platConnected(pl)" class="chip go"
:title="'You have a connected account for '+platConnNames(pl)"><span class="godot"></span>Connected</span>
<span v-else class="pt-quiet" title="No connected account for any provider serving this platform yet">not connected</span>
</span>
</div>
<div class="pt-foot">
<span class="pt-eps"><span class="pt-dot"></span>{{pl.endpoints}} endpoint{{pl.endpoints===1?'':'s'}}</span>
<span v-if="platPrice(pl)" class="pt-price" :class="{free:platPrice(pl).free}"
:title="platPriceTitle(pl)"><template v-if="!platPrice(pl).free">from </template><b>{{platPrice(pl).text}}</b></span>
</div>
</button>
</div>
<!-- The tail of a long shelf, as one row: a stack of the marks plus two names, so it
reads as "there is more of this kind here" rather than as a bare count. -->
<button v-if="g.rest.length" class="pt-more" @click="platShelfOpen[g.category]=true"
:aria-label="'Show the other '+g.rest.length+' '+g.category+' platforms'">
<span class="pt-stack">
<span v-for="pl in g.rest.slice(0,7)" :key="pl.slug" class="pt-mini" :title="pl.label"
:class="{gen:platLogoBad[pl.slug]}"
:style="platLogoBad[pl.slug] ? {background:platTileBg(pl.slug)} : null">
<img v-if="!platLogoBad[pl.slug]" :src="'/logos/platforms/'+pl.slug+'.svg'" alt=""
aria-hidden="true" @error="platLogoBad[pl.slug]=true">
<span v-else class="pt-i">{{platInitial(pl)}}</span>
</span>
</span>
<span class="pt-more-t">{{moreLabel(g.rest)}}</span>
<span class="pt-more-a" aria-hidden="true">→</span>
</button>
</div>
<div v-if="!platCatGroups.length" class="mk-empty">
No catalogued platforms{{mkTabActive==='all'?'':' in '+mkTabActive}} on this server yet — the
<b>Platform</b> tab lists every integration you can connect.
</div>
</template>
<!-- The original marketplace: integrations grouped by category, unchanged. -->
<template v-if="mkTabActive==='platform'">
<div class="mk-filters" style="margin-top:0">
<button class="mk-chip" :class="{on:mkCat===''}" @click="mkCat=''">All <span>{{providers.length}}</span></button>
<button v-for="g in providerGroups" :key="g.category" class="mk-chip"
:class="{on:mkCat===g.category}" @click="mkCat=g.category">{{g.category}} <span>{{g.items.length}}</span></button>
</div>
<div class="tgroup" v-for="g in shownGroups" :key="g.category">
<div class="tgh">{{g.category}} <span class="tgh-n">{{g.items.length}}</span><span class="tgh-hint">{{g.hint}}</span></div>
<!-- A LIST, not cards: this tab is where "bring your own key" lands, and the visitor is
scanning forty names for one they hold an account with. One provider per row keeps
every name on the same left edge; the summary rides along muted and truncated. The
whole row opens the provider page; the Connect / Add key action is inline, so the
common path is one click, not two. -->
<div class="ttable-wrap"><table class="ttable prov-list">
<tr v-for="p in g.items" :key="p.service" :id="'prov-'+p.service" class="prov-row"
:class="{focus:byokFocus===p.service}" tabindex="0" role="button"
:aria-label="'Open '+p.display_name"
@click="publicCatalog ? goPublicTool(p.service) : openProvider(p.service)"
@keyup.enter="publicCatalog ? goPublicTool(p.service) : openProvider(p.service)">
<td class="tn prov-n">
<!-- Flex on a wrapper INSIDE the cell, never on the <td> — see the ledger rows. -->
<span class="prov-n-i">
<span class="plogo-tile"><img class="plogo" :src="'/logos/'+p.service+'.svg'" alt="" aria-hidden="true" @error="$event.target.style.visibility='hidden'"></span>
<span class="prov-name"><b>{{p.display_name}}</b><span class="prov-sum">{{p.summary}}</span></span>
</span>
</td>
<td class="prov-auth">{{p.auth_kind==='key'?'API key':(p.auth_kind==='token'?'your own bot':'one-click')}}</td>
<td class="ta prov-st">
<!-- Connected count is the one fact that changes what you'd do next. -->
<span v-if="connCount[p.service]" class="chip ok">{{connCount[p.service]}} connected</span>
<span v-else-if="!p.configured" class="chip warn" title="This server has no client credentials for the provider">not configured</span>
<span v-else class="mk-quiet">not connected</span>
</td>
<td class="tx" @click.stop>
<button v-if="p.configured" class="btn sm" :class="{primary:!connCount[p.service]}"
@click="publicCatalog ? openSignin() : startConnect(p)"
:title="p.auth_kind==='key'||p.auth_kind==='token' ? 'Paste your own '+p.display_name+' credential — treg keeps it server-side' : 'Connect '+p.display_name+' — approve on their site'">
{{connCount[p.service] ? 'Add account' : (p.auth_kind==='key'||p.auth_kind==='token' ? 'Add key' : 'Connect')}}</button>
</td>
</tr>
</table></div>
</div>
</template>
</template>
+108
View File
@@ -0,0 +1,108 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="tut-head">
<div>
<p class="sub" style="margin:0 0 4px"><a href="/app#tools" @click.prevent="go('tools')">← Tools</a></p>
<h1 style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">{{detail.name}}
<span class="chip">{{detail.kind==='skill' ? 'skill' : (detailData&&detailData.cli ? 'endpoint + CLI' : 'endpoint')}}</span>
<span v-if="detailData&&detailData.owner" class="chip" :title="'shared by '+detailData.owner">{{detailData.owner}}</span>
</h1>
</div>
<div class="tut-actions" style="position:relative">
<template v-if="detailData && detail.kind==='skill' && (detailData.tools||[]).length">
<button class="btn sm" @click.stop="(detailData.tools.length===1 ? tryDetailTool(detailData.tools[0]) : tryMenu=!tryMenu)" title="Call one of this skill's tools right here — the key stays server-side">▶ Try it</button>
<div v-if="tryMenu" style="position:fixed;inset:0;z-index:29" @click="tryMenu=false"></div>
<div class="dropdown" v-if="tryMenu" style="left:auto;right:0;top:110%;width:260px" @click.stop>
<div class="row" v-for="t in detailData.tools" :key="'try'+t.id" @click="tryDetailTool(t)"><span style="min-width:0"><b style="font-size:12.5px">⚒ {{t.name}}</b><span class="sub" style="display:block;font-size:11px;margin-top:1px">{{t.host||t.base_url}}</span></span></div>
</div>
</template>
<template v-if="canRegister && detailData">
<button v-if="detail.kind==='tool'" class="btn sm" @click="configureTool(detailData)" :title="'Edit this '+(detailData.cli?'CLI':'endpoint')+' — credentials, '+(detailData.cli?'deny list, local runs':'bindings')">⚙ Configure</button>
<template v-else-if="(detailData.tools||[]).length">
<button class="btn sm" @click.stop="(detailData.tools.length===1 ? configureTool(detailData.tools[0]) : cfgMenu=!cfgMenu)" title="Edit this skill's tools — credentials, deny lists, local runs">⚙ Configure</button>
<div v-if="cfgMenu" style="position:fixed;inset:0;z-index:29" @click="cfgMenu=false"></div>
<div class="dropdown" v-if="cfgMenu" style="left:auto;right:0;top:110%;width:260px" @click.stop>
<div class="row" v-for="t in detailData.tools" :key="'cfg'+t.id" @click="configureTool(t)"><span style="min-width:0"><b style="font-size:12.5px">⚒ {{t.name}}</b><span class="sub" style="display:block;font-size:11px;margin-top:1px">{{t.host||t.base_url}}</span></span></div>
</div>
</template>
</template>
<button class="btn sm" @click="copyDetail(detailShareUrl,'url')" title="Copy this page's URL — anyone on the team can open it">{{detailCopied==='url'?'✓ copied':'⧉ Copy link'}}</button>
<button v-if="canAdmin && detailData" class="btn sm primary" @click="openShare" title="Invite someone outside the team — they'll land right on this page">Share…</button>
</div>
</div>
<div v-if="detailErr" class="banner" style="margin-top:12px">{{detailErr}}</div>
<div v-if="detailNote" class="tut-notice" style="margin-top:12px;max-width:860px">{{detailNote}}</div>
<p v-if="detailLoading" class="sub">Loading…</p>
<template v-if="detailData && detail.kind==='skill'">
<div style="max-width:860px;border:1px solid var(--line);border-radius:16px;padding:16px 18px;margin:14px 0;background:var(--panel)">
<h3 style="margin:0 0 6px;font-size:14px">Use with your agent</h3>
<p class="sub" style="margin:0 0 10px">Send someone this page's link, or paste this into a coding agent (Claude Code / Codex / Gemini) — it installs the CLI, signs them in as themselves, and pulls just this skill. <template v-if="(detailData.secrets||[]).length">Its credential{{detailData.secrets.length>1?'s stay':' stays'}} server-side — calls go through the proxy, no key lands on their machine.</template></p>
<pre class="code" style="white-space:pre-wrap;max-height:34vh;overflow:auto">{{detailPrompt}}</pre>
<div style="margin-top:10px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<button class="btn primary" @click="copyDetail(detailPrompt,'prompt')">⧉ {{detailCopied==='prompt'?'Copied!':'Copy prompt'}}</button>
<button class="btn" @click="copyDetail('treg skill install '+detail.name,'cli')" title="Just the CLI command, if treg is already set up">{{detailCopied==='cli'?'✓ copied':'treg skill install '+detail.name}}</button>
</div>
</div>
<div v-if="(detailData.tools||[]).length || (detailData.secrets||[]).length" style="max-width:860px;margin:0 0 14px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<span class="sub" style="margin:0">Bundled:</span>
<a v-for="t in detailData.tools" :key="'dt'+t.id" class="chip" :href="'/app/tools/'+encodeURIComponent(t.name)" @click.prevent="openDetail('tool',t.name)" style="cursor:pointer" :title="'tool · '+(t.host||t.base_url)">⚒ {{t.name}}</a>
<span v-for="s in detailData.secrets" :key="'ds'+s.id" class="chip" title="credential — stored encrypted server-side, injected by the proxy, never shown">🔒 {{s.name||s.local_name}}</span>
</div>
<div style="max-width:860px;border:1px solid var(--line);border-radius:16px;overflow:hidden;display:flex;min-height:280px;background:var(--panel)">
<div style="width:220px;flex:none;border-right:1px solid var(--line);padding:10px 0;overflow:auto;max-height:60vh">
<div v-for="row in detailTree" :key="row.path"
:style="{padding:'4px 12px 4px '+(14+row.depth*14)+'px', cursor:row.dir?'default':'pointer', fontSize:'12.5px',
color:row.dir?'var(--muted)':'var(--ink)', background:(!row.dir&&detailFile===row.path)?'var(--panel2)':'transparent'}"
@click="!row.dir && (detailFile=row.path)">{{row.dir?'▸ ':'· '}}{{row.name}}</div>
</div>
<pre class="code" style="flex:1;margin:0;border:0;border-radius:0;white-space:pre-wrap;word-break:break-word;overflow:auto;max-height:60vh">{{detailFileContent}}</pre>
</div>
</template>
<template v-if="detailData && detail.kind==='tool'">
<div style="max-width:860px;border:1px solid var(--line);border-radius:16px;padding:16px 18px;margin:14px 0;background:var(--panel)">
<h3 style="margin:0 0 6px;font-size:14px">Use with your agent</h3>
<p class="sub" style="margin:0 0 10px">Send someone this page's link, or paste this into a coding agent. Calls go through the proxy — the credential is injected server-side, never on their machine.</p>
<pre class="code" style="white-space:pre-wrap;max-height:34vh;overflow:auto">{{detailPrompt}}</pre>
<div style="margin-top:10px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<button class="btn primary" @click="copyDetail(detailPrompt,'prompt')">⧉ {{detailCopied==='prompt'?'Copied!':'Copy prompt'}}</button>
<button class="btn" @click="openCopy(detailData)" title="Language-specific call snippets (cURL / Python / Node / …)">⧉ Call snippets</button>
<button class="btn" @click="openUse(detailData)" title="Try it right here">▶ Try it</button>
</div>
</div>
<div style="max-width:860px;border:1px solid var(--line);border-radius:16px;padding:16px 18px;background:var(--panel)">
<div class="lbl">Upstream</div>
<p style="margin:2px 0 12px"><span class="mono">{{detailData.base_url}}</span></p>
<div class="lbl">Credentials</div>
<p class="sub" style="margin:2px 0 12px" v-if="(detailData.bindings||[]).length || (detailData.cli&&(detailData.cli.inject||[]).length)"><span class="chip" v-for="(w,wi) in credChips(detailData)" :key="'dw'+wi" :class="w.kind" :title="w.title">{{w.label}}</span> injected server-side on every call — the value never appears here or on a caller's machine.</p>
<p class="sub" style="margin:2px 0 12px" v-else>none — a public upstream, no credential needed.</p>
<template v-if="(detailData.examples||[]).length">
<div class="lbl">Examples</div>
<div class="exrow" style="margin:4px 0 12px"><span v-for="(ex,exi) in detailData.examples" :key="'de'+exi" class="exchip"><span class="m">{{ex.method||'GET'}}</span>{{ex.note||ex.path}}</span></div>
</template>
<template v-if="detailData.cli">
<div class="lbl">CLI</div>
<p class="sub" style="margin:2px 0 12px"><span class="mono">treg run {{detail.name}} -- &lt;args&gt;</span>
<span class="chip ok" v-if="detailData.server_runnable" title="Runs on the server — the key is injected there, never on a member's machine">server</span>
<span class="chip" v-else title="Local only — this CLI authenticates from the member's own machine (treg run --local)">local-only</span>
<span class="chip run" v-if="!detailData.cli.enabled" title="The owner has local runs switched off for this tool">⌘ run off</span>
</p>
<template v-if="(detailData.cli.deny||[]).length">
<div class="lbl">Guardrails</div>
<p class="sub" style="margin:2px 0 12px">runs matching these patterns are refused before the key is injected: <span class="mono" v-for="(p,pi) in detailData.cli.deny" :key="'dg'+pi" style="margin-right:8px">{{p}}</span></p>
</template>
</template>
<template v-if="detailParentSkill">
<div class="lbl">From skill</div>
<p style="margin:2px 0 0"><a :href="'/app/skills/'+encodeURIComponent(detailParentSkill.name)" @click.prevent="openDetail('skill',detailParentSkill.name)">▚ {{detailParentSkill.name}}</a> — the recipe + files that registered this tool.</p>
</template>
</div>
</template>
</template>
+172
View File
@@ -0,0 +1,172 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="rd-start">
<div class="rd-welcome">
<div class="rd-welcome-title"><span><img src="/media/redesign/welcome-mark.svg" alt=""></span><h1>Welcome! Getting started</h1></div>
<button v-if="canRegister" class="rd-search" @click="go('connections'); $nextTick(()=>elements.search?.focus())"><img src="/media/redesign/search.svg" alt="">Search tools…</button>
</div>
<div class="start-card rd-setup">
<div class="rd-setup-side">
<div class="start-hd">
<span class="start-num">1</span><b>Set up your</b>
<div class="rd-agent-picker" @keydown.esc.stop="startAgentOpen=false; elements.startAgentTrigger.focus()" @focusout="!$event.currentTarget.contains($event.relatedTarget) && (startAgentOpen=false)">
<button :ref="el => setElement('startAgentTrigger', el)" type="button" class="agent-card" :aria-expanded="startAgentOpen" aria-controls="rd-agent-options" @click="startAgentOpen=!startAgentOpen">
<img v-if="welcomeAgent.icon" :src="welcome.agent==='openclaw'?'/media/redesign/openclaw.svg':agentIcon(welcomeAgent.icon)" alt=""><span>{{welcomeAgent.name}}</span><span aria-hidden="true">▾</span>
</button>
<div v-if="startAgentOpen" id="rd-agent-options" class="agent-menu">
<button v-for="a in welcomeAgents.concat(welcomeMoreAgents)" :key="a.id" type="button" class="agent-card" :class="{on:welcome.agent===a.id}" :aria-pressed="welcome.agent===a.id" @click="welcome.agent=a.id; startAgentOpen=false; elements.startAgentTrigger.focus()"><img v-if="a.icon" :src="a.id==='openclaw'?'/media/redesign/openclaw.svg':agentIcon(a.icon)" alt=""><span>{{a.name}}</span></button>
</div>
</div>
</div>
<img v-if="welcome.agent==='openclaw'" class="rd-agent-preview" src="/media/redesign/openclaw-preview.png" alt="OpenClaw">
<div v-else class="rd-agent-preview rd-agent-fallback"><img v-if="welcomeAgent.icon" :src="agentIcon(welcomeAgent.icon)" alt=""><span>{{welcomeAgent.name}}</span></div>
</div>
<div class="start-bd rd-setup-body">
<p class="rd-agent-context">Setting up treg for <b>{{welcomeAgent.name}}</b></p>
<template v-if="welcomeAgent.plugin"><p>First, install the treg plugin:</p><a class="btn" :href="welcomeAgent.plugin" target="_blank" rel="noopener" @click="track('onboarding_plugin_install_clicked',{agent:welcome.agent,from:'start'})">Install plugin in {{welcomeAgent.name}} ↗</a></template>
<section class="rd-setup-panel">
<p class="rd-panel-label">{{welcomeAgent.plugin ? "Then, in your Bot's chat, send:" : "In your agent's chat, send:"}}</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart(welcomeSetupCmd,'gsc')">{{startCopied==='gsc'?'Copied':'Copy'}}</button><pre>{{welcomeSetupCmd}}</pre></div>
</section>
<section v-if="myToken" class="rd-setup-panel">
<p class="rd-panel-label">Your API key</p>
<p class="rd-token-help">Setup signs your agent in automatically. This is your team's Default key (header <code>X-Treg-Token</code>). Additional and agent keys are random secrets shown only once; manage them in Team.</p>
<div class="lc-codewrap rd-token-code"><div class="rd-token-actions"><button class="rd-token-toggle" @click="startTokenShow=!startTokenShow" :aria-label="startTokenShow?'Hide key':'Show key'"><img :src="'/media/redesign/'+(startTokenShow?'eye-open':'eye-closed')+'.svg'" alt=""></button><button class="lc-cp" @click="copyStart(myToken,'gsk')">{{startCopied==='gsk'?'Copied':'Copy'}}</button></div><pre>{{startTokenShow ? myToken : (myToken.slice(0,14)+'••••••••••••••••')}}</pre></div>
</section>
<section v-else-if="defaultKeyState==='disabled'" class="rd-setup-panel">
<p class="rd-panel-label">Your API key</p>
<p class="rd-token-help">This team's Default key is disabled, so it is hidden and cannot be used.</p>
<button class="btn sm" @click="enableDefaultKey" :disabled="keyBusy">{{keyBusy?'…':'Enable key'}}</button>
</section>
</div>
</div>
<!-- ② Try it out -->
<div class="start-card rd-try">
<div class="start-hd"><span class="start-num">2</span><b style="font-size:16px">Try it out</b></div>
<div class="start-bd">
<p class="rd-try-intro">Copy an example below and send it to your agent.</p>
<div class="try-grid">
<button v-for="ex in tryExamples" :key="ex.k" type="button" class="try-card" :class="'rd-task-'+ex.k" @click="track('tryit_prompt_copied',{key:ex.k,cat:ex.cat,from:'getting_started'}); copyStart(ex.prompt,'try-'+ex.k)">
<img class="rd-task-image" :src="'/media/redesign/try-'+({trend:'trending',enr:'contact',serp:'keyword',soc:'linkedin',posts:'linkedin',ugc:'trending'}[ex.k])+'-bg.png'" alt=""><span class="rd-task-overlay">
<span class="try-cat"><span style="display:inline-flex;align-items:center;gap:7px"><img class="try-ico" :src="ex.k==='ugc' ? '/logos/platforms/seedance.svg' : '/media/redesign/try-'+({trend:'tiktok',enr:'people',serp:'google',soc:'linkedin',posts:'linkedin'}[ex.k] || 'people')+'.svg'" alt=""/>{{ex.cat}}</span><span class="try-copy" :class="{done:startCopied==='try-'+ex.k}">{{startCopied==='try-'+ex.k ? '✓ copied' : '⧉ copy'}}</span></span>
<span class="try-txt">{{ex.show || ex.prompt}}</span></span>
</button>
</div>
<div class="oauth-div"><span>also connect OAuth to unlock new agent capabilities</span></div>
<div v-for="g in tryOauth" :key="g.label" style="margin-top:14px">
<div class="oauth-grp">{{g.label}}</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button v-for="p in g.items" :key="p.s" type="button" class="prov-chip" @click="track('tryit_oauth_clicked',{service:p.s,group:g.label,from:'getting_started'}); openProvider(p.s)"><img :src="'/media/redesign/oauth-'+({'google-business-profile':'business-profile','google-search-console':'search-console'}[p.s]||p.s)+'.svg'" alt=""/>{{p.n}}</button>
<span v-if="g.soon.length" class="soon-note" :data-tip="g.soon.map(p=>p.n).join(', ')">{{g.soon.length}} coming soon</span>
</div>
</div>
</div>
</div>
<!-- BUILD ON TREG — deliberately NOT a numbered step. Steps 1-2 are the one path every
new user walks; these two are optional and belong to different people (a vendor with an
API to list, a platform embedding treg). Numbering them implied everyone had three
things to do. Tabs, because nobody needs both. -->
<div class="rd-build">
<h2 style="margin:0 0 4px;font-size:17px">Build on treg</h2>
<p class="sub" style="margin:0 0 12px">Optional, for two other kinds of user. Each is a skill file — paste the line into your coding agent, in your own repo, and it implements the rest.</p>
<div class="seg">
<button :class="{on:buildTab==='vendor'}" @click="buildTab='vendor'">List as vendor</button>
<button :class="{on:buildTab==='platform'}" @click="buildTab='platform'">Integrate into your platform</button>
</div>
<div v-show="buildTab==='vendor'" class="rd-build-panel">
<p class="sub">Put your API in front of every treg agent. Your agent reads the guide, writes the catalog entry for your endpoints and prices, and opens the PR.</p>
<div class="lc-codewrap" style="margin-top:8px">
<button class="lc-cp" @click="copyStart('Read '+proxy+'/vendor-listing.md and add our API to the treg catalog, then open a PR.','vend')">{{startCopied==='vend'?'✓ copied':'copy'}}</button>
<pre>Read <span class="hl-str">{{proxy}}/vendor-listing.md</span> and add our API to the
treg catalog, then open a PR.</pre>
</div>
</div>
<div v-show="buildTab==='platform'" class="rd-build-panel">
<p class="sub">Give <i>your</i> users 2,800+ tools without owning the keys — and bill each of your customers for what they used. Covers the call methods (HTTP, MCP, CLI), per-customer tagging, spend limits and invoicing.</p>
<div class="lc-codewrap" style="margin-top:8px">
<button class="lc-cp" @click="copyStart('Read '+proxy+'/integrate.md and integrate treg into our product, including per-customer usage tracking and billing.','intg')">{{startCopied==='intg'?'✓ copied':'copy'}}</button>
<pre>Read <span class="hl-str">{{proxy}}/integrate.md</span> and integrate treg into our
product, including per-customer usage tracking and billing.</pre>
</div>
</div>
</div>
<!-- The manual CLI paths, kept but tucked away -->
<details style="margin-top:26px" :open="startTab==='setup'">
<summary class="sub" style="cursor:pointer;margin-bottom:0">Prefer the terminal? Manual CLI setup &amp; sharing your team's keys →</summary>
<div class="seg" style="margin-top:14px">
<button :class="{on:startTab==='access'}" @click="startTab='access'">Access 2000+ treg tools</button>
<button :class="{on:startTab==='setup'}" @click="startTab='setup'">Set up your own tools</button>
</div>
<!-- TAB · Access the catalog — agent instruction first, then the manual CLI walkthrough -->
<div v-show="startTab==='access'" style="max-width:720px">
<p class="sub">2,800+ catalogued endpoints: SEO and backlinks, social and trends, people and company enrichment, ads. Find one by what it <i>does</i>, see its price, call it — no provider signup. New verified accounts get <b>$1.00 free credit once</b> on an eligible team, covering hundreds of calls.</p>
<div class="lbl" style="margin-top:16px">▸ Set up with your agent <span class="muted" style="font-weight:400">— paste &amp; go</span></div>
<p class="sub">One line, token included — your agent reads llms.txt and does the rest: installs the CLI, signs in as you, and makes its first call.</p>
<div class="lc-codewrap" style="margin-top:8px"><button class="lc-cp" @click="copyAgentGuide('agent')">{{agentRowCopied==='agent'?'✓ copied':'copy'}}</button><pre style="max-height:240px;overflow:auto">{{buildAgentPrompt('agent', true)}}</pre></div>
<div class="lbl" style="margin-top:26px">▸ Or set it up manually</div>
<div class="lbl" style="margin-top:14px">1 · Install the CLI</div>
<p class="sub">Installs the <span class="mono">treg</span> command and points it at this server. Upgrade later with <span class="mono">treg update</span>.</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('curl -fsSL '+proxy+'/install.sh | sh','i')">{{startCopied==='i'?'✓ copied':'copy'}}</button><pre><span class="hl-cmd">curl</span> -fsSL <span class="hl-str">{{proxy}}/install.sh</span> | sh</pre></div>
<div class="lbl" style="margin-top:22px">2 · Sign in</div>
<p class="sub">Opens your browser — GitHub, Google, or an email code. Your first sign-in also registers you.</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('treg login','l')">{{startCopied==='l'?'✓ copied':'copy'}}</button><pre><span class="hl-cmd">treg</span> login</pre></div>
<div class="lbl" style="margin-top:22px">3 · Call something — no key, nothing to set up</div>
<p class="sub">Search the catalog by what you need done, check the price, call it, and see exactly what it cost.</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('treg catalog search &quot;backlinks for a domain&quot;\ntreg call tikhub.tiktok.user.profile --query uniqueId=tiktok\ntreg balance','cat1')">{{startCopied==='cat1'?'✓ copied':'copy'}}</button><pre><span class="hl-cmd">treg</span> catalog search <span class="hl-str">"backlinks for a domain"</span>
<span class="hl-cmd">treg</span> call tikhub.tiktok.user.profile <span class="hl-flag">--query</span> uniqueId=tiktok
<span class="hl-cmd">treg</span> balance <span class="hl-comment"># see exactly what it cost</span></pre></div>
</div>
<!-- TAB · Setup the team vault — two sections: set it up, then let the team use it -->
<div v-show="startTab==='setup'" style="max-width:720px">
<p class="sub">Share your skills &amp; API keys with the team — your <span class="mono">.env</span> keys and skill folders become tools every teammate's agent can call, without the key ever leaving the server.</p>
<div class="lbl" style="margin-top:16px">▸ Set up with your agent <span class="muted" style="font-weight:400">— paste &amp; go</span></div>
<p class="sub">One line, token included — your agent reads llms.txt, installs the CLI, signs in as you, and can register your local skills + keys as shared tools (read-only scan first, you approve).</p>
<div class="lc-codewrap" style="margin-top:8px"><button class="lc-cp" @click="copyAgentGuide('agent')">{{agentRowCopied==='agent'?'✓ copied':'copy'}}</button><pre style="max-height:240px;overflow:auto">{{buildAgentPrompt('agent', true)}}</pre></div>
<div class="lbl" style="margin-top:26px">▸ Or set it up manually</div>
<div class="lbl" style="margin-top:14px">1 · Install the CLI</div>
<p class="sub">Installs the <span class="mono">treg</span> command and points it at this server. Upgrade later with <span class="mono">treg update</span>.</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('curl -fsSL '+proxy+'/install.sh | sh','si')">{{startCopied==='si'?'✓ copied':'copy'}}</button><pre><span class="hl-cmd">curl</span> -fsSL <span class="hl-str">{{proxy}}/install.sh</span> | sh</pre></div>
<div class="lbl" style="margin-top:22px">2 · Sign in</div>
<p class="sub">Opens your browser — GitHub, Google, or an email code. Your first sign-in also registers you.</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('treg login','sl')">{{startCopied==='sl'?'✓ copied':'copy'}}</button><pre><span class="hl-cmd">treg</span> login</pre></div>
<div class="lbl" style="margin-top:22px">3 · Upload your keys &amp; skills</div>
<p class="sub">Two ways to give your agents power: share a whole <b>skill</b>, or register a single <b>API endpoint</b>. Preview with <span class="mono">treg scan</span> first. Idempotent — re-run freely.</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('treg upload # both .env + skills in this dir\ntreg upload env --select openai,stripe\ntreg upload skills --dir ~/.claude/skills --all # register skill from a specific path','im')">{{startCopied==='im'?'✓ copied':'copy'}}</button><pre><span class="hl-cmd">treg</span> upload <span class="hl-comment"># both .env + skills in this dir</span>
<span class="hl-cmd">treg</span> upload env <span class="hl-flag">--select</span> openai,stripe
<span class="hl-cmd">treg</span> upload skills <span class="hl-flag">--dir</span> <span class="hl-str">~/.claude/skills</span> <span class="hl-flag">--all</span> <span class="hl-comment"># register skill from a specific path</span></pre></div>
<div class="lbl" style="margin-top:30px">▸ Then — let the team use it</div>
<p class="sub">Your team's own tools: a teammate pulls a shared skill and calls any registered API, with no keys on their machine. These are never metered — your team already pays for them.</p>
<div class="lbl" style="margin-top:14px">4 · Use the team's shared tools</div>
<p class="sub">Browse the shared library, pull a skill, and call a tool — the credential stays on the server, never on your machine.</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('treg skill ls\ntreg skill install seo-blog-writer # a teammate pulls one (or --all)\ntreg call https://api.stripe.com/v1/charges','cc')">{{startCopied==='cc'?'✓ copied':'copy'}}</button><pre><span class="hl-cmd">treg</span> skill ls
<span class="hl-cmd">treg</span> skill install seo-blog-writer
<span class="hl-cmd">treg</span> call <span class="hl-str">https://api.stripe.com/v1/charges</span></pre></div>
</div>
</details>
</div>
</template>
+132
View File
@@ -0,0 +1,132 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<!-- chooser -->
<template v-if="!helpMode">
<h1>Tutorial</h1><p class="sub">Four ways to learn treg - pick one.</p>
<div class="grid" style="max-width:680px">
<div class="card" @click="helpMode='cli'" style="cursor:pointer">
<h3>▤ CLI tutorial</h3><p class="muted" style="font-family:var(--sans);margin:7px 0 0">The whole registry from your terminal - copy each command and follow along. {{tutSteps.length}} steps.</p>
</div>
<div class="card" @click="helpMode='dashboard'; tourI=0" style="cursor:pointer">
<h3>▚ Dashboard tour</h3><p class="muted" style="font-family:var(--sans);margin:7px 0 0">Everything you can do in this web UI, a screenshot per step. {{tourSteps.length}} steps.</p>
</div>
<div class="card" @click="helpMode='import-shell'; xtut.i=0" style="cursor:pointer">
<h3>▞ Import &amp; shell</h3><p class="muted" style="font-family:var(--sans);margin:7px 0 0">Turn the CLIs on your machine into team tools, then use them in a shell where they just work - plus the security sandbox. {{(tutData.importShell||[]).length}} steps.</p>
</div>
<div class="card" @click="helpMode='access'; xtut.i=0" style="cursor:pointer">
<h3>▟ Team access control</h3><p class="muted" style="font-family:var(--sans);margin:7px 0 0">Choose which tools each member may use, and whether they may run CLIs locally. {{(tutData.access||[]).length}} steps.</p>
</div>
</div>
<p v-if="onboarded" class="sub" style="margin-top:14px;font-size:12px">Tried the demo? <a href="#" @click.prevent="resetDemo" style="color:var(--accent)">Remove demo teammates →</a></p>
</template>
<!-- CLI tutorial -->
<template v-else-if="helpMode==='cli'">
<div class="tut-head">
<div><a href="#" @click.prevent="helpMode=null" class="sub" style="display:inline-block;margin-bottom:3px">← Tutorials</a><h1 style="margin:0">CLI tutorial</h1></div>
<div class="tut-actions">
<button class="btn sm" :class="{primary:tut.panel==='concepts'}" @click="tut.panel = tut.panel==='concepts'?null:'concepts'">Concepts</button>
<button class="btn sm" :class="{primary:tut.panel==='roles'}" @click="tut.panel = tut.panel==='roles'?null:'roles'">Roles</button>
<button class="btn sm" :class="{primary:tut.panel==='auth'}" @click="tut.panel = tut.panel==='auth'?null:'auth'">Auth shapes</button>
<button class="btn sm" :class="{primary:tut.panel==='skills'}" @click="tut.panel = tut.panel==='skills'?null:'skills'">Skills</button>
</div>
</div>
<div v-if="tut.panel==='concepts'" class="tut-cards">
<div class="card" v-for="c in tutData.concepts" :key="c.h"><h4 v-html="c.h"></h4><p v-html="c.p"></p></div>
</div>
<div v-if="tut.panel==='auth'" class="tut-cards wide">
<div class="card" v-for="c in (tutData.auth||[])" :key="c.h"><h4 v-html="c.h"></h4><p v-html="c.p"></p></div>
</div>
<div v-if="tut.panel==='skills'" class="tut-cards wide">
<div class="card" v-for="c in (tutData.skills||[])" :key="c.h"><h4 v-html="c.h"></h4><p v-html="c.p"></p></div>
</div>
<table v-if="tut.panel==='roles'" style="max-width:640px;margin:12px 0">
<tr><th>Action</th><th v-for="c in tutData.roles.cols" :key="c">{{c}}</th></tr>
<tr v-for="r in tutData.roles.rows" :key="r[0]"><td>{{r[0]}}</td><td v-for="(v,i) in r[1]" :key="i" :style="{color:v?'var(--green)':'var(--red)'}">{{v?'✔':'✘'}}</td></tr>
</table>
<div class="tut-body" v-if="tutStep">
<nav class="tut-nav">
<template v-for="(s,i) in tutSteps" :key="i">
<div v-if="i===0 || s.part!==tutSteps[i-1].part" class="tut-part">{{s.part}}</div>
<button class="tut-step" :class="{active:i===tut.i}" @click="tutGo(i)"><span class="n">{{String(i+1).padStart(2,'0')}}</span>{{s.title}}</button>
</template>
</nav>
<div class="tut-main">
<div class="crumbs">{{tutStep.part}}</div>
<h2>{{tutStep.title}}</h2>
<span class="persona" :class="tutStep.who">{{personaLabel(tutStep.who)}}</span>
<div class="explain" v-html="tutStep.explain"></div>
<div class="tut-label">Command <button class="copy" @click="tutCopy(tutStep.cmd)">{{tutCopied?'✓ copied':'copy'}}</button></div>
<pre class="term cmd" v-html="tutHL(tutStep.cmd,'cmd')"></pre>
<div class="tut-label">Expected result</div>
<pre class="term out" v-html="tutHL(tutStep.out,'out')"></pre>
<div class="tut-notice"><b>What to notice:</b> <span v-html="tutStep.notice"></span></div>
<div class="tut-foot">
<button class="btn sm" :disabled="tut.i===0" @click="tutGo(tut.i-1)">← Prev</button>
<span class="pos">Step {{tut.i+1}} of {{tutSteps.length}}</span>
<button class="btn sm" :disabled="tut.i===tutSteps.length-1" @click="tutGo(tut.i+1)">Next →</button>
</div>
</div>
</div>
</template>
<!-- Focused tutorials (Import & shell · Team access control) - same stepper, own step arrays -->
<template v-else-if="helpMode==='import-shell' || helpMode==='access'">
<div class="tut-head">
<div><a href="#" @click.prevent="helpMode=null" class="sub" style="display:inline-block;margin-bottom:3px">← Tutorials</a><h1 style="margin:0">{{xtutTitle}}</h1></div>
</div>
<div class="tut-body" v-if="xtutStep">
<nav class="tut-nav">
<template v-for="(s,i) in xtutSteps" :key="i">
<div v-if="i===0 || s.part!==xtutSteps[i-1].part" class="tut-part">{{s.part}}</div>
<button class="tut-step" :class="{active:i===xtut.i}" @click="xtutGo(i)"><span class="n">{{String(i+1).padStart(2,'0')}}</span>{{s.title}}</button>
</template>
</nav>
<div class="tut-main">
<div class="crumbs">{{xtutStep.part}}</div>
<h2>{{xtutStep.title}}</h2>
<span class="persona" :class="xtutStep.who">{{personaLabel(xtutStep.who)}}</span>
<div class="explain" v-html="xtutStep.explain"></div>
<div class="tut-label">Command <button class="copy" @click="tutCopy(xtutStep.cmd)">{{tutCopied?'✓ copied':'copy'}}</button></div>
<pre class="term cmd" v-html="tutHL(xtutStep.cmd,'cmd')"></pre>
<div class="tut-label">Expected result</div>
<pre class="term out" v-html="tutHL(xtutStep.out,'out')"></pre>
<div class="tut-notice"><b>What to notice:</b> <span v-html="xtutStep.notice"></span></div>
<div class="tut-foot">
<button class="btn sm" :disabled="xtut.i===0" @click="xtutGo(xtut.i-1)">← Prev</button>
<span class="pos">Step {{xtut.i+1}} of {{xtutSteps.length}}</span>
<button class="btn sm" :disabled="xtut.i===xtutSteps.length-1" @click="xtutGo(xtut.i+1)">Next →</button>
</div>
</div>
</div>
</template>
<!-- Dashboard tour -->
<template v-else-if="helpMode==='dashboard'">
<div class="tut-head"><div><a href="#" @click.prevent="helpMode=null" class="sub" style="display:inline-block;margin-bottom:3px">← Tutorials</a><h1 style="margin:0">Dashboard tour</h1></div></div>
<div class="tut-body" v-if="tourStep">
<nav class="tut-nav">
<template v-for="(s,i) in tourSteps" :key="i">
<div v-if="i===0 || s.part!==tourSteps[i-1].part" class="tut-part">{{s.part}}</div>
<button class="tut-step" :class="{active:i===tourI}" @click="tourI=i"><span class="n">{{String(i+1).padStart(2,'0')}}</span>{{s.title}}</button>
</template>
</nav>
<div class="tut-main">
<div class="crumbs">{{tourStep.part}}</div>
<h2>{{tourStep.title}}</h2>
<span class="persona" :class="tourStep.who">{{personaTour(tourStep.who)}}</span>
<div class="explain" v-html="tourStep.explain"></div>
<div class="tour-mat" :style="{background:tourMatColor(tourStep.part)}"><img :src="'/dashboard-tour/img/'+tourStep.img" :alt="tourStep.title"/></div>
<div class="tut-notice"><b>What to notice:</b> <span v-html="tourStep.notice"></span></div>
<div class="tut-foot">
<button class="btn sm" :disabled="tourI===0" @click="tourI--">← Prev</button>
<span class="pos">Step {{tourI+1}} of {{tourSteps.length}}</span>
<button class="btn sm" :disabled="tourI===tourSteps.length-1" @click="tourI++">Next →</button>
</div>
</div>
</div>
</template>
</template>
+262
View File
@@ -0,0 +1,262 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<!-- Stacked, not the two-column .tut-head the other pages use: a platform can be served by
a dozen providers, and as a right-hand column that chip list steals half the width and
wraps the title into a three-line ribbon ("People & / contact / data"). Full-width
title, then the intro at a readable measure, then the providers as their own row. -->
<div class="plat-head">
<button class="btn sm" style="margin-bottom:12px" @click="go('connections')">← Catalog</button>
<div class="plat-title">
<span class="pt-logo" style="width:44px;height:44px;flex:0 0 44px"
:class="{gen:platLogoBad[platSlug]}"
:style="platLogoBad[platSlug] ? {background:platTileBg(platSlug)} : null">
<img v-if="!platLogoBad[platSlug]" :src="'/logos/platforms/'+platSlug+'.svg'" alt=""
aria-hidden="true" @error="platLogoBad[platSlug]=true">
<span v-else class="pt-i">{{platInitial({label:platLabel, slug:platSlug})}}</span>
</span>
<h1>{{platLabel}}</h1>
</div>
<p class="sub plat-intro">Every endpoint treg knows for this platform, one ledger, filed by subject — jobs several
providers do sit on a single row, so you can compare price and coverage before you spend a call.</p>
<div class="plat-provs" v-if="platProviders.length">
<span class="plat-provs-l">Providers</span>
<!-- Signed out, both destinations (a provider's marketplace page, the vault) need an
account, so the chips state who supplies the shelf and the last one is the way in. -->
<button v-for="s in platProviders" :key="s" class="btn sm"
@click="publicCatalog ? openSignin() : openProvider(s)">{{provName(s)}}{{publicCatalog?'':' \u2192'}}</button>
<button class="btn sm" @click="publicCatalog ? openSignin() : goByok(platProviders.length===1 ? platProviders[0] : null)"
title="Register your own provider key — your key wins over treg's and those calls are never metered">🔑 Bring your own key</button>
</div>
</div>
<div v-if="platLoading" class="mk-empty" style="margin-top:14px">Loading the catalog…</div>
<div v-else-if="platErr" class="mk-empty" style="margin-top:14px">{{platErr}}</div>
<template v-else-if="platData">
<!-- THE LEDGER. One table for the whole platform, filed into sticky domain sections
(user · video · search · shop · …, "other" always last). Within a section the
MERGED rows come first — a job several providers do, on one comparable line —
then the endpoints only one provider offers, each led by its own summary, because
"Get Showcase Product List" says more than the capability id ever could. -->
<div class="lbar">
<!-- The chips scroll rather than wrap: a bar that grows a second row as you filter
would shift the sticky section headings out from under it. -->
<div class="lchips">
<button class="mk-chip" :class="{on:!platDomain}" @click="platDomain=''">All <span>{{platBrowseCount}}</span></button>
<button v-for="d in platDomainTabs" :key="d.domain" class="mk-chip"
:class="{on:platDomain===d.domain}"
@click="platDomain = platDomain===d.domain ? '' : d.domain">{{d.domain}} <span>{{d.n}}</span></button>
</div>
<label class="lchk"><input type="checkbox" v-model="platVerifiedOnly"> verified only</label>
<input class="lfind" v-model="platQ" placeholder="filter… e.g. comments, showcase">
</div>
<!-- Two numbers, because a merged row is several endpoints: what you are scrolling
through, and how much of the catalog that actually is. -->
<div class="lstat">{{platStats.rows}} row{{platStats.rows===1?'':'s'}} · {{platStats.eps}} endpoint{{platStats.eps===1?'':'s'}}<span
v-if="platDomain || platQ || platVerifiedOnly"> · filtered <button class="lclear" @click="platClearFilters">clear</button></span></div>
<div class="ttable-wrap lwrap" v-if="platLedger.length"><table class="ledger">
<thead><tr><th class="lth-w">What it does</th><th>Providers / route</th><th class="lth-p">Price</th><th class="lth-v">✓</th></tr></thead>
<!-- A row, its section heading and its expanded detail are all table rows, so each
level rides a wrapper tag — a tbody per group would strip the row separators. -->
<template v-for="sec in platLedger" :key="sec.domain">
<tr class="lsec" :class="{on:sec.actions}"><td colspan="4">
<!-- The one collapsed section: every account/utility endpoint on the platform,
whatever domain it nominally belongs to. -->
<button v-if="sec.actions" class="lsec-btn" :aria-expanded="platActionsOpen"
@click="platActionsOpen=!platActionsOpen"><span class="lcar">▸</span>Actions
<span>{{sec.count}}</span></button>
<template v-else>{{sec.domain}} <span>{{sec.rows.length}}</span></template>
</td></tr>
<template v-for="r in sec.rows" :key="r.key">
<!-- EVERY row expands, merged or not: the row says what it does, the expansion
says how to call it, and which of the two a visitor needs is not something
the row shape can decide for them. -->
<tr class="lrow" :class="{open:platOpen[r.key], go:r.ready}" @click="toggleRow(r)"
:aria-expanded="!!platOpen[r.key]">
<!-- The flex lives on a wrapper INSIDE the cell, never on the <td>. A td with
`display:flex` stops being a table-cell: the browser wraps it in an
anonymous cell that stretches to the row height while the flex box sizes to
its content and keeps the border — so the row separator under column one
landed a pixel above the one under column two, and the hover background
split along the same seam. -->
<td class="lsum">
<div class="lsum-i">
<span class="lcar">▸</span>
<span style="min-width:0">
<b :title="r.description!==r.title ? r.description : null">{{r.title}}</b>
</span>
</div>
</td>
<td>
<!-- One pill PER PROVIDER — not per endpoint, or TikHub's four takes on the
same job repeat four identical pills. A pill is a name, a price only when
the price is a real number, and a ✓. THREE of them, then a +N chip: the
strip never wraps, so a merged row is exactly as tall as a single one.
The full list is one click down, on the provider sub-rows. -->
<div v-if="r.kind==='merged'" class="lprovs" :title="r.provTitle">
<span v-for="pill in r.pills" :key="pill.name" class="pchip">
<b>{{pill.name}}</b><span v-if="pill.price">{{pill.price}}</span><span
v-if="pill.verified" class="vmark">✓</span></span>
<span v-if="r.pillsMore" class="pchip more" :title="r.pillsMoreTitle">+{{r.pillsMore}}</span>
</div>
<span v-else class="lpath mono"><b>{{r.endpoints[0].provider_display||r.endpoints[0].provider}}</b>
<span class="chip" v-if="r.endpoints[0].platform_eligible===false">{{endpointAccessLabel(r.endpoints[0])}}</span><span class="cat-m">{{r.endpoints[0].method}}</span>{{r.endpoints[0].path}}<!--
--><span v-if="r.mgmt" class="chip lkind"
:title="r.endpoints[0].kind==='account' ? 'Manages the provider account itself — lists, campaigns, webhooks' : 'A helper route: token exchange, enum lookups, format cleanup'">{{r.endpoints[0].kind}}</span></span>
</td>
<td class="lprice" :title="r.priceTitle">{{r.price}}<span
v-if="r.priceNative" class="cost-nat">({{r.priceNative}})</span></td>
<td><span v-if="r.verified" class="vmark" title="Called for real against the live API, and the response captured">✓</span><span
v-else class="xmark" title="Documented, but treg has not called it with a live key yet">·</span></td>
</tr>
<tr v-if="platOpen[r.key]" :key="r.key+'::d'" class="cat-d">
<td colspan="4">
<!-- TWO LEVELS on a merged row: it opens to its providers, one collapsed line
each, and a provider opens to its own instruction. Dropping six full
parameter tables on one click buried the comparison the merge exists to
make. A single row has nothing to compare, so it skips the middle level
and its detail renders straight away — same block either way, so the two
paths can never present the instruction differently. -->
<div class="lgrp" v-for="e in r.endpoints" :key="e.id">
<button v-if="r.kind==='merged'" class="lsub" :class="{on:epOpen[e.id]}"
:aria-expanded="!!epOpen[e.id]" @click.stop="toggleEp(e)">
<span class="lcar">▸</span>
<span class="plogo-tile"><img class="plogo" :src="'/logos/'+e.provider+'.svg'" alt="" aria-hidden="true" @error="$event.target.style.visibility='hidden'"></span>
<span class="lsub-label"><b>{{e.provider_display||e.provider}}</b><span v-if="e.name" class="lsub-name">{{e.name}}</span></span>
<span class="chip">{{endpointAccessLabel(e)}}</span>
<span class="lsub-price" :title="costTitle(e.cost)">{{costShort(e.cost)}}</span>
<span v-if="e.verified" class="vmark" :title="'Called for real on '+e.verified">✓</span>
<span v-else class="xmark" title="Documented, but treg has not called it with a live key yet">·</span>
<span v-if="catEndpointConnected(e)" class="chip go" title="You have a connected account with an authorization method that can call this"><span class="godot"></span>connected</span>
<span class="lsub-path mono"><span class="cat-m">{{e.method}}</span>{{e.path}}</span>
</button>
<div class="lep" v-if="r.kind!=='merged' || epOpen[e.id]">
<div class="lep-h">
<!-- On a merged row the sub-row above already names the provider and the
route, so the detail leads with the chips instead of repeating them. -->
<template v-if="r.kind!=='merged'">
<span class="plogo-tile"><img class="plogo" :src="'/logos/'+e.provider+'.svg'" alt="" aria-hidden="true" @error="$event.target.style.visibility='hidden'"></span>
<b>{{e.provider_display||e.provider}}</b>
<span class="mono cat-path"><span class="cat-m">{{e.method}}</span>{{e.path}}</span>
</template>
<span class="lep-chips">
<!-- The short form even here: the chip is a label, and "per result ·
price in provider dashboard" is a sentence. It rides in the facts
list below, where a sentence belongs. -->
<span class="chip">{{endpointAccessLabel(e)}}</span>
<span class="chip" :title="costTitle(e.cost)">{{costShort(e.cost)}}<span
v-if="costNative(e.cost)" class="cost-nat">({{costNative(e.cost)}})</span></span>
<span v-if="e.verified" class="chip ver" :title="'Called for real on '+e.verified+' and the response captured'">verified {{e.verified}}</span>
<span v-else class="chip" title="Documented, but treg has not called it with a live key yet">unverified</span>
<!-- Scope is the difference between "point this at any handle" and "this
only ever sees the account you connected", which changes what you
can build. -->
<span v-if="e.scope==='own_account'" class="chip own"
title="Reads the account YOU connect via OAuth, not arbitrary public accounts">your account</span>
<span v-else-if="e.scope==='any_account'" class="chip any"
title="Reads any public account, page or query — no OAuth connection to that account needed">any account</span>
<span v-if="e.tier && e.tier!=='core'" class="chip">{{e.tier}}</span>
</span>
</div>
<p class="cat-sum">{{e.summary||'No summary in the catalog for this endpoint.'}}</p>
<!-- TABS. What you SEND and what comes BACK are two documents, and stacking
them made the expansion a page you scrolled rather than read. The bar
also carries the two things you want without scrolling at all: the
provider's docs, and the Connect button — the one action on this page
that unblocks every row, so it gets the strongest treatment the design
language has (ink fill), not a ghost button below the fold. -->
<div class="ltabs">
<button class="ltab" :class="{on:epTabOf(e)==='req'}" @click.stop="setEpTab(e,'req')">Request</button>
<!-- No captured response means no tab and no placeholder: a greyed-out tab
is a promise the catalog can't keep, and it draws the eye to the one
thing that isn't there. Endpoints without an example just have one
tab, which reads as a label for the pane under it. -->
<button v-if="e.has_example" class="ltab" :class="{on:epTabOf(e)==='res'}"
@click.stop="setEpTab(e,'res')">Example response</button>
<span class="ltabs-r">
<a v-if="e.docs_url" class="btn sm" :href="e.docs_url" target="_blank" rel="noopener" @click.stop>Docs ↗</a>
<a v-else-if="provFact(e.provider,'pricing_url')" class="btn sm" :href="provFact(e.provider,'pricing_url')" target="_blank" rel="noopener" @click.stop>Pricing ↗</a>
<!-- Always offered: the drawer's access dry-run says how (or whether) THIS
org can call it, and disables Run with the reason when it can't. -->
<!-- OAuth providers can't be served on treg's key (they act AS your account),
so Connect is their primary CTA and Try-it is secondary. Key/token
providers are the reverse: Try-it (treg's key) leads, own key is optional. -->
<!-- Calling costs money and needs a team, so a public visitor gets the
way in rather than a button that can only 401. Everything else in
the expander — parameters, limits, rate card, the copyable command
— is open, and is the part worth reading before you sign up. -->
<button class="btn sm" :class="{primary:!mkOauth(e.provider)}" @click.stop="publicCatalog ? openSignin() : openEpTry(e)"
:title="publicCatalog ? 'New verified accounts get $1.00 once on an eligible team, no card' : 'Call it now, or copy the agent / CLI / API way to run it'">▶ Try it</button>
<span v-if="catEndpointConnected(e)" class="chip go" title="You have a connected account with an authorization method that can call this"><span class="godot"></span>Connected</span>
<button v-else-if="mkOauth(e.provider)" class="btn sm primary" @click.stop="publicCatalog ? openSignin() : openProvider(e.provider)"
:title="endpointConnectLabel(e)+' — calls act as your account'">{{endpointConnectLabel(e)}}</button>
<!-- Lands on the Catalog's Platform tab focused on this provider — the
whole key shelf in view, not a dead-end detail page — so the user
sees where their key lives among the rest before they paste it. -->
<button v-else-if="mkKnown(e.provider)" class="btn sm" @click.stop="publicCatalog ? openSignin() : goByok(e.provider)"
:title="'Register your own '+(e.provider_display||e.provider)+' key — your key wins over treg\'s and those calls are never metered'">Bring your own key</button>
</span>
</div>
<div v-show="epTabOf(e)==='req'">
<!-- What you have to SEND. The captured response answered "what comes back"
while this half was missing, which made every endpoint look uncallable
until you left for the provider's docs. Query first, then path, then
body: the order you fill them in for the common GET case. -->
<div class="prm" v-if="paramSections(e).length || (e.input && e.input.note)">
<div class="prm-h">Parameters</div>
<p class="prm-note" v-if="e.input && e.input.note">{{e.input.note}}</p>
<div class="prm-sec" v-for="s in paramSections(e)" :key="s.key">
<div class="prm-loc">{{s.label}}<span v-if="s.type" class="prm-type mono">{{s.type}}</span></div>
<table class="prm-t">
<tr><th>Name</th><th>Type</th><th>Required</th><th>Notes</th></tr>
<tr v-for="p in s.rows" :key="p.name">
<td class="prm-n mono">{{p.name}}</td>
<td class="prm-ty mono">{{p.type||'—'}}</td>
<td><span v-if="p.required" class="prm-req">required</span><span v-else class="prm-opt">optional</span></td>
<td class="prm-d">
<span v-if="p.note">{{p.note}}</span>
<span v-if="p.example!=null" class="prm-ex mono">e.g. {{fmtExample(p.example)}}</span>
</td>
</tr>
</table>
</div>
</div>
<p v-else class="prm-none">The catalog has no parameter reference for this endpoint yet — the provider's docs have them.</p>
<!-- The provider-wide facts an expanded row needs and a table cell can't
hold: how it meters, what it rate-limits, where the rate card lives. -->
<ul class="lfacts" v-if="epFacts(e).length"><li v-for="(f,fi) in epFacts(e)" :key="fi">{{f}}</li></ul>
<!-- The line that actually runs it. `treg call` proxies the key server-side,
so this is paste-ready with no key on the machine that runs it. -->
<!-- The line that actually runs it, at the bottom of the Request tab — the
last thing you read before you go and run it. -->
<div class="lcall" v-if="e.call_template">
<code class="mono">{{e.call_template}}</code>
<button class="btn sm" @click.stop="copyCall(e)">{{platCopied===e.id?'✓ copied':'Copy'}}</button>
</div>
<div class="lep-id mono">{{e.id}}</div>
</div>
<!-- Fetched when the tab is first opened, never with the page: a platform can
carry hundreds of endpoints and the captured responses are the heaviest
thing in the catalog. -->
<div class="cat-ex" v-show="epTabOf(e)==='res'">
<div v-if="!platEx[e.id] || platEx[e.id].loading" class="mk-quiet" style="font-size:12px">Loading the captured response…</div>
<div v-else-if="platEx[e.id].err" class="mk-quiet" style="font-size:12px">{{platEx[e.id].err}}</div>
<pre v-else class="code">{{platEx[e.id].text}}</pre>
</div>
</div>
</div>
</td>
</tr>
</template>
</template>
</table></div>
<div v-else class="mk-empty" style="margin-top:14px">Nothing on this platform matches that filter — clear it, or
<button class="lclear" @click="platClearFilters">start over</button>.</div>
</template>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="tut-head">
<div style="min-width:0">
<button class="btn sm" style="margin-bottom:10px" @click="go('connections')">← Catalog</button>
<div class="pl-row" style="gap:13px">
<span class="plogo-tile" style="width:44px;height:44px;flex:0 0 44px;border-radius:11px">
<img class="plogo" style="width:27px;height:27px" :src="'/logos/'+mkProvider.service+'.svg'" alt="" aria-hidden="true" @error="$event.target.style.visibility='hidden'">
</span>
<div style="min-width:0">
<h1 style="margin:0">{{mkProvider.display_name}}</h1>
<p class="sub" style="margin:0">{{mkProvider.category}} · <span class="mono">{{mkProvider.base_url}}</span></p>
</div>
</div>
<p class="sub" style="margin:12px 0 0;max-width:64ch">{{mkProvider.summary}}</p>
</div>
<div class="tut-actions">
<a v-if="mkProvider.docs_url" class="btn sm" :href="mkProvider.docs_url" target="_blank" rel="noopener">API docs ↗</a>
<button class="btn sm primary" :disabled="!mkProvider.configured || connBusy" @click="startConnect(mkProvider)">
{{mkConns.length ? 'Add account' : 'Connect'}}
</button>
</div>
</div>
<div v-if="connErr" class="banner" style="margin-top:12px">{{connErr}}</div>
<div v-for="c in mkNeedsCred" :key="'n'+c.id" class="banner" style="margin-top:12px">
<div><b>{{c.name}}</b> is connected, but can't call the API on its own yet. {{c.extra_credential_note}}</div>
<div style="display:flex;gap:8px;margin-top:10px;align-items:center;flex-wrap:wrap">
<input class="bindinput" style="flex:1;min-width:220px" type="password"
:placeholder="c.extra_credential_label||'Second credential'"
v-model="extraCred[c.id]" @keyup.enter="saveExtraCred(c)"/>
<button class="btn sm primary" :disabled="!extraCred[c.id] || extraBusy===c.id" @click="saveExtraCred(c)">
{{extraBusy===c.id?'Saving…':'Save & finish setup'}}
</button>
</div>
</div>
<div v-if="!mkProvider.configured" class="banner" style="margin-top:12px">
This server holds no client credentials for {{mkProvider.display_name}}, so the connect flow can't run here.
</div>
<div class="tgroup">
<div class="tgh">Connected accounts <span class="tgh-n">{{mkConns.length}}</span>
<span class="tgh-hint">each account gets its own tool name, so an agent can call a specific one</span></div>
<div v-if="!mkConns.length" class="mk-empty">
No accounts yet. Connecting takes you to {{mkProvider.display_name}} to approve — treg keeps the
credential server-side and injects it on every call.
</div>
<div class="ttable-wrap" v-else><table class="ttable">
<tr v-for="c in mkConns" :key="c.id">
<td class="tn">
<b>{{c.identity_label || c.name}}</b>
<span class="sub" style="display:block;font-size:11px;overflow-wrap:anywhere" :title="c.resource_ref">
{{c.resource_name || c.resource_ref || (c.supports_discovery ? 'no '+(c.resource_label||'account')+' chosen yet' : 'whole account')}}
</span>
</td>
<td class="th">
<!-- The tool name is the whole point of several accounts: it is what the agent types. -->
<span class="mono" :title="'treg call '+c.name">{{c.name}}</span>
</td>
<td class="ta">
<span v-if="c.health==='ok'" class="chip ok" title="A real upstream call succeeded with this credential">working</span>
<span v-else-if="c.health==='setup_required'" class="chip warn" :title="c.health_detail||'Account setup is required'">setup required</span>
<span v-else-if="c.health==='invalid'" class="chip warn" :title="c.last_error||'The last upstream call failed'">failing</span>
<span v-if="c.expiry_state==='expired'" class="chip warn" title="This credential has expired — reconnect">expired</span>
<span v-else-if="c.expiry_state==='expiring'" class="chip warn" :title="'Expires '+c.expires_at">expiring</span>
<span v-if="!c.refreshable" class="chip" title="treg cannot renew this one unattended — it must be reconnected by hand when it expires">manual renew</span>
<span v-if="c.extra_credential_note" class="chip warn" :title="c.extra_credential_note">needs a second credential</span>
<span v-for="cap in (c.capabilities||[])" :key="cap" class="chip ok">{{cap}}</span>
</td>
<td class="tx" @click.stop>
<button v-if="(c.missing_capabilities||[]).length" class="btn sm" @click="startConnect(mkProvider, c)"
:title="'Ask for '+c.missing_capabilities.join(', ')+' as well'">Add {{c.missing_capabilities.join(' + ')}}</button>
<button v-if="c.supports_discovery" class="btn sm" @click="openResources(c)"
:title="'Choose which '+(c.resource_label||'account')+' this connection uses'">Choose {{c.resource_label||'account'}}</button>
<button class="btn sm" @click="reconnect(c)" title="Re-consent to refresh this account">Reconnect</button>
<button class="btn sm ico" :class="{danger:confirmDisc===c.id}" @click="disconnect(c)" :title="confirmDisc===c.id?'Click again to disconnect':'Disconnect'">✕</button>
</td>
</tr>
</table></div>
</div>
<div class="tgroup">
<div class="tgh">Permissions <span class="tgh-hint">what {{mkProvider.display_name}} is asked to grant — hover a line for the exact scope</span></div>
<div class="mk-perms">
<div v-for="cap in (mkProvider.permission_capabilities||mkProvider.capabilities)" :key="cap" class="mk-perm">
<div class="mk-perm-h">
<b>{{mkCapabilityLabel(cap)}}</b>
<span v-if="mkGranted.has(cap)" class="chip ok">granted</span>
<span v-else class="mk-quiet">not requested yet</span>
</div>
<p v-if="mkCapabilityIntro(cap)" class="mk-quiet" style="margin:8px 0 0">{{mkCapabilityIntro(cap)}}</p>
<ul class="mk-perm-l">
<li v-for="d in mkCapabilityDetails(cap)" :key="d.scope||d.label" :title="d.scope||null">
<span class="mk-tick">✓</span>{{d.label}}
</li>
</ul>
</div>
</div>
</div>
<!-- Cross-link into the endpoint catalog: from "I hold this account" to "here is what it
pulls, next to every other provider that serves the same platform". -->
<div class="tgroup" v-if="mkPlatforms.length">
<div class="tgh">Covered in the catalog <span class="tgh-n">{{mkPlatforms.length}}</span>
<span class="tgh-hint">the platforms {{mkProvider.display_name}} serves — compare its endpoints with the other providers'</span></div>
<div class="mk-filters" style="margin:0">
<button v-for="pl in mkPlatforms" :key="pl.slug" class="mk-chip" @click="openPlatform(pl.slug)">{{pl.label}} <span>{{pl.endpoints}}</span></button>
</div>
</div>
</template>
+119
View File
@@ -0,0 +1,119 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<!-- Two programs, one page, forked at the header: the credit referral program every user
gets, and the invite-only cash tier behind it. A fork rather than a card under the
referral column, because a card there read as step four of the referral program. The
cash tier is hand-approved on purpose — hand-approval IS the anti-gaming design
(money.md §Referrals) — so it is a form, not a self-serve toggle, and it shows to
everyone, team or not, so a partner without a team yet can still apply. -->
<h1>{{refTab==='partner' ? 'Affiliate partner' : 'Refer a friend'}}</h1>
<p class="sub" v-if="refTab==='partner'">Revenue share, paid in cash. By invitation.</p>
<p class="sub" v-else>They get {{money(ref.terms.referred_micro)}} when they add
{{money(ref.terms.min_topup_micro)}}. You get {{money(ref.terms.referrer_micro)}},
{{ref.terms.hold_days}} days later.</p>
<div class="tabs" style="max-width:720px">
<button :class="{active:refTab==='friend'}" @click="refTab='friend'">Refer a friend</button>
<button :class="{active:refTab==='partner'}" @click="refTab='partner'">Affiliate partner</button>
</div>
<div v-if="refTab==='partner'" style="max-width:720px">
<div class="card">
<div class="lbl">Who it's for</div>
<p class="sub" style="margin:6px 0 0">People who send real volume: an audience, a newsletter,
a community, or a team that builds on treg for clients.</p>
<div class="lbl" style="margin-top:16px">What you earn</div>
<p class="sub" style="margin:6px 0 0">A share of what your referrals top up, paid to you in
cash monthly. Not credit, and no {{ref.cap.limit}}-referral cap.</p>
<div class="lbl" style="margin-top:16px">How to get in</div>
<p class="sub" style="margin:6px 0 14px">Invite only. Tell us about your audience and how
you'd send people to treg, and we'll get back to you.</p>
<a class="btn primary sm" href="https://forms.gle/5N3DyVPrNJkrGuxF6" target="_blank"
rel="noopener" @click="track('affiliate_apply_clicked')" style="text-decoration:none">Apply to be an affiliate partner</a>
</div>
<p class="sub" style="margin:12px 0 0">Just want to tell a friend? The
<a href="#" @click.prevent="refTab='friend'">refer-a-friend</a> program pays
{{money(ref.terms.referrer_micro)}} in credit per friend, no application needed.</p>
</div>
<template v-if="refTab==='friend'">
<p v-if="ref.loading" class="sub">Loading…</p>
<!-- ONE column for the whole view. The children carry no max-width of their own: three
different ones is what made the card, the tiles and the table start and end at three
different x-positions. -->
<div v-else style="max-width:720px">
<!-- Everyone with a team gets a link — including free-tier users, who on a product
pitched as "$1.00 free, no card" are most users and the likeliest to tell a friend.
This branch is now only the degenerate case (no team to pay the reward into), kept
because the reward has to land SOMEWHERE and a silent zero is the worst version. -->
<div v-if="!ref.eligible" class="card" style="margin-top:14px">
<div class="lbl">Create a team to unlock your link</div>
<p class="sub" style="margin:6px 0 12px">Referral rewards are paid as credit into a
team you own, so you'll need one first.</p>
<button class="btn primary sm" @click="go('orgs')">Go to teams</button>
</div>
<template v-else>
<div class="card" style="margin-top:14px">
<div class="lbl">Your link</div>
<div style="display:flex;gap:8px;margin-top:8px">
<input readonly :value="ref.link" @focus="$event.target.select()"
style="flex:1;min-width:0;font-family:var(--mono);font-size:12px">
<button class="btn sm" @click="copyRefLink" style="flex:none">{{refCopied?'Copied':'Copy'}}</button>
</div>
<p class="sub" style="margin:10px 0 0" v-if="ref.credit_org">
Your reward lands in <b>{{ref.credit_org.name}}</b>.</p>
</div>
<!-- `.stat` expects `.n` (the figure) then `.l` (the label), in that order — any other
class names render as unstyled text, which is exactly what a hand-rolled k/v pair
did here. -->
<!-- auto-FIT, not the sheet's auto-fill: at this width auto-fill lays out four 150px
tracks and leaves the fourth empty, so three tiles stop short of the column's
right edge while the card above and the table below reach it. auto-fit collapses
the empty track and the three stretch to fill. -->
<div class="statgrid" style="margin-top:14px;grid-template-columns:repeat(auto-fit,minmax(150px,1fr))">
<div class="stat"><div class="n">{{ref.totals.signed_up}}</div><div class="l">signed up</div></div>
<div class="stat"><div class="n">{{ref.totals.topped_up}}</div><div class="l">topped up</div></div>
<div class="stat"><div class="n">{{money(ref.totals.earned_micro)}}</div><div class="l">earned</div></div>
</div>
<p v-if="ref.totals.pending_micro" class="sub" style="margin:-8px 0 0">
{{money(ref.totals.pending_micro)}} on the way — rewards land
{{ref.terms.hold_days}} days after your friend adds funds.</p>
<!-- The cap refusal IS the commercial conversation, same posture as a daily-cap
refusal: it names the limit and points at a human rather than silently paying 0. -->
<div v-if="ref.cap.paid >= ref.cap.limit" class="card"
style="margin-top:14px;border-color:var(--accent)">
<div class="lbl">You've hit the {{ref.cap.limit}}-referral limit</div>
<p class="sub" style="margin:6px 0 0">That's as far as the self-serve program goes.
If you're sending real volume, switch to the Affiliate partner tab above — we pay affiliate partners in cash.</p>
</div>
<div class="grp" style="margin:20px 0 8px">Your referrals</div>
<p v-if="!ref.referrals.length" class="sub" style="margin:0">
Nobody yet. Share your link above.</p>
<!-- House-style table: bare `<table>` is ALREADY a card (background, border, radius)
and `th,td` already carry 10px/13px padding. Overriding that padding to `8px 0`
is what pushed the amount flush against the card's own right edge. -->
<table v-else>
<tr><th>Who</th><th>Signed up</th><th>Status</th><th style="text-align:right">Reward</th></tr>
<tr v-for="(r,i) in ref.referrals" :key="i">
<td>{{r.email || '—'}}</td>
<td class="sub" style="white-space:nowrap">{{bhistDate(r.signed_up_at)}}</td>
<td>{{refStatus(r)}}</td>
<td style="text-align:right;font-family:var(--mono);white-space:nowrap">
<span v-if="r.reward_micro">{{money(r.reward_micro)}}</span>
<span v-else class="muted">—</span>
</td>
</tr>
</table>
</template>
</div>
</template>
</template>
+84
View File
@@ -0,0 +1,84 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div v-if="orgMsg" class="tut-notice" style="max-width:660px;margin-bottom:14px;display:flex;justify-content:space-between;gap:12px;align-items:flex-start">
<span>{{orgMsg}}</span><button class="btn sm ico" @click="orgMsg=''" aria-label="Dismiss">✕</button>
</div>
<div v-if="secretErr" class="banner err" style="max-width:660px;margin-bottom:14px;display:flex;justify-content:space-between;gap:12px;align-items:flex-start">
<span>{{secretErr}}</span><button class="btn sm ico" @click="secretErr=''" aria-label="Dismiss">✕</button>
</div>
<div class="tut-head">
<div><h1>Bring your own keys &amp; skills</h1>
<div class="tabs" style="margin:8px 0 4px">
<button @click="go('tools')">Skills &amp; tools</button>
<button class="active">Secrets</button>
</div>
<p class="sub" style="margin:0">The credentials your tools inject. Values are encrypted server-side and never shown.</p></div>
</div>
<div class="ttable-wrap" v-if="secrets.length" style="margin-bottom:16px"><table class="ttable">
<tr v-for="s in secrets" :key="s.id">
<td class="tn"><b>{{s.name}}</b></td>
<td class="ta"><span class="chip" :class="s.kind">{{s.kind}}</span></td>
<td class="th">owner: {{short(s.owner)}}</td>
<td class="tx"><button class="btn sm ico" :class="{danger:confirmDelSecret===s.id}" @click="deleteSecret(s)" :title="confirmDelSecret===s.id?'Click again to delete':'Delete'">✕</button></td>
</tr>
</table></div>
<p v-else class="sub">No secrets yet — add one below, or bulk-load everything at once (setup guide under the form).</p>
<div class="tgh" style="margin-top:6px">Add {{secretRows.length>1?'secrets':'a secret'}}</div>
<p class="sub" v-if="keyNameSuggestions.length" style="max-width:660px;margin:0 0 8px;font-size:12px">
Name a key after its catalog provider — e.g. <span class="mono">{{keyNameSuggestions.slice(0,3).map(p=>p.service).join(', ')}}</span> —
and catalog calls use it automatically. The name field suggests every provider that takes a pasted key.</p>
<p class="sub" style="margin:2px 0 8px">Tip: paste a whole <span class="mono">.env</span> into the name field — it splits into rows automatically.</p>
<div v-for="(row,i) in secretRows" :key="i" class="field" style="max-width:660px;flex-wrap:wrap;margin-bottom:8px">
<input v-model="row.name" list="secret-name-suggest" placeholder="name, e.g. apollo" style="min-width:150px" @paste="pasteEnv($event,i,'name')" @keyup.enter="addSecrets"/>
<input v-model="row.value" :type="row.kind==='param'?'text':'password'" :placeholder="row.kind==='param'?'value, e.g. a project id (not secret)':'value (encrypted server-side)'" style="min-width:190px" @paste="pasteEnv($event,i,'value')" @keyup.enter="addSecrets"/>
<select v-model="row.kind" class="msel"><option>env</option><option>oauth</option><option>secret_file</option><option value="param">param (non-secret)</option></select>
<button v-if="secretRows.length>1" class="btn sm ico" @click="secretRows.splice(i,1)" title="Remove row">✕</button>
<!-- The ladder matches by NAME EQUALITY, so the hint either confirms a name the catalog
will find, or offers the exact one when the row is a near miss (APOLLO_API_KEY). -->
<span v-if="secretNameHint(row)" class="sub" style="flex-basis:100%;font-size:11.5px;margin:-2px 0 0">
<template v-if="secretNameHint(row).ok">✓ {{secretNameHint(row).text}}</template>
<template v-else>{{secretNameHint(row).text}}
<a href="#" @click.prevent="row.name=secretNameHint(row).service">rename to “{{secretNameHint(row).service}}”</a></template>
</span>
</div>
<!-- Every provider a key can be pasted into, as native input suggestions on the name field. -->
<datalist id="secret-name-suggest">
<option v-for="p in keyNameSuggestions" :key="p.service" :value="p.service">{{p.display_name}}</option>
</datalist>
<p class="sub" v-if="secretRows.some(r=>r.kind==='param')" style="margin:2px 0 8px">A param is non-secret config (project id, org id) — injected alongside a credential into CLI env or an HTTP query.</p>
<div class="field" style="max-width:660px">
<button class="btn primary" @click="addSecrets" :disabled="secretBusy||!secretRowsReady">{{secretBusy?'…':(secretRowsReady>1?('Add '+secretRowsReady+' secrets'):'Add secret')}}</button>
<button class="btn" @click="secretRows.push({name:'',value:'',kind:'env'})">+ row</button>
<button v-if="secretRows.length>1" class="btn" @click="secretRows=[{name:'',value:'',kind:'env'}]">Clear</button>
</div>
<div v-if="!secrets.length" style="margin-top:20px">
<div style="max-width:660px;border:1px solid var(--line);border-radius:16px;padding:20px 22px;background:var(--panel2)">
<h3 style="margin:0 0 6px;font-size:15px">Add your own keys &amp; skills</h3>
<p class="sub" style="margin:0 0 12px">Bulk-load your keys (and skills) from your machine — each lands here encrypted, referenced by name.</p>
<div class="seg" style="margin-bottom:10px">
<button :class="{on:emptyTab==='agent'}" @click="emptyTab='agent'">Agent instruction</button>
<button :class="{on:emptyTab==='manual'}" @click="emptyTab='manual'">Manual</button>
</div>
<template v-if="emptyTab==='manual'">
<div class="lbl">1 · Install the CLI &amp; sign in</div>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('curl -fsSL '+proxy+'/install.sh | sh\ntreg login','es1')">{{startCopied==='es1'?'✓ copied':'copy'}}</button><pre><span class="hl-cmd">curl</span> <span class="hl-flag">-fsSL</span> <span class="hl-str">{{proxy}}/install.sh</span> | sh
<span class="hl-cmd">treg</span> login</pre></div>
<div class="lbl" style="margin-top:12px">2 · Preview, then upload — it scans your <span class="mono">.env</span> + skill folders, you pick what to share.</div>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('treg scan\ntreg upload --all','es3')">{{startCopied==='es3'?'✓ copied':'copy'}}</button><pre><span class="hl-comment"># preview what's here (read-only)</span>
<span class="hl-cmd">treg</span> scan
<span class="hl-comment"># register keys + skills</span>
<span class="hl-cmd">treg</span> upload <span class="hl-flag">--all</span></pre></div>
</template>
<template v-else>
<p class="sub" style="margin:0 0 8px">One line, token included — your agent reads llms.txt, installs the CLI, signs in, and registers your skills + keys (read-only scan first, you approve).</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyAgentGuide('agent')">{{agentRowCopied==='agent'?'✓ copied':'copy'}}</button><pre style="max-height:220px;overflow:auto">{{buildAgentPrompt('agent', true)}}</pre></div>
</template>
</div>
</div>
</template>
+589
View File
@@ -0,0 +1,589 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div class="tut-head">
<div><h1>Team: {{activeName}}<span v-if="isPersonal(activeOrg)" class="chip" style="margin-left:8px;vertical-align:middle">personal</span></h1>
<p class="sub" style="margin:0">You are <b>{{activeRole}}</b> here. <span class="muted">Switch, create or join a team from the picker at the top of the page.</span></p></div>
</div>
<!-- settings for the ACTIVE team (switching lives in the sidebar) -->
<div v-if="activeOrg" style="margin-top:20px">
<div class="tabs" style="margin:4px 0 16px">
<button v-if="canAdmin" :class="{active:orgTab==='members'}" @click="orgTab='members'">Members</button>
<button :class="{active:orgTab==='keys'}" @click="orgTab='keys'; loadApiKeys()">API Keys</button>
<button v-if="canAdmin" :class="{active:orgTab==='projects'}" @click="orgTab='projects'">Projects</button>
<button v-if="canAdmin" :class="{active:orgTab==='policy'}" @click="orgTab='policy'">Policy</button>
<button v-if="canAdmin" :class="{active:orgTab==='billing'}" @click="orgTab='billing'">Billing</button>
<button :class="{active:orgTab==='danger'}" @click="orgTab='danger'; resetRenameForm()">Team settings</button>
</div>
<p v-if="!canAdmin && !['keys','danger'].includes(orgTab)" class="sub" style="margin-bottom:12px">Members, projects and policy are visible to admins.</p>
<p v-if="myUsage && myUsage.cap>=0" class="sub" style="margin:-6px 0 12px">Your usage today: <b>{{myUsage.used_today}} / {{myUsage.cap}}</b> calls.</p>
<div v-if="orgErr" class="banner">{{orgErr}}</div>
<div v-if="orgMsg" class="tut-notice" style="margin-bottom:12px;display:flex;justify-content:space-between;gap:12px;align-items:flex-start">
<span>{{orgMsg}}</span><button class="btn sm ico" @click="orgMsg=''" aria-label="Dismiss">✕</button>
</div>
<template v-if="canAdmin && orgTab==='members'">
<div v-if="agentErr" class="banner" style="margin-top:8px">{{agentErr}}</div>
<!-- the token is shown ONCE: make that unmissable -->
<div v-if="newAgent || snipAgent" class="card" style="margin-top:16px" :style="newAgent?'border-color:var(--accent)':''">
<div class="lbl" style="margin-top:0;display:flex;align-items:center;gap:10px">
<template v-if="newAgent">Token for {{newAgent.name}} — copy it now</template>
<template v-else>How to use {{snipAgent.name}}</template>
<button v-if="!newAgent" class="btn sm" style="margin-left:auto" @click="snipAgent=null">Close</button>
</div>
<p v-if="newAgent && newAgent.rotated" class="tut-notice" style="margin:2px 0 10px"><b>The previous key stopped working immediately.</b> Update <b>{{newAgent.name}}</b> everywhere it runs before closing this card.</p>
<p v-if="newAgent" class="sub" style="margin:2px 0 10px">This is the only time it is shown. Put it in the agent's <code>TREG_TOKEN</code>. Anyone holding it can act as {{newAgent.name}}, so keep it in an environment variable or secret manager — never commit or share it.
<span v-if="agentConnected" class="chip ok" style="margin-left:8px">✓ connected — {{newAgent.name}} called in as itself</span>
<span v-else class="muted" style="margin-left:8px">waiting for its first check-in…</span></p>
<p v-else-if="agentTokens[snipAgent.user_id]" class="sub" style="margin:2px 0 10px">Snippets carry the real token (minted this page-load — the server only keeps a hash).</p>
<p v-else class="sub" style="margin:2px 0 10px">The server stores only a hash of the token, so it can't be shown again — these snippets use <span class="mono">$TREG_TOKEN</span>. <button class="btn sm" @click="rotateAgent(snipAgent,true)">⟳ Rotate &amp; fill real token</button> <span class="muted">(the old token stops working)</span></p>
<div v-if="newAgent" class="field">
<input :value="newAgent.token" readonly style="font-family:var(--mono)" @focus="$event.target.select()"/>
<button class="btn primary" @click="copy(newAgent.token,'agenttok')">{{copied==='agenttok'?'Copied':'Copy'}}</button>
<button class="btn" @click="newAgent=null">{{newAgent.rotated?'I’ve updated '+newAgent.name:'Done'}}</button>
</div>
<!-- a bare token is a dead end: hand over the paste-ready thing, like the Tools snippets do -->
<div class="tabs" style="margin:14px 0 8px">
<button :class="{active:agentSnip==='prompt'}" @click="agentSnip='prompt'">Give it to your agent</button>
<button :class="{active:agentSnip==='env'}" @click="agentSnip='env'">Environment</button>
</div>
<div class="lc-codewrap">
<button class="lc-cp" @click="copy(agentSnippet,'agentsnip')">{{copied==='agentsnip'?'✓ copied':'copy'}}</button>
<pre style="max-height:220px;overflow:auto">{{agentSnippet}}</pre>
</div>
<p class="sub" style="margin:6px 0 0;font-size:11.5px">Put this wherever the agent runs — a <span class="mono">.env</span>, a CI secret, or its config.</p>
</div>
<div class="lbl" style="margin-top:14px">Members</div>
<p class="sub" style="margin:-2px 0 8px;font-size:12px">Daily cap = how many calls + runs a member may make per day. <b>-1</b> = unlimited.</p>
<p class="sub" style="margin:-2px 0 8px;font-size:12px"><b>Tools</b> = which endpoints/CLIs a member may call or run. <b>Local run</b> = may run CLIs on their own machine (off = server only). The owner always has full access.</p>
<table>
<tr><th>Email</th><th>Role</th><th style="text-align:right">Today</th><th>Daily cap</th><th>Tools</th><th>Local run</th><th></th></tr>
<template v-for="m in rosterMembers" :key="m.key">
<tr v-if="m.is_observed">
<td><span style="opacity:.45">↳</span> <b>{{m.client}}</b> <span class="chip" title="seen in this member\'s traffic — the runtime reports itself; attribution, not authentication">detected</span>
<div class="muted" style="font-size:11px">runs as {{short(m.member)}} — inherits their full access</div></td>
<td><span class="muted" style="font-size:12px">—</span></td>
<td style="text-align:right" class="muted">{{m.used_today}}</td>
<td colspan="3"><span class="muted" style="font-size:12px">{{m.calls_30d}} calls in 30 days · last seen {{when(m.last_seen)}}</span></td>
<td style="text-align:right"><button class="btn sm" @click="promoteObserved(m)" title="mint this runtime its own token — its own cap, scope and audit trail">Scope this agent</button></td>
</tr>
<tr v-else>
<td>
<template v-if="m.is_agent"><span style="opacity:.45">↳</span> <b>{{m.name}}</b> <span class="chip" :title="m.email">agent</span>
<div class="muted" style="font-size:11px">{{m.created_by?('owned by '+short(m.created_by)):'machine identity'}}</div>
<div v-if="confirmAgent==='rotate-'+m.user_id" class="sub" style="font-size:11px;margin-top:5px;max-width:360px"><b>Its current key will stop immediately.</b> The next card shows the replacement once so you can update {{m.name}}.</div>
<div v-if="confirmAgent==='revoke-'+m.user_id" class="sub" style="font-size:11px;margin-top:5px;max-width:360px"><b>This removes the agent and revokes all its keys.</b> Activity stays for audit; restoring it means creating and configuring it again.</div></template>
<template v-else>{{m.email}}</template>
</td>
<td>
<select v-if="isOwner" class="msel" :value="m.role" @change="setRole(m,$event.target.value)">
<option v-for="r in ['viewer','member','admin','owner']" :key="r" :value="r">{{r}}</option>
</select>
<span v-else class="role" :class="m.role">{{m.role}}</span>
</td>
<td style="text-align:right" class="muted">{{m.used_today}}</td>
<td><input class="msel" type="number" min="-1" step="1" style="width:78px" :value="m.daily_call_cap" @change="setCap(m,$event.target.value)" title="-1 = unlimited"/></td>
<td>
<span v-if="m.role==='owner'" class="chip">All</span>
<button v-else class="btn sm" :class="{active:editAccess===m.user_id}" @click="openAccess(m)" :title="m.tool_access===null?'Access to every tool':'Access to '+m.tool_access.length+' tool(s)'">{{m.tool_access===null?'All':m.tool_access.length+' tools'}} ▾</button>
</td>
<td>
<label class="tgl" :title="m.role==='owner'?'the owner always may run locally':(m.local_run_enabled?'local runs on — click to disable':'local runs off — server only')">
<input type="checkbox" :checked="m.local_run_enabled" :disabled="m.role==='owner'" @change="setLocalRun(m,$event.target.checked)"/>
<span>{{m.local_run_enabled?'on':'off'}}</span>
</label>
</td>
<td style="text-align:right;white-space:nowrap">
<template v-if="m.is_agent">
<span class="row-actions"><button class="btn sm" @click="showAgentSetup(m)" title="how to give this agent its identity">Setup</button>
<button class="btn sm" :class="{danger:confirmAgent==='rotate-'+m.user_id}" @click="rotateAgent(m)" title="issue a new token — the old one stops working immediately">{{confirmAgent==='rotate-'+m.user_id?'Confirm rotate':'Rotate'}}</button>
<button class="btn sm" :class="{danger:confirmAgent==='revoke-'+m.user_id}" @click="revokeAgent(m)">{{confirmAgent==='revoke-'+m.user_id?'Confirm revoke':'Revoke'}}</button></span>
</template>
<button v-else-if="m.role!=='owner'" class="btn sm" :class="{danger:confirmRemove===m.user_id}" @click="removeMember(m)">{{confirmRemove===m.user_id?'Confirm remove':'Remove'}}</button>
</td>
</tr>
<tr v-if="!m.is_observed && editAccess===m.user_id">
<td colspan="7" style="background:color-mix(in srgb,var(--accent) 5%,transparent)">
<div style="padding:6px 2px">
<div class="sub" style="margin-bottom:6px">Tools &amp; skills <b>{{m.email}}</b> may use/see — uncheck to withhold. All checked = full access (and new ones apply automatically).</div>
<div v-if="!accessNames.length" class="muted" style="font-size:12px">No tools registered yet.</div>
<div style="display:flex;flex-wrap:wrap;gap:6px 16px">
<label v-for="n in accessNames" :key="n" class="tgl" style="min-width:150px">
<input type="checkbox" v-model="accessDraft[n]"/><span>{{n}}</span>
</label>
</div>
<template v-if="projects.length">
<div class="sub" style="margin:12px 0 6px">Projects <b>{{m.email}}</b> may use — all checked = every project (and new ones apply automatically). Tools with no project stay visible to everyone.</div>
<div style="display:flex;flex-wrap:wrap;gap:6px 16px">
<label v-for="p in projects" :key="p.id" class="tgl" style="min-width:150px">
<input type="checkbox" v-model="projDraft[p.id]"/><span>{{p.name}}</span>
</label>
</div>
</template>
<p v-else class="sub" style="margin:12px 0 0;font-size:12px">Tip: create a <a href="#" @click.prevent="orgTab='projects'">project</a> to scope members to just part of the team's tools.</p>
<div style="margin-top:10px;display:flex;gap:8px">
<button class="btn sm primary" @click="saveAccess(m)">Save access</button>
<button class="btn sm" @click="editAccess=null">Cancel</button>
<button class="btn sm" @click="setAllAccess(true)">Check all</button>
<button class="btn sm" @click="setAllAccess(false)">Uncheck all</button>
</div>
</div>
</td>
</tr>
</template>
</table>
<div class="lbl" style="margin-top:22px;display:flex;align-items:center;gap:10px">Add to this team
<button v-if="!isPersonal(activeOrg)" class="btn sm" :class="{active:showInvite}" @click="showInvite=!showInvite; if(showInvite)showAddAgent=false">+ Add member</button>
<button class="btn sm" :class="{active:showAddAgent}" @click="openAddAgent()">+ Add agent</button></div>
<p class="sub" style="margin:-2px 0 8px;font-size:12px">A member is a person who signs in. An agent is a machine identity with its own token — capped, scoped and logged as itself. Scope either with <b>Tools</b> and <b>Projects</b> above.</p>
<div v-show="showAddAgent">
<div class="lbl" style="margin-top:18px">New agent</div>
<p v-if="promoteHint" class="sub" style="margin:-2px 0 6px;font-size:12px;color:var(--accent)">{{promoteHint}}</p>
<div class="field" style="max-width:620px">
<input v-model="agentName" placeholder="ci-bot" @keyup.enter="createAgent"/>
<select v-model="agentRole" class="msel"><option>viewer</option><option>member</option><option v-if="isOwner">admin</option></select>
<input v-model.number="agentCap" type="number" min="-1" step="1" class="msel" style="width:96px" title="daily call cap (-1 = unlimited)"/>
<button class="btn primary" @click="createAgent" :disabled="agentBusy||!agentAccessMode">{{agentBusy?'…':'Create'}}</button>
</div>
<div style="max-width:620px;margin:4px 0 8px">
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap">
<span class="sub" style="font-size:12px"><b>Tools:</b></span>
<label class="tgl"><input type="radio" value="all" v-model="agentAccessMode"/><span>All tools</span></label>
<label class="tgl"><input type="radio" value="choose" v-model="agentAccessMode"/><span>Choose tools</span></label>
<span v-if="!agentAccessMode" class="sub" style="font-size:11px;color:var(--accent)">Choose access before creating.</span>
</div>
<div v-if="agentAccessMode==='choose'" style="display:flex;flex-wrap:wrap;gap:6px 16px;margin-top:7px">
<span v-if="!accessNames.length" class="muted" style="font-size:12px">No tools registered yet; this agent will have no tool access.</span>
<label v-for="n in accessNames" :key="n" class="tgl" style="min-width:150px"><input type="checkbox" v-model="agentToolSel[n]"/><span>{{n}}</span></label>
</div>
</div>
<div v-if="projects.length" style="max-width:620px;margin:4px 0 0;display:flex;gap:6px 16px;flex-wrap:wrap;align-items:center">
<span class="sub" style="font-size:12px">Projects (all checked = every project):</span>
<label v-for="p in projects" :key="p.id" class="tgl"><input type="checkbox" v-model="agentProjSel[p.id]"/><span>{{p.name}}</span></label>
</div>
<p class="sub" style="margin:-2px 0 0;font-size:12px">Cap <b>-1</b> = unlimited. An agent can never be an owner, and can never sign in — its token is the only way to act as it.</p>
</div>
<template v-if="!isPersonal(activeOrg)">
<div v-show="showInvite">
<div class="field" style="max-width:560px">
<input v-model="inviteEmail" type="email" placeholder="teammate@email.com" @keyup.enter="sendInvite"/>
<select v-model="inviteRole" class="msel"><option>viewer</option><option>member</option><option v-if="isOwner">admin</option></select>
<button class="btn primary" @click="sendInvite" :disabled="orgBusy">{{orgBusy?'…':'Invite'}}</button>
</div>
<div style="max-width:560px;margin:-4px 0 4px;display:flex;gap:16px;align-items:center;flex-wrap:wrap">
<label class="tgl"><input type="radio" :checked="!inviteCustomize" @change="inviteCustomize=false"/><span>All tools</span></label>
<label class="tgl"><input type="radio" :checked="inviteCustomize" @change="openInviteCustomize()"/><span>Customize</span></label>
<label class="tgl"><input type="checkbox" v-model="inviteLocalRun"/><span>Local runs allowed</span></label>
</div>
<div v-if="inviteCustomize" style="max-width:560px;margin-bottom:8px">
<div class="sub" style="font-size:12px;margin-bottom:4px">Tools this member may use (all checked by default — uncheck to withhold):</div>
<div v-if="!tools.length" class="muted" style="font-size:12px">No tools registered yet.</div>
<div style="display:flex;flex-wrap:wrap;gap:6px 16px">
<label v-for="n in accessNames" :key="n" class="tgl" style="min-width:150px"><input type="checkbox" v-model="inviteToolSel[n]"/><span>{{n}}</span></label>
</div>
</div>
<div v-if="lastInvite" class="tut-notice" style="max-width:560px">
Invite sent to <b>{{lastInvite.email}}</b> ({{lastInvite.role}}). They can sign in and accept it code-free - or use this one-time code:
<div style="display:flex;gap:8px;align-items:center;margin-top:6px"><code class="mono" style="word-break:break-all;font-size:11px">{{lastInvite.code}}</code><button class="btn sm" @click="copy(lastInvite.code)">{{copied?'✓ copied':'⧉ copy'}}</button></div>
</div>
<div class="lbl" style="margin-top:18px">Pending invites</div>
<table v-if="orgInvites.length">
<tr><th>Email</th><th>Role</th><th>Invited by</th><th>Expires</th><th></th></tr>
<tr v-for="inv in orgInvites" :key="inv.id">
<td>{{inv.email}}</td><td><span class="role" :class="inv.role">{{inv.role}}</span></td>
<td class="muted">{{short(inv.invited_by)}}</td><td class="muted">{{inv.expires_at?until(inv.expires_at):'never'}}</td>
<td style="text-align:right"><button class="btn sm" @click="revokeInvite(inv)">Revoke</button></td>
</tr>
</table>
<p v-else class="sub">No pending invites.</p>
</div>
</template>
</template>
<!-- API KEYS tab. Members see their own keys; admins see the full team inventory. -->
<template v-if="orgTab==='keys'">
<div class="tut-head" style="align-items:flex-end;margin-bottom:14px"><div><div class="lbl">API Keys</div>
<p class="sub" style="margin:2px 0 0">Use more than one key without making a new team member. Permissions, limits, and billing stay with the assigned identity.</p></div></div>
<div v-if="keyErr" class="banner">{{keyErr}}</div>
<div v-if="keyMsg" class="tut-notice" style="margin-bottom:12px;display:flex;justify-content:space-between;gap:12px;align-items:flex-start">
<span>{{keyMsg.text}}</span><span style="white-space:nowrap"><button v-if="keyMsg.agent" class="btn sm" @click="orgTab='members'; keyMsg=null">Open Members</button><button class="btn sm ico" @click="keyMsg=null" aria-label="Dismiss">✕</button></span>
</div>
<div v-if="newApiKey" class="card" style="border-color:var(--accent);margin-bottom:16px">
<div class="lbl"><template v-if="newApiKey.kind==='default_human'">Default key rotated — copy the new key</template><template v-else-if="newApiKey.assigned_type==='agent'">New key for {{newApiKey.assigned_name}}</template><template v-else>{{newApiKey.name}} — copy it now</template></div>
<p v-if="newApiKey.kind==='default_human'" class="tut-notice" style="margin:2px 0 10px"><b>The previous key stopped working immediately.</b> Update every CLI, MCP installation, or other client using this team's Default key.</p>
<p v-if="newApiKey.assigned_type==='agent' && newApiKey.rotated" class="tut-notice" style="margin:2px 0 10px"><b>The previous key stopped working immediately and is now hidden from this list.</b> Update {{newApiKey.assigned_name}} everywhere it runs before closing this card.</p>
<p class="sub" style="margin:2px 0 10px"><template v-if="newApiKey.kind==='default_human'">This team-specific key remains revealable on Getting Started.</template><template v-else>This is the only time treg shows the full key. The server stores only its hash.<template v-if="newApiKey.assigned_type==='agent'"> Anyone holding it can act as {{newApiKey.assigned_name}}, so keep it in an environment variable or secret manager — never commit or share it.</template></template></p>
<div class="field"><input :value="newApiKey.secret" readonly class="mono" @focus="$event.target.select()"/><button class="btn primary" @click="copy(newApiKey.secret,'managedkey')">{{copied==='managedkey'?'Copied':'Copy'}}</button><button class="btn" @click="newApiKey=null">{{newApiKey.assigned_type==='agent'?'I’ve updated '+newApiKey.assigned_name:'Done'}}</button></div>
<p v-if="newApiKey.kind==='default_human'" class="sub" style="margin:6px 0 0;font-size:11.5px">CLI users can run <code>treg login</code> again to save the new key. Update MCP and other clients manually.</p>
<template v-if="newApiKey.assigned_type==='agent'">
<div class="tabs" style="margin:14px 0 8px"><button :class="{active:agentSnip==='prompt'}" @click="agentSnip='prompt'">Give it to your agent</button><button :class="{active:agentSnip==='env'}" @click="agentSnip='env'">Environment</button></div>
<div class="lc-codewrap"><button class="lc-cp" @click="copy(agentSnippet,'agentsnip')">{{copied==='agentsnip'?'✓ copied':'copy'}}</button><pre style="max-height:220px;overflow:auto">{{agentSnippet}}</pre></div>
<p class="sub" style="margin:6px 0 0;font-size:11.5px">Replace <code>TREG_TOKEN</code> in every environment, CI secret, secret manager, or agent config where {{newApiKey.assigned_name}} runs.</p>
</template>
</div>
<div v-if="activeRole!=='viewer'" class="field" style="max-width:620px;margin-bottom:18px"><input v-model="keyName" :class="{'field-invalid':keyNameInvalid}" :aria-invalid="keyNameInvalid" placeholder="New key name" maxlength="80" @input="keyNameInvalid=false" @keyup.enter="createApiKey"/><button class="btn primary" :disabled="keyBusy" @click="createApiKey">{{keyBusy?'…':'Create key'}}</button></div>
<template v-for="group in apiKeyGroups" :key="group.identity">
<div class="lbl" style="margin-top:16px">{{group.name}} <span class="chip">{{group.type}}</span><span v-if="group.type==='agent' && group.name!==group.identity" class="muted mono" style="margin-left:8px;text-transform:none">{{group.identity}}</span></div>
<table class="key-table"><tr><th>Key</th><th>Status</th><th>Created</th><th>Last used</th><th></th></tr>
<tr v-for="k in group.rows" :key="k.id">
<td class="key-identity"><input v-if="editKey===k.id" v-model="editKeyName" maxlength="80" class="msel" @keyup.enter="renameApiKey(k)"/><template v-else><div class="key-primary"><span v-if="group.type==='human' && k.assigned_type==='agent'" class="muted" aria-hidden="true">↳</span><b>{{group.type==='human' && k.assigned_type==='agent'?k.assigned_name:k.name}}</b><span class="chip">{{keyKind(k.kind)}}</span></div><div class="key-meta mono">{{maskedKey(k)}}</div></template>
</td>
<td><span class="badge" :class="k.state==='active'?'ok':'invalid'">{{k.state}}</span></td><td class="muted">{{k.created_at?when(k.created_at):'Unknown'}}</td><td class="muted">{{k.last_used_at?when(k.last_used_at):'Never'}}</td>
<td style="text-align:right;white-space:nowrap"><span v-if="editKey===k.id" class="row-actions"><button class="btn sm primary" @click="renameApiKey(k)">Save</button><button class="btn sm" @click="editKey=null">Cancel</button></span><span v-else class="row-actions">
<button class="btn sm" @click="showKeyActivity(k)">Activity</button><button v-if="k.can_rotate" class="btn sm" @click="requestKeyAction(k,'rotate')">Rotate</button><button v-if="keyHasMore(k)" class="btn sm ico" aria-haspopup="menu" :aria-expanded="keyMenu&&keyMenu.key.id===k.id" :aria-label="'More actions for '+k.name" @click.stop="toggleKeyMenu(k,$event)">⋮</button>
</span></td>
</tr>
</table>
</template>
<p v-if="!keyBusy && !apiKeys.length" class="sub">No keys are available.</p>
</template>
<!-- PROJECTS tab -->
<!-- BILLING. Balance + top-ups + auto top-up for the ACTIVE team. Admin-gated like the
rest of the team tabs. Hidden entirely when the deployment has no Stripe key
(self-hosters don't sell balance) - `billing.configured` says so. -->
<template v-if="canAdmin && orgTab==='billing'">
<div v-if="billing" style="margin-top:4px">
<div class="card" style="padding:16px">
<div style="display:flex;gap:12px;align-items:flex-start;flex-wrap:wrap">
<div style="flex:1;min-width:220px">
<div class="lbl">Balance</div>
<div style="font-size:28px;font-weight:700;color:var(--accent);font-family:var(--mono)">{{money(billing.balance_micro)}}</div>
</div>
<!-- Stripe's hosted portal: card, billing address, tax ID and the invoice archive.
Hidden until the team has a Stripe customer, which its first payment creates. -->
<button v-if="billing.portal" class="btn sm" :disabled="billingBusy" @click="openPortal()"
title="Update your card, billing address and tax ID, and download past invoices">
{{billingBusy?'Opening…':'Manage billing'}}
</button>
</div>
<p v-if="!billing.configured" class="sub" style="margin:12px 0 0">Top-ups aren't configured on this deployment.</p>
<template v-if="billing.configured">
<div style="margin-top:12px">
<!-- A team that arrived through someone's link does not know a bonus exists.
This is the one screen where saying so changes what they do, and the
MINIMUM is the part that matters: the first preset ($5) is below it, so
without this the most-clicked button silently forfeits the reward. -->
<div v-if="billing.referral_offer" style="margin-top:8px;padding:9px 12px;font-size:12.5px;
border:1px solid var(--green);border-radius:8px;
background:color-mix(in srgb,var(--green) 12%,transparent)">
<!-- The referrer is MASKED (`j•••@domain`): a referral link is public, so
the full address would be published to every stranger who signs up
through an influencer's link. The domain is what a real friend
recognises. Falls back to "to treg" if we somehow have no address. -->
You were invited
<template v-if="billing.referral_offer.referrer_masked">by
<b>{{billing.referral_offer.referrer_masked}}</b></template>
<template v-else>to treg</template>
— add
<!-- Once they have paid something, ask for the REMAINDER. Repeating the
full amount reads as though the money they already added did not
count, and it does: the threshold is cumulative. -->
<b>{{money(billing.referral_offer.remaining_micro)}}</b>
<template v-if="billing.referral_offer.topped_up_micro"> more</template>
<template v-else> or more</template>
<!-- "straight away" is load-bearing. The referee has no Referrals page, so
the balance is their ONLY feedback; promising a bonus without saying
when is what made a correct payout look like a failure. -->
and we'll add <b>{{money(billing.referral_offer.referred_micro)}}</b> to your
balance <b>straight away</b>. They get
{{money(billing.referral_offer.referrer_micro)}} after
{{billing.referral_offer.hold_days}} days.
</div>
<!-- The amounts live in a modal (openTopup), not inline: choosing a size,
seeing the bonus it earns and deciding on auto top-up are one decision,
and the mandate text has to sit next to the Pay button it authorizes. -->
<button class="btn primary" :disabled="billingBusy" @click="openTopup()">Top up</button>
</div>
<!-- `no_card` is not a failure: consent was recorded (usually from the top-up
modal) and the policy arms itself when a payment saves a card. Showing it in
the red "switched itself off" banner made an abandoned Checkout look broken. -->
<p v-if="billing.autotopup.disabled_reason==='no_card'" class="sub" style="margin:14px 0 0">
Auto top-up is set up and will switch on as soon as a top-up saves your card.
</p>
<div v-else-if="billing.autotopup.disabled_reason" class="banner" style="margin:14px 0 0">
Auto top-up switched itself off ({{billing.autotopup.disabled_reason}}). Once the balance runs out, calls will fail until it's funded.
</div>
<div style="margin-top:16px;border-top:1px solid var(--line);padding-top:14px">
<label style="display:flex;gap:10px;align-items:center;cursor:pointer">
<span class="tswitch"><input type="checkbox" :checked="billing.autotopup.enabled||autoOpen" :disabled="billingBusy" @change="autoToggled"/><span class="knob"></span></span>
<span class="lbl" style="margin:0">Auto top-up</span>
<span v-if="billing.card_on_file" class="sub" style="margin-left:auto">Card on file ✓</span>
</label>
<p class="sub" style="margin:8px 0 0">
<template v-if="billing.autotopup.enabled">On - we add {{money(billing.autotopup.amount_micro)}} whenever the balance drops below {{money(billing.autotopup.threshold_micro)}}. This month: {{money(billing.autotopup.month_spend_micro)}}.
<a href="#" @click.prevent="autoOpen=!autoOpen;autoConsent=false">{{autoOpen?'Cancel':'Edit'}}</a></template>
<template v-else>Keep agents running without watching the balance. We charge your saved card only when it dips below your threshold.</template>
</p>
<!-- The same panel edits a running policy (Edit link above) and arms a new one:
either way the numbers change, so the mandate is re-agreed and re-stamped. -->
<div v-if="autoOpen" style="margin-top:12px;background:var(--panel2);border:1px solid var(--line);border-radius:var(--rb);padding:12px 14px;max-width:560px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<span class="sub" style="margin:0">Add $</span>
<input class="msel" style="width:70px" type="number" min="5" v-model.number="autoAmount"/>
<span class="sub" style="margin:0">when the balance drops below $</span>
<input class="msel" style="width:70px" type="number" min="5" v-model.number="autoThreshold"/>
</div>
<label style="display:flex;gap:8px;align-items:flex-start;margin-top:12px;cursor:pointer">
<input type="checkbox" v-model="autoConsent" style="margin-top:3px"/>
<!-- The mandate text. Not decoration: an off-session charge with no recorded
agreement to THESE numbers is an unauthorized charge under PSD2/SCA. -->
<span class="sub" style="margin:0">I authorize treg to charge my saved card ${{autoAmount}} automatically whenever my balance drops below ${{autoThreshold}}. Cancel any time.</span>
</label>
<div style="margin-top:12px">
<button class="btn primary" :disabled="!autoConsent||billingBusy" @click="setAuto(true)">{{billingBusy?'…':(billing.autotopup.enabled?'Save':'Turn on auto top-up')}}</button>
</div>
</div>
</div>
<div style="margin-top:16px;border-top:1px solid var(--line);padding-top:14px">
<div class="lbl">Payment history</div>
<p v-if="bhist.loading" class="sub" style="margin:8px 0 0">Loading…</p>
<p v-else-if="!bhist.items.length" class="sub" style="margin:8px 0 0">No top-ups yet. Once you add funds, every payment shows up here with its invoice.</p>
<template v-else>
<!-- Amounts come from our own ledger, so this list always agrees with the
balance above. Only the links come from Stripe — hence the notice below. -->
<table style="width:100%;margin-top:8px;border-collapse:collapse;max-width:560px">
<tr v-for="i in bhist.items" :key="i.payment_intent" style="border-top:1px solid var(--line)">
<td style="padding:8px 10px 8px 0;white-space:nowrap">{{bhistDate(i.created_at)}}</td>
<td style="padding:8px 10px 8px 0;font-family:var(--mono);white-space:nowrap">{{money(i.amount_micro)}}</td>
<td style="padding:8px 10px 8px 0" class="sub">{{i.auto?'auto':''}}<span v-if="i.bonus_micro" style="color:var(--green)">+{{money(i.bonus_micro)}} bonus</span></td>
<td style="padding:8px 0;text-align:right;white-space:nowrap">
<a v-if="bhistLink(i)" :href="bhistLink(i)" target="_blank" rel="noopener">{{bhistLabel(i)}}</a>
<span v-else class="muted">—</span>
</td>
</tr>
</table>
<p v-if="!bhist.ok" class="sub" style="margin:8px 0 0">Invoice links are temporarily unavailable — the amounts above are correct. Try again shortly, or use <b>Manage billing</b> for the full archive.</p>
</template>
</div>
</template>
</div>
</div>
<p v-else class="sub">Loading…</p>
</template>
<template v-if="canAdmin && orgTab==='projects'">
<div class="lbl" style="margin-top:14px">Projects</div>
<p class="sub" style="margin:-2px 0 8px;font-size:12px">A project groups tools inside this team, so a member or agent can be scoped to just part of it. A tool with no project stays visible to everyone. Secrets stay team-level — one shared credential can back tools in several projects; scope who <i>uses</i> them via member access.</p>
<div class="field" style="max-width:560px">
<input v-model="projectName" placeholder="Apollo" @keyup.enter="createProject"/>
<button class="btn primary" @click="createProject" :disabled="projBusy">{{projBusy?'…':'Add project'}}</button>
</div>
<p v-if="!projects.length" class="muted" style="font-size:12px;margin-top:8px">No projects yet — every tool is team-wide.</p>
<table v-else style="margin-top:8px">
<tr><th>Project</th><th>Slug</th><th style="text-align:right">Tools</th><th></th></tr>
<template v-for="p in projects" :key="p.id">
<tr>
<td><b>{{p.name}}</b></td>
<td class="muted" style="font-family:var(--mono);font-size:12px">{{p.slug}}</td>
<td style="text-align:right">
<button class="btn sm" :class="{active:editProj===p.id}" @click="openProjTools(p)">{{p.tool_count}} tool{{p.tool_count===1?'':'s'}} ▾</button>
</td>
<td style="text-align:right">
<button class="btn sm" :class="{danger:confirmProj===p.id}" @click="deleteProject(p)" :title="'its '+p.tool_count+' tool(s) become team-wide — they are not deleted'">{{confirmProj===p.id?'Confirm delete':'Delete'}}</button>
</td>
</tr>
<tr v-if="editProj===p.id">
<td colspan="4" style="background:color-mix(in srgb,var(--accent) 5%,transparent)">
<div style="padding:6px 2px">
<div class="sub" style="margin-bottom:6px">Tools in <b>{{p.name}}</b> — check to add, uncheck to make team-wide again. Checking a tool that lives in another project moves it here.</div>
<div v-if="!tools.length" class="muted" style="font-size:12px">No tools registered yet.</div>
<div style="display:flex;flex-wrap:wrap;gap:6px 16px">
<label v-for="t in tools" :key="t.id" class="tgl" style="min-width:190px">
<input type="checkbox" v-model="projToolDraft[t.id]"/><span>{{t.name}}</span>
<span v-if="t.project_id && t.project_id!==p.id" class="chip" style="margin-left:4px" :title="'currently in '+projName(t.project_id)">{{projName(t.project_id)}}</span>
</label>
</div>
<div style="margin-top:10px;display:flex;gap:8px">
<button class="btn sm primary" @click="saveProjTools(p)" :disabled="projToolBusy">{{projToolBusy?'…':'Save'}}</button>
<button class="btn sm" @click="editProj=null">Cancel</button>
</div>
</div>
</td>
</tr>
</template>
</table>
</template>
<!-- POLICY tab -->
<template v-if="canAdmin && orgTab==='policy'">
<div class="lbl" style="margin-top:14px">Policy</div>
<p class="sub" style="margin:-2px 0 8px;font-size:12px">Block calls to a host, a path, or a method. Leave a field empty to mean <b>any</b>. Applies to the proxy and to runs — and to everyone, including owners.</p>
<div class="field" style="max-width:720px;flex-wrap:wrap">
<input v-model="denyForm.host" placeholder="host (e.g. api.stripe.com)" style="min-width:190px"/>
<input v-model="denyForm.path_prefix" placeholder="path (e.g. /admin)" style="min-width:140px"/>
<select v-model="denyForm.method" class="msel"><option value="">any method</option><option>GET</option><option>POST</option><option>PUT</option><option>PATCH</option><option>DELETE</option></select>
<select v-model="denyForm.user_id" class="msel" title="who it applies to">
<option :value="null">whole team</option>
<option v-for="m in rosterMembers.filter(x=>!x.is_observed)" :key="m.user_id" :value="m.user_id">{{m.is_agent?('agent: '+(m.name||m.email)):m.email}}</option>
</select>
<select v-if="projects.length" v-model="denyForm.project_id" class="msel" title="which project's tools it applies to">
<option :value="null">any project</option>
<option v-for="p in projects" :key="p.id" :value="p.id">{{p.name}}</option>
</select>
<input v-model="denyForm.note" placeholder="why (shown in the refusal)" style="min-width:170px"/>
<button class="btn primary" @click="addDeny" :disabled="denyBusy">{{denyBusy?'…':'Block'}}</button>
</div>
<p v-if="!denyRules.length" class="muted" style="font-size:12px;margin-top:8px">No rules — nothing is blocked.</p>
<table v-else style="margin-top:8px">
<tr><th>Method</th><th>Host</th><th>Path</th><th>Applies to</th><th v-if="projects.length">Project</th><th>Note</th><th></th></tr>
<tr v-for="r in denyRules" :key="r.id">
<td><span class="chip">{{r.method||'any'}}</span></td>
<td style="font-family:var(--mono);font-size:12px">{{r.host||'any'}}</td>
<td style="font-family:var(--mono);font-size:12px">{{r.path_prefix||'any'}}</td>
<td>{{r.scope==='org'?'whole team':(denyWho(r.user_id))}}</td>
<td v-if="projects.length">{{r.project_id?projName(r.project_id):'any'}}</td>
<td class="muted" style="font-size:12px">{{r.note}}</td>
<td style="text-align:right"><button class="btn sm" :class="{danger:confirmDeny===r.id}" @click="removeDeny(r)">{{confirmDeny===r.id?'Confirm remove':'Remove'}}</button></td>
</tr>
</table>
<!-- Tool-level argv blocks — the OTHER deny layer, surfaced read-only so the whole
"what is blocked" picture lives on one screen. -->
<template v-if="cliDeny.length">
<div class="lbl" style="margin-top:22px">Per-tool CLI blocks <span class="chip" style="margin-left:6px">read-only</span></div>
<p class="sub" style="margin:-2px 0 8px;font-size:12px">treg blocks at three layers: the rules above (HTTP host/path/method, this team's policy), these per-tool argument patterns (from the skill's <span class="mono">treg.json</span> or the treg catalog — edit them there), and the OS sandbox around isolated local runs.</p>
<table>
<tr><th>Tool</th><th>Blocked argument patterns</th></tr>
<tr v-for="t in cliDeny" :key="t.tool">
<td><b>{{t.tool}}</b><span v-if="!t.enabled" class="chip" style="margin-left:6px" title="local runs for this tool are not enabled">off</span></td>
<td><span v-for="pat in t.patterns" :key="pat.pattern" class="chip" style="margin:2px 6px 2px 0;font-family:var(--mono);font-size:11px" :title="pat.source==='skill'?'from this skill\'s treg.json':'from the treg catalog defaults'">{{pat.pattern}}<span class="muted" style="margin-left:4px">· {{pat.source}}</span></span></td>
</tr>
</table>
</template>
</template>
<!-- TEAM SETTINGS: just the danger zone (new/join/switch team live in the sidebar
picker). Visible to every role — leaving a team is self-service, not admin. -->
<template v-if="orgTab==='danger'">
<!-- DAILY SPEND CAP. A team-level setting, so it lives here rather than under Billing.
It was invisible entirely before this, which meant a team first met it as an
unexplained 429; and it matters MOST when auto top-up is on, because a balance that
refills itself is no longer a ceiling and this becomes the only thing bounding a
runaway agent. `capCfg` is loaded by loadBilling(), which runs on every Team-page
load — NOT per tab — so it is populated by the time this renders.
NB the name: `orgSettings` is already a METHOD on this component (the ⚙ in the team
picker), and data and methods share one namespace. -->
<div v-if="capCfg" style="margin-top:18px;border-top:1px solid var(--line);padding-top:14px">
<span class="lbl" style="margin:0">Daily spend limit</span>
<p class="sub" style="margin:4px 0 8px;font-size:12px">
The most this team can spend per day on treg's keys. Resets at 00:00 UTC. None by
default. Worth setting if agents run unattended with auto top-up on, since a
balance that refills itself can't stop a runaway one.
</p>
<div class="field" style="max-width:560px">
<input v-model="capUsd" type="number" min="0" step="0.5" placeholder="no limit" style="max-width:140px"/>
<button class="btn primary" :disabled="capBusy" @click="saveCap">{{capBusy?'…':'Save'}}</button>
<span class="sub" style="margin:0">
USD/day · now {{capCfg.daily_cap_micro ? money(capCfg.daily_cap_micro) : 'no limit'}}<template v-if="!capCfg.daily_cap_set_by_team"> (our default)</template>
</span>
</div>
<p v-if="capErr" class="banner" style="margin:10px 0 0">{{capErr}}</p>
<p class="muted" style="font-size:12px;margin-top:8px">
Any amount, up or down. Leave it empty (or 0) for no daily limit — your balance is then the only bound.
</p>
</div>
<!-- PER-TAG BUDGETS. What a team reselling treg sets on ITS OWN customers. Only
DECLARED dimensions are enforced on the call path, so the key is a picker over
those rather than a free-text box — the API refuses a limit on anything else
precisely because a stored-but-unenforced budget looks like protection and isn't. -->
<div v-if="capCfg" style="margin-top:22px;border-top:1px solid var(--line);padding-top:14px">
<span class="lbl" style="margin:0">Per-customer limits</span>
<p class="sub" style="margin:4px 0 8px;font-size:12px">
Cap or block one of your own customers by the tags your calls carry
(<code>X-Treg-Meta</code>). Limits <b>stack</b> — a workspace ceiling and a
per-customer ceiling both apply, and a refusal names which one hit. They are
<b>advisory</b>: concurrent calls can overshoot slightly, and your balance is the
hard limit.
</p>
<div class="field" style="max-width:680px;flex-wrap:wrap">
<!-- Every tag the team SENDS, not only the ones already budgeted. Setting a
limit declares the dimension, so offering only the declared list made the
common path a hidden two-step: you can see `feature` in Usage, so you expect
to be able to cap it. -->
<select class="msel" v-model="budDim" style="max-width:160px">
<option v-for="k in budDims" :key="k" :value="k">{{k}}</option>
</select>
<input v-model="budVal" placeholder="cust_8123 — blank = default for all" style="max-width:230px"/>
<input v-model="budDaily" type="number" min="0" step="0.5" placeholder="USD/day" style="max-width:120px"/>
<button class="btn primary" :disabled="budBusy" @click="saveBudget()">{{budBusy?'…':(budVal?'Set limit':'Set default')}}</button>
</div>
<p v-if="budErr" class="banner" style="margin:10px 0 0">{{budErr}}</p>
<template v-for="dim in (capCfg.budget_dims||[])" :key="dim">
<table style="margin-top:10px">
<tr><th>{{dim}}</th><th style="text-align:right">Daily limit</th><th>Status</th><th></th></tr>
<!-- The default first: everything under it is an exception to it. Rendered even
when unset, so "no limit" is something you can SEE rather than infer from
an empty table. -->
<tr>
<td><b>every {{dim}}</b> <span class="muted">(default)</span></td>
<td style="text-align:right">{{defaultFor(dim)?fmtCap(defaultFor(dim)):'no limit'}}</td>
<td><span class="muted">—</span></td>
<td style="text-align:right"><button v-if="defaultFor(dim)" class="btn sm" @click="removeBudget(defaultFor(dim))">Clear</button></td>
</tr>
<tr v-for="b in overridesFor(dim)" :key="b.val">
<td style="padding-left:22px"><span class="chip">{{b.val}}</span></td>
<!-- EFFECTIVE, never a dash: an override with no cap really is unlimited, and
showing "—" would hide that it is exempt from the default. -->
<td style="text-align:right">{{fmtCap(b)}}</td>
<td><span class="badge" :class="b.status==='blocked'?'invalid':'ok'">{{b.status}}</span></td>
<td style="text-align:right;white-space:nowrap">
<button class="btn sm" @click="toggleBlock(b)">{{b.status==='blocked'?'Unblock':'Block'}}</button>
<button class="btn sm" @click="removeBudget(b)">Remove</button>
</td>
</tr>
<tr v-if="!overridesFor(dim).length"><td colspan="4" class="muted" style="font-size:12px">no exceptions — every {{dim}} follows the default</td></tr>
</table>
</template>
</div>
<!-- TEAM NAME + SLUG. Admin+. The old slug stays an alias server-side, so copied keys,
~/.treg and MCP pins keep working; the CLI just needs `treg org use <new-slug>`. -->
<div v-if="canAdmin && !isPersonal(activeOrg)" style="margin-top:18px;border-top:1px solid var(--line);padding-top:14px">
<span class="lbl" style="margin:0">Team name and slug</span>
<p class="sub" style="margin:4px 0 8px;font-size:12px">
The slug is the team's id in <code>X-Treg-Org</code>, <code>treg org use</code> and MCP. Changing it
keeps existing keys working; run <code>treg org use {{renameSlug.trim()||activeSlugNow}}</code> to re-pin the CLI.
</p>
<div class="field" style="max-width:560px">
<input v-model="renameName" placeholder="team name" maxlength="80" style="max-width:220px"/>
<input v-model="renameSlug" placeholder="slug" maxlength="40" style="max-width:180px"/>
<button class="btn primary" :disabled="renameBusy || !renameDirty" @click="renameOrg">{{renameBusy?'…':'Save'}}</button>
</div>
<p v-if="renameErr" class="sub" style="color:var(--red);margin:4px 0 0;font-size:12px">{{renameErr}}</p>
</div>
<template v-if="!isPersonal(activeOrg)">
<div class="lbl" style="margin-top:14px;color:var(--red)">Danger zone</div>
<div style="display:flex;gap:22px;flex-wrap:wrap;align-items:flex-start">
<div>
<button class="btn sm" :class="{danger:confirmLeave}" @click="leaveOrg">{{confirmLeave?'Confirm leave':'Leave org'}}</button>
<a v-if="confirmLeave" href="#" @click.prevent="confirmLeave=false" class="sub" style="margin-left:8px;font-size:12px">cancel</a>
</div>
<div v-if="isOwner" class="field" style="margin:0">
<input v-model="confirmDel" :placeholder="'type '+activeSlugNow+' to delete'" style="min-width:230px"/>
<button class="btn sm danger" :disabled="confirmDel!==activeSlugNow" @click="deleteOrg">Delete org</button>
</div>
</div>
</template>
<p v-else class="sub" style="margin-top:14px;font-size:12px">A personal team cannot be left or deleted. Create or join other teams from the picker at the top of the page.</p>
</template>
</div>
</template>
+91
View File
@@ -0,0 +1,91 @@
<script>
import { useDashboard } from '../state/context'
export default { setup: useDashboard }
</script>
<template>
<div v-if="orgMsg" class="tut-notice" style="max-width:660px;margin-bottom:14px;display:flex;justify-content:space-between;gap:12px;align-items:flex-start">
<span>{{orgMsg}}</span><button class="btn sm ico" @click="orgMsg=''" aria-label="Dismiss">✕</button>
</div>
<div class="tut-head">
<div><h1>Bring your own keys &amp; skills</h1>
<div v-if="canRegister" class="tabs" style="margin:8px 0 4px">
<button class="active">Skills &amp; tools</button>
<button @click="go('secrets')">Secrets</button>
</div>
<p class="sub" style="margin:0" v-if="filteredTools.length">Call any of these with no key on your machine. <b>Copy</b> a snippet, or <b>Try it</b> here.</p><p class="sub" style="margin:0" v-else-if="canRegister && !q">Register an upstream API here, then call it with no key on your machine.</p></div>
<div class="tut-actions" v-if="canRegister" style="position:relative">
<button class="btn sm" @click="openAddSkill">+ Skill</button>
<button class="btn sm primary" @click.stop="addToolMenu=!addToolMenu" aria-haspopup="true" :aria-expanded="addToolMenu">+ Add tool</button>
<div v-if="addToolMenu" style="position:fixed;inset:0;z-index:29" @click="addToolMenu=false"></div>
<div class="dropdown" v-if="addToolMenu" style="left:auto;right:0;top:110%;width:280px" @click.stop>
<div class="row" @click="openAddTool('endpoint')"><span style="min-width:0"><b style="font-size:12.5px">⛁ Endpoint</b><span class="sub" style="display:block;font-size:11px;margin-top:1px">an HTTP API, called through the proxy</span></span></div>
<div class="row" @click="openAddTool('cli')"><span style="min-width:0"><b style="font-size:12.5px">⌘ CLI</b><span class="sub" style="display:block;font-size:11px;margin-top:1px">a command members run with the key injected</span></span></div>
<div class="row" @click="addToolMenu=false; mkTab='platform'; go('connections')"><span style="min-width:0"><b style="font-size:12.5px">▤ From the catalog</b><span class="sub" style="display:block;font-size:11px;margin-top:1px">browse ready-made endpoints by platform — many need no key</span></span></div>
</div>
</div>
</div>
<div v-if="toolErr && !newTool" class="banner" style="margin-top:12px">{{toolErr}}</div>
<div v-if="runNote" class="banner" style="margin-top:12px;display:flex;gap:8px;align-items:flex-start">
<span>Local runs are on for {{runNote}}. During a run the key is injected into that member's process - consider storing a <b>restricted</b> key (many providers offer read-only/scoped keys) rather than a full one.</span>
<button class="btn sm ico" @click="runNote=''" style="margin-left:auto" aria-label="dismiss">✕</button>
</div>
<div class="seg" v-if="hasAnyTools">
<button :class="{on:toolTab==='all'}" @click="toolTab='all'">All</button>
<button :class="{on:toolTab==='endpoints'}" @click="toolTab='endpoints'">Endpoints/CLI <span>{{endpoints.length}}</span></button>
<button :class="{on:toolTab==='skills'}" @click="toolTab='skills'">Integration Skills <span>{{skillTools.length}}</span></button>
<button :class="{on:toolTab==='recipes'}" @click="toolTab='recipes'">Skills <span>{{recipes.length}}</span></button>
</div>
<div class="tgroup" v-for="grp in toolGroups" :key="grp.key" v-show="grp.rows.length && (toolTab==='all'||toolTab===grp.key)">
<div class="tgh">{{grp.label}} <span class="tgh-n">{{grp.rows.length}}</span><span class="tgh-hint">{{grp.hint}}</span></div>
<div class="ttable-wrap"><table class="ttable">
<tr v-for="t in grp.rows" :key="t.id" @click="rowOpen(t)" style="cursor:pointer" :title="'Open the '+rowTarget(t).kind+'’s page (shareable link)'">
<td class="tn"><a :href="rowHref(t)" @click.prevent.stop="rowOpen(t)" style="color:inherit;text-decoration:none"><b>{{t.name}}</b></a></td>
<td class="th"><span class="mono">{{t.host}}</span></td>
<td class="ta"><span v-if="t.cli" class="chip run" :title="localRunTitle(t)">⌘ run{{t.cli.enabled?'':' off'}}</span><span v-if="t.server_runnable" class="chip ok" title="Runs on the server — the key is injected there, never on a member's machine">server</span><span v-else-if="t.cli" class="chip" title="Local only — this CLI authenticates from the member's own machine (treg run --local)">local-only</span><span class="chip" :class="w.kind" v-for="(w,wi) in credChips(t)" :key="'w'+wi" :title="w.title">{{w.label}}</span></td>
<td class="tx" @click.stop><button v-if="t.cli && canRegister" class="btn sm ico" :class="{on:t.cli.enabled}" @click="toggleLocalRun(t)" :title="localRunTitle(t)">⌘</button><button class="btn sm ico" @click="openCopy(t)" title="Copy a call snippet">⧉</button><button class="btn sm ico" @click="openUse(t)" title="Use it - API call or CLI run">▶</button><button v-if="canRegister" class="btn sm ico" @click="openEditTool(t)" title="Edit">✎</button><button v-if="canRegister" class="btn sm ico" :class="{danger:confirmDelTool===t.id}" @click="deleteTool(t)" :title="confirmDelTool===t.id?'Click again to delete':'Delete'">✕</button></td>
</tr>
</table></div>
</div>
<div class="tgroup" v-show="recipes.length && (toolTab==='all'||toolTab==='recipes')">
<div class="tgh">Skills <span class="tgh-n">{{recipes.length}}</span><span class="tgh-hint">knowledge skills - pull with the CLI</span></div>
<div class="ttable-wrap"><table class="ttable">
<tr v-for="r in recipes" :key="r.id" @click="openDetail('skill',r.name)" style="cursor:pointer" title="Open the skill’s page (shareable link)">
<td class="tn"><a :href="'/app/skills/'+encodeURIComponent(r.name)" @click.prevent.stop="openDetail('skill',r.name)" style="color:inherit;text-decoration:none"><b>{{r.name}}</b></a></td>
<td class="th" colspan="2"><span class="mono muted">treg skill install {{r.name}}</span></td>
<td class="tx" @click.stop><button class="btn sm ico" @click="openRecipeCopy(r)" title="How to install / use">⧉</button><button class="btn sm ico" @click="openRecipeView(r)" title="View the SKILL.md">✎</button><button v-if="canRegister" class="btn sm ico" :class="{danger:confirmDelBundle===r.id}" @click="deleteRecipe(r)" :title="confirmDelBundle===r.id?'Click again to delete':'Delete'">✕</button></td>
</tr>
</table></div>
</div>
<p v-if="!loading && !hasAnyTools" class="sub">Nothing here{{q?' matches "'+q+'"':' yet'}}.<template v-if="!q && isPersonal(activeOrg) && myOrgs.some(o=>!isPersonal(o))"> This is your <b>personal</b> space - your team's tools live under another org. <a href="#" @click.prevent="jumpToTeam()">Switch to a team →</a></template></p>
<div v-if="!loading && !hasAnyTools && !q && canRegister" style="margin-top:14px">
<div style="max-width:660px;border:1px solid var(--line);border-radius:16px;padding:20px 22px;background:var(--panel2)">
<h3 style="margin:0 0 6px;font-size:15px">Nothing of your own yet — but you can already call 2,800+ tools</h3>
<p class="sub" style="margin:0 0 12px">New verified accounts get <b>$1.00 of free credit once</b> when creating an eligible team, so the catalog works before you register anything: find a tool by what it does, see the price, call it. <a href="#" @click.prevent="go('connections')">Browse the catalog →</a><br>When you're ready to add your <i>own</i> keys and skills — your <span class="mono">.env</span> and skill folders — load them here and they become callable tools for the whole team.</p>
<div class="seg" style="margin-bottom:10px">
<button :class="{on:emptyTab==='agent'}" @click="emptyTab='agent'">Agent instruction</button>
<button :class="{on:emptyTab==='manual'}" @click="emptyTab='manual'">Manual</button>
</div>
<template v-if="emptyTab==='manual'">
<div class="lbl">1 · Install the CLI &amp; sign in</div>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('curl -fsSL '+proxy+'/install.sh | sh\ntreg login','es1')">{{startCopied==='es1'?'✓ copied':'copy'}}</button><pre><span class="hl-cmd">curl</span> <span class="hl-flag">-fsSL</span> <span class="hl-str">{{proxy}}/install.sh</span> | sh
<span class="hl-cmd">treg</span> login</pre></div>
<div class="lbl" style="margin-top:12px">2 · Preview, then upload — it scans your <span class="mono">.env</span> + skill folders, you pick what to share.</div>
<div class="lc-codewrap"><button class="lc-cp" @click="copyStart('treg scan\ntreg upload --all','es3')">{{startCopied==='es3'?'✓ copied':'copy'}}</button><pre><span class="hl-comment"># preview what's here (read-only)</span>
<span class="hl-cmd">treg</span> scan
<span class="hl-comment"># register keys + skills</span>
<span class="hl-cmd">treg</span> upload <span class="hl-flag">--all</span></pre></div>
</template>
<template v-else>
<p class="sub" style="margin:0 0 8px">One line, token included — your agent reads llms.txt, installs the CLI, signs in, and registers your skills + keys (read-only scan first, you approve).</p>
<div class="lc-codewrap"><button class="lc-cp" @click="copyAgentGuide('agent')">{{agentRowCopied==='agent'?'✓ copied':'copy'}}</button><pre style="max-height:220px;overflow:auto">{{buildAgentPrompt('agent', true)}}</pre></div>
</template>
</div>
</div>
</template>
+54
View File
@@ -0,0 +1,54 @@
export default {
async loadUsage(){ if(!this.canAdmin || !this.activeOrgId) return; this.usage=null;
try{ this.usage=await this.api('/orgs/'+this.activeOrgId+'/usage?days='+this.usageDays); }
catch(e){ this.err='Failed to load usage: '+(e.detail||e.status); }
await this.loadTagUsage(); },
async loadTagUsage(){ if(!this.canAdmin || !this.activeOrgId) return;
this.tagUsage={};
try{
// What the team has actually SENT. Reporting works on any key, unlike enforcement, which
// needs a declared one — so this is deliberately NOT the budgetable list, which hid
// `feature=` and friends from the dashboard even though the API served them fine.
const k=await this.api('/orgs/'+this.activeOrgId+'/tag-keys');
const primary=k.primary;
const keys=[...new Set([...(k.seen||[]), ...(k.budgetable||[])].filter(Boolean))];
// Primary first — it is the one a reselling team bills on; the rest alphabetically.
keys.sort((a,b)=> a===primary ? -1 : b===primary ? 1 : a.localeCompare(b));
this.tagKeys=keys;
// One request per key. Bounded by the 5-key cap on the header, and they run together.
const got=await Promise.all(keys.map(key=>
this.api('/orgs/'+this.activeOrgId+'/usage/by-tag?key='+encodeURIComponent(key)+
'&days='+this.usageDays).catch(()=>null)));
const out={};
keys.forEach((key,i)=>{ if(got[i]) out[key]=got[i]; });
this.tagUsage=out;
}catch(e){ this.tagUsage={}; this.tagKeys=[]; } },
// micro-USD -> a money string, EXACT. Cents round a $0.0006 charge to $0.00 and a $9.999 balance
// to "$10.00"; a fixed 4 decimals rounds $0.00015 to $0.0001 (float: 0.00015 is stored just
// under). So: whole-cent amounts render as normal cents, everything else renders all 6 micro
// digits with trailing zeros trimmed — a micro amount is always representable. Mirrors cli._usd.
// Async generation tasks on the Activity feed: the state chip and the artifact link's tooltip.
taskStateLabel(t){ return {pending:'generating…', settled:'done', released:'failed · refunded', timed_out:'timed out'}[t.status]||t.status; },
taskStateTitle(t){
if(t.status==='pending') return 'the task is still running upstream; the hold settles or is refunded when it finishes';
if(t.status==='settled') return 'the task succeeded; charged '+this.money(t.settled_micro)+(t.completed_at?' at '+new Date(t.completed_at+(/(Z|[+-]\d{2}:?\d{2})$/.test(t.completed_at)?'':'Z')).toLocaleString():'');
if(t.status==='released') return 'the provider reported the task failed; the whole hold was refunded'+(t.error?' - '+t.error:'');
if(t.status==='timed_out') return 'no terminal state within 24 hours; the whole hold was refunded and treg absorbed any upstream charge (flagged for review)';
return t.status;
},
taskArtifactTitle(t){ return 'the provider\'s download URL - time-limited'+(t.ttl_note?' (lifetime: '+t.ttl_note+')':'')+', download promptly'+(t.completed_at?'; generated '+this.when(t.completed_at):''); },
async openCall(a){ // Activity row → the drawer; details load from /calls/{id}/result
this.callViewFull=false; this.callCopied='';
// `task` rides along from the row: a generation's artifact (the provider's time-limited URL)
// lives on the async task, not in the archived submission body, and the drawer shows it inline.
this.callView={id:a.id, tool:a.tool, endpoint_id:a.endpoint_id, call_ref:a.call_ref, created_at:a.created_at, status_code:a.status, credential_tier:a.tier, cached:a.cached, task:a.task||null, loading:true};
try{ const d=await this.api('/calls/'+a.id+'/result'); this.callView={...this.callView, ...d, loading:false}; }
catch(e){ this.callView={...this.callView, loading:false, error:'Could not load this call.'}; }
},
pretty(text){ try{ return JSON.stringify(JSON.parse(text), null, 2); }catch(e){ return text; } },
isVideoUrl(u){ try{ return /\.(mp4|webm|mov|m4v)$/i.test(new URL(u).pathname); }catch(e){ return /\.(mp4|webm|mov|m4v)(\?|$)/i.test(u||''); } },
fmtBytes(n){ if(n==null) return '—'; if(n<1024) return n+' B'; if(n<1048576) return (n/1024).toFixed(1)+' KB'; return (n/1048576).toFixed(2)+' MB'; },
async copyCallBody(){ const t=this.callView&&this.callView.response&&this.callView.response.body_text; if(!t) return; if(await this.toClipboard(t)){ this.callCopied='Copied'; setTimeout(()=>{ this.callCopied=''; },1400); } },
async loadCalls(){ try{ if(!this.apiKeys.length)await this.loadApiKeys(); const q='?limit=100'+(this.activityKey?'&api_key_id='+encodeURIComponent(this.activityKey):''); const [calls,runs]=await Promise.all([this.api('/calls'+q), this.api('/runs'+q).catch(()=>[])]); this.calls=calls; this.runs=runs; }catch(e){ this.err='Failed to load activity.'; } }
}
+14
View File
@@ -0,0 +1,14 @@
export default {
async loadAdmin(){ this.confirmAdmUser=null; this.confirmAdmOrg=null;
try{ this.adminStats=await this.api('/admin/stats'); this.adminOrgs=await this.api('/admin/orgs'); this.adminUsers=await this.api('/admin/users'); }
catch(e){ this.err='Admin: '+(e.detail||e.status); } },
async _adm(path, method, body){ this.adminBusy=true; this.err='';
try{ await this.api(path, {method, ...(body?{headers:{'content-type':'application/json'},body:JSON.stringify(body)}:{})}); await this.loadAdmin(); }
catch(e){ this.err='Admin action failed: '+(e.detail||e.status); } finally{ this.adminBusy=false; } },
admGrant(u){ this._adm('/admin/users/'+u.id+'/superadmin','POST',{value:!u.is_superadmin}); },
admSuspendUser(u){ this._adm('/admin/users/'+u.id+'/suspend','POST',{value:!u.suspended}); },
admDeleteUser(u){ if(this.confirmAdmUser!==u.id){ this.confirmAdmUser=u.id; return; } this.confirmAdmUser=null; this._adm('/admin/users/'+u.id,'DELETE'); },
admSuspendOrg(o){ this._adm('/admin/orgs/'+o.id+'/suspend','POST',{value:!o.suspended}); },
admDeleteOrg(o){ if(this.confirmAdmOrg!==o.id){ this.confirmAdmOrg=o.id; return; } this.confirmAdmOrg=null; this._adm('/admin/orgs/'+o.id,'DELETE'); }
}
+63
View File
@@ -0,0 +1,63 @@
export default {
// ---- agents: a member identity for a machine caller ----
pollAgentConnected(){
// Flip the token card to ✓ the moment the setup instruction's final check-in lands.
if(this._agentPoll) clearInterval(this._agentPoll);
let tries=0;
this._agentPoll=setInterval(async()=>{
if(!this.newAgent || this.agentConnected || ++tries>40){ clearInterval(this._agentPoll); this._agentPoll=null; return; }
await this.loadOrgAdmin(); }, 3000); },
promoteObserved(o){
// Promotion = mint a real identity for a runtime we've only SEEN so far. Prefill the form;
// the admin still picks role/cap/projects and presses Create, then swaps the env key.
// promotePending links the mint to this (member, runtime) pair, so the detected row
// disappears the moment the agent exists (and returns if the agent is revoked).
const who=(o.member.split('@')[0]||'agent').replace(/[^a-zA-Z0-9-]/g,'-');
this.promotePending={member:o.member, client:o.client};
this.showAddAgent=true; this.showInvite=false;
this.agentAccessMode=null; this.agentToolSel={};
this.agentName=who+'-'+o.client; this.promoteHint='Creating a token for "'+o.client+'" running as '
+o.member+'. After Create, put the new token in that runtime\'s TREG_TOKEN — from then on it '
+'acts as itself, with its own cap and scope, instead of as '+o.member+'.'; },
openAddAgent(){ this.showAddAgent=!this.showAddAgent; if(this.showAddAgent){ this.showInvite=false; this.agentAccessMode=null; this.agentToolSel={}; } },
async createAgent(){ const name=(this.agentName||'').trim();
if(!name){ this.agentErr='Give the agent a name, e.g. ci-bot.'; return; }
if(!this.agentAccessMode){ this.agentErr='Choose All tools or Choose tools before creating the agent.'; return; }
// An admin agent can manage this team's tools, secrets and members. That is a real step up from
// 'can call things', so make it a deliberate choice rather than a dropdown you skimmed past.
if(this.agentRole==='admin' && !confirm(
'Create "'+name+'" as an ADMIN agent?\n\nAn admin agent can register and delete tools and '
+'secrets, invite members, and set access for this team — not just call tools.\n\n'
+'Most agents only need "member".')) return;
this.agentBusy=true; this.agentErr='';
const body={name, role:this.agentRole, daily_call_cap:this.agentCap};
body.tool_access=this.agentAccessMode==='all' ? null : this.accessNames.filter(n=>this.agentToolSel[n]);
// Only send project_access when the admin actually narrowed it — all checked = every project.
const picked=this.projects.filter(p=>this.agentProjSel[p.id]).map(p=>p.id);
if(this.projects.length && picked.length<this.projects.length) body.project_access=picked;
if(this.promotePending){ body.promoted_member=this.promotePending.member; body.promoted_client=this.promotePending.client; }
try{ const r=await this.api('/orgs/'+this.activeOrgId+'/agents',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify(body)});
this.agentTokens[r.user_id]=r.token; this.newApiKey=null; this.newAgent={...r,rotated:false}; this.snipAgent=null; this.agentName=''; this.agentAccessMode=null; this.agentToolSel={}; this.promoteHint=''; this.promotePending=null; await this.loadOrgAdmin(); this.pollAgentConnected(); }
catch(e){ this.agentErr='Create failed: '+(e.detail||e.status); }
this.agentBusy=false; },
// Rotate = create with the SAME name: the server replaces the token hash, so the old one dies.
// We deliberately send only name/role/cap — the server leaves every field we DON'T send as it is,
// so the agent's tool ACL and project scope survive a rotate (they used to be silently cleared).
async rotateAgent(a, confirmed=false){ const mark='rotate-'+a.user_id;
if(!confirmed && this.confirmAgent!==mark){ this.confirmAgent=mark; return; }
this.confirmAgent=null; this.agentBusy=true; this.agentErr='';
try{ const r=await this.api('/orgs/'+this.activeOrgId+'/agents',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({name:a.name, role:a.role, daily_call_cap:a.daily_call_cap})});
this.agentTokens[r.user_id]=r.token; this.newApiKey=null; this.newAgent={...r,rotated:true}; this.snipAgent=null; await this.loadOrgAdmin(); this.pollAgentConnected(); }
catch(e){ this.agentErr='Rotate failed: '+(e.detail||e.status); }
this.agentBusy=false; },
showAgentSetup(a){ this.newAgent=null; this.agentSnip='prompt';
this.snipAgent=(this.snipAgent && this.snipAgent.user_id===a.user_id) ? null : a; },
async revokeAgent(a){ const mark='revoke-'+a.user_id; if(this.confirmAgent!==mark){ this.confirmAgent=mark; return; }
this.confirmAgent=null;
try{ await this.api('/orgs/'+this.activeOrgId+'/agents/'+a.user_id,{method:'DELETE'}); await this.loadOrgAdmin();
this.orgMsg=a.name+' was removed and all its keys were revoked. Its Activity history remains; create and configure it again to restore it.'; }
catch(e){ this.agentErr='Revoke failed: '+(e.detail||e.status); } }
}
+95
View File
@@ -0,0 +1,95 @@
export default {
snip(){ return this.buildSnippet(this.snippetTab); },
recipeSnip(){ return this.recipeSnippet(this.recipeTab); },
tutData(){ return window.TREG_TUTORIAL || {concepts:[], roles:{cols:[],rows:[]}, steps:[]}; },
tutSteps(){ return this.tutData.steps; },
tutStep(){ return this.tutSteps[this.tut.i]; },
xtutSteps(){ return (this.helpMode==='import-shell' ? this.tutData.importShell : this.tutData.access) || []; },
xtutStep(){ return this.xtutSteps[this.xtut.i]; },
xtutTitle(){ return this.helpMode==='import-shell' ? 'Import & shell' : 'Team access control'; },
tourData(){ return window.TREG_TOUR || {steps:[], personas:{}, colors:['--accent']}; },
tourSteps(){ return this.tourData.steps; },
tourStep(){ return this.tourSteps[this.tourI]; },
tourParts(){ return [...new Set(this.tourSteps.map(s=>s.part))]; },
agentPromptText(){ return this.buildAgentPrompt(this.agentGuide); },
// The whole vendor pitch is two sentences: the hosted /vendor-listing page carries the real
// instructions, so this prompt only has to name the repo, the page, and the contact email.
vendorPromptText(){ return `Help me create a PR to treg (https://github.com/superdesigndev/treg) that adds our API to its tool catalog. Follow the instructions at ${this.proxy}/vendor-listing — and include our contact email in the PR description so the maintainers can reach us to arrange live verification.`; },
// What to actually DO with a freshly minted agent token, in the three shapes people need.
agentSnippet(){ const a=this.newAgent||this.snipAgent||((this.newApiKey&&this.newApiKey.assigned_type==='agent')?this.newApiKey:null); if(!a) return '';
const base=this.proxy||location.origin, org=a.org||this.activeSlugNow;
const tok=a.token||a.secret||this.agentTokens[a.user_id]||'$TREG_TOKEN'; // hash-stored server-side; real value only if minted this page-load
if(this.agentSnip==='env'){
return [
'# Where to persist these, per agent harness:',
'# Claude Code -> the "env" map in ~/.claude/settings.json',
'# Codex CLI -> [shell_environment_policy] set = {...} in ~/.codex/config.toml',
'# Gemini CLI -> append KEY=value lines to ~/.gemini/.env (a project .env shadows it)',
'# Cursor -> ~/.zshrc: [ -n "$CURSOR_AGENT" ] && export ...',
'# pi -> ~/.zshrc guard on $PI_CODING_AGENT, or shellCommandPrefix in ~/.pi/agent/settings.json',
'# opencode -> shell.env plugin; also export TREG_CLIENT=opencode',
'# plain shell / CI -> the exports below, as-is',
'export TREG_TOKEN='+tok,
'export TREG_ORG='+org,
'export TREG_URL='+base,
].join('\n'); }
return [
'You have your own treg identity in the team "'+org+'" (you are "'+a.name+'").',
'',
'FIRST, persist this identity into YOUR OWN global config, so every future session of',
'yours, in any repo, runs as "'+a.name+'" — while other agents and the human keep theirs:',
' TREG_TOKEN='+tok,
' TREG_ORG='+org,
' TREG_URL='+base,
'',
'How, per runtime — do the one that matches you:',
'- Claude Code: add them to the "env" map in ~/.claude/settings.json:',
' "env": { "TREG_TOKEN": "'+tok+'", "TREG_ORG": "'+org+'", "TREG_URL": "'+base+'" }',
'- Codex CLI: in ~/.codex/config.toml (a `set` entry bypasses Codex\'s default filtering',
' of *TOKEN* names, so this works where plain shell exports get stripped):',
' [shell_environment_policy]',
' set = { TREG_TOKEN = "'+tok+'", TREG_ORG = "'+org+'", TREG_URL = "'+base+'" }',
'- Gemini CLI: append the three KEY=value lines to ~/.gemini/.env. Caveat: a project',
' .env shadows that file entirely, so repos with their own .env must carry them too.',
'- Cursor: a guarded line in the shell profile (~/.zshrc or ~/.bashrc):',
' [ -n "$CURSOR_AGENT" ] && export TREG_TOKEN='+tok+' TREG_ORG='+org+' TREG_URL='+base,
'- pi: same guarded profile line keyed on $PI_CODING_AGENT — or set',
' "shellCommandPrefix": "source ~/.pi-env.sh" in ~/.pi/agent/settings.json and put the',
' exports in that file.',
'- opencode: no global env config and no fingerprint var — use a shell.env plugin, or an',
' unguarded profile export ONLY on a machine that runs no other agents; ALSO export',
' TREG_CLIENT=opencode so your calls are attributed to your runtime.',
'- anything else: a profile line guarded by an env var only your runtime sets, plus',
' TREG_CLIENT=<your-runtime-name> if treg does not already detect you.',
'',
'Do NOT put the token in a repo file (.env, settings committed to git) — it is a secret,',
'and a repo-level file would also hand YOUR identity to every other agent in that repo.',
'',
'From then on the treg CLI automatically acts as you (TREG_TOKEN beats the machine',
'owner\'s ~/.treg config), and raw HTTP works anywhere:',
' curl '+base+'/call/<tool>/<path> -H "X-Treg-Token: $TREG_TOKEN" -H "X-Treg-Org: '+org+'"',
'',
'List what you may use: GET '+base+'/tools',
'Your calls are capped and logged as "'+a.name+'", so keep them purposeful.',
'',
'FINALLY, after persisting the env, confirm the identity landed by checking in AS yourself:',
' curl -s -X POST '+base+'/agents/checkin -H "X-Treg-Token: '+tok+'" -H "X-Treg-Org: '+org+'"',
'A {"connected": true, "you": "agent-…"} reply means treg registered the connection —',
'the dashboard shows this agent as connected from that moment.',
].join('\n'); },
rosterMembers(){ // one roster: each person, then the agents they minted, then the runtimes
// DETECTED in their traffic (observed — not yet their own identity, hence "Scope this agent").
// Orphaned agents (creator left, or pre-created_by rows) trail at the end.
const humans=(this.orgMembers||[]).filter(m=>!m.is_agent);
const agents=(this.orgMembers||[]).filter(m=>m.is_agent);
const obs=(this.observedAgents||[]);
const out=[];
humans.forEach(h=>{ out.push({...h, key:'u'+h.user_id});
agents.filter(a=>a.created_by===h.email).forEach(a=>out.push({...a, key:'u'+a.user_id}));
obs.filter(o=>o.member===h.email).forEach(o=>out.push({...o, is_observed:true, key:'o'+o.member+'/'+o.client})); });
agents.filter(a=>!humans.some(h=>h.email===a.created_by)).forEach(a=>out.push({...a, key:'u'+a.user_id}));
return out; },
agentConnected(){ const n=this.newAgent; if(!n) return false;
const row=(this.agents||[]).find(a=>a.user_id===n.user_id); return !!(row&&row.connected); },
isOwner(){ return this.activeRole==='owner'; }
}
+42
View File
@@ -0,0 +1,42 @@
export default {
// Product analytics — only when this deployment opted in (meta.posthog_key present); self-hosters send nothing.
initAnalytics(){
// /sitetrack.js (loaded at the bottom of this page, before Vue mounts) normally initialises
// PostHog already — with pageviews on, so first-touch source survives into the person. Then
// this only has to identify. The inline path below is the fallback for a stale bundle.
if(window.__phInit){ this.analyticsIdentify(); return; }
const key=this.meta && this.meta.posthog_key; if(!key) return; window.__phInit=true;
const host=this.meta.posthog_host || 'https://eu.i.posthog.com';
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.posthog.com","-assets.i.posthog.com")+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug getPageViewId captureTraceFeedback captureTraceMetric".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
// Session replay is enabled on the project; MASK aggressively — the dashboard shows API tokens
// and the setup line + curl snippets carry the caller's token in <pre> blocks. Mask all inputs
// and every code block so a replay can never leak a credential.
window.posthog.init(key,{api_host:host, person_profiles:'identified_only', capture_pageview:false,
session_recording:{maskAllInputs:true, maskTextSelector:'pre, .lc-codewrap, .agent-copy'}});
this.analyticsIdentify();
},
analyticsIdentify(){ if(!this.me)return; if(window.TregTracking){window.TregTracking.identify(this.me,this.activeSlugNow);return;} if(!window.posthog || !window.posthog.identify || !this.me) return;
try{ window.posthog.identify(this.me, {email:this.me}); if(this.activeSlugNow) window.posthog.group('team', this.activeSlugNow); }catch(e){} },
track(name, props){ try{ if(window.posthog && window.posthog.capture) window.posthog.capture(name, props||{}); }catch(e){} },
// Support chat (Intercom) — only when this deployment opted in (meta.intercom_app_id present);
// self-hosters load nothing. Booted AFTER /auth/me resolves (not at the /meta fetch, like
// analytics) so the common case boots identified once instead of anonymous→identified.
initIntercom(){
const app=this.meta && this.meta.intercom_app_id; if(!app || window.__icInit) return; window.__icInit=true;
window.intercomSettings=this.intercomPayload();
const s=document.createElement('script'); s.async=true; s.src='https://widget.intercom.io/widget/'+app;
const i=function(){i.c(arguments)}; i.q=[]; i.c=function(a){i.q.push(a)}; if(!window.Intercom) window.Intercom=i;
document.head.appendChild(s);
try{ window.Intercom('boot', this.intercomPayload()); }catch(e){}
},
intercomPayload(){
const p={app_id:this.meta.intercom_app_id};
// Never send email without user_hash: an unhashed identify is exactly the impersonation
// vector identity verification exists to close. No hash (or no login) = anonymous visitor chat.
if(this.me && this.icHash){ p.email=this.me; p.user_hash=this.icHash;
if(this.activeSlugNow) p.company={id:this.activeSlugNow, name:this.activeSlugNow}; }
return p;
},
intercomUpdate(){ try{ if(window.__icInit && window.Intercom) window.Intercom('update', this.intercomPayload()); }catch(e){} }
}
+135
View File
@@ -0,0 +1,135 @@
export default {
money(micro){ const m=Math.round(Number(micro)||0), s=m<0?'-':'', a=Math.abs(m);
if(a%10000===0) return s+'$'+(a/1e6).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
return s+'$'+(a/1e6).toFixed(6).replace(/0+$/,''); },
async loadBilling(){ if(!this.canAdmin || !this.activeOrgId){ this.billing=null; return; }
this.autoOpen=false; this.autoConsent=false;
this.billing=await this.api('/billing').catch(()=>null);
if(this.billing){ this.topupAmount=this.billing.topup.default_usd;
this.autoAmount=Math.round(this.billing.autotopup.amount_usd);
this.autoThreshold=Math.round(this.billing.autotopup.threshold_usd);
this.loadBillingHistory(); }
// The daily cap lives beside the balance for the user even though it is an org setting.
this.capCfg=await this.api(`/orgs/${this.activeOrgId}/settings`).catch(()=>null);
if(this.capCfg){ this.capUsd=this.capCfg.daily_cap_micro ? (this.capCfg.daily_cap_micro/1e6).toFixed(2) : '';
const k=await this.api(`/orgs/${this.activeOrgId}/tag-keys`).catch(()=>null);
this.budDims=[...new Set([...((k&&k.seen)||[]), ...(this.capCfg.budget_dims||[])])];
if(!this.budDims.length) this.budDims=['customer'];
this.budDim=this.budDim||this.budDims[0];
this.budgets=await this.api(`/orgs/${this.activeOrgId}/budgets`).catch(()=>[]); } },
defaultFor(dim){ return (this.budgets||[]).find(b=>b.dim===dim && b.is_default) || null; },
overridesFor(dim){ return (this.budgets||[]).filter(b=>b.dim===dim && !b.is_default); },
fmtCap(b){ return b.daily_cap_micro==null ? 'no limit' : this.money(b.daily_cap_micro)+'/day'; },
// The server's own words when it has any. Every budget refusal is one a builder must be able to
// act on — "above_platform_ceiling" carries the ceiling, "too_many_budget_dimensions" names the
// dimensions in use — so the generic fallback is the last resort, never the first.
_errMsg(e, fallback){ return (e&&e.detail&&e.detail.message) || (e&&typeof e.detail==='string'&&e.detail)
|| (e&&e.message) || fallback; },
// Blank `val` addresses the DIMENSION'S DEFAULT — `PUT /budgets/{dim}`, the route with no value.
_budgetUrl(dim, val){ const base=`/orgs/${this.activeOrgId}/budgets/${encodeURIComponent(dim)}`;
return val ? `${base}/${encodeURIComponent(val)}` : base; },
async _putBudget(dim, val, body){
return this.api(this._budgetUrl(dim, val),
{method:'PUT', headers:{'content-type':'application/json'}, body:JSON.stringify(body)});
},
async _reloadBudgets(){ this.budgets=await this.api(`/orgs/${this.activeOrgId}/budgets`).catch(()=>this.budgets); },
async saveBudget(){ this.budBusy=true; this.budErr='';
try{
const body={};
if(this.budDaily!=='') body.daily_cap_micro=Math.round(parseFloat(this.budDaily)*1e6);
await this._putBudget(this.budDim, this.budVal.trim(), body);
this.budVal=''; this.budDaily='';
await this.loadBilling(); // re-reads settings: a first limit DECLARES the dimension
await this._reloadBudgets();
}catch(e){ this.budErr=this._errMsg(e, 'could not save that limit'); }
finally{ this.budBusy=false; } },
async toggleBlock(b){ this.budErr='';
// A PARTIAL put: only `status` travels, so caps set earlier survive being blocked/unblocked.
try{ await this._putBudget(b.dim, b.val, {status: b.status==='blocked'?'active':'blocked'});
await this._reloadBudgets();
}catch(e){ this.budErr=this._errMsg(e, 'could not change that'); } },
async removeBudget(b){ this.budErr='';
try{ await this.api(this._budgetUrl(b.dim, b.val), {method:'DELETE'}); await this._reloadBudgets();
}catch(e){ this.budErr=this._errMsg(e, 'could not remove that'); } },
async saveCap(){ this.capBusy=true; this.capErr='';
try{
const micro=Math.round(parseFloat(this.capUsd||'0')*1e6);
this.capCfg=await this.api(`/orgs/${this.activeOrgId}/settings`,
{method:'PATCH', headers:{'content-type':'application/json'},
body:JSON.stringify({daily_cap_micro:micro})});
this.capUsd=this.capCfg.daily_cap_micro ? (this.capCfg.daily_cap_micro/1e6).toFixed(2) : '';
}catch(e){
this.capErr=this._errMsg(e, 'could not save that limit');
}finally{ this.capBusy=false; } },
// Amounts come from our own credit blocks, so this list can never disagree with the balance above
// it; Stripe supplies only the document links, and bhist.ok===false means those were unavailable.
async loadBillingHistory(){ if(!this.canAdmin || !this.billing || !this.billing.configured){ this.bhist={items:[],loading:false,ok:true}; return; }
this.bhist={items:[],loading:true,ok:true};
const out=await this.api('/billing/history').catch(()=>null);
this.bhist={items:(out&&out.items)||[], loading:false, ok:!out||out.stripe_ok!==false}; },
async openPortal(){ this.billingBusy=true; this.err='';
try{ const out=await this.api('/billing/portal',{method:'POST'});
// Stripe's hosted portal owns card, billing address, tax ID and the full invoice archive. Its
// return_url comes back to #billing, so the same tab is right here as it is for Checkout.
window.location.href=out.url; }
catch(e){ this.err='Could not open the billing portal: '+(e.detail||e.status); this.billingBusy=false; } },
bhistLink(i){ return i.invoice_pdf || i.hosted_invoice_url || i.receipt_url || ''; },
bhistLabel(i){ return (i.invoice_pdf||i.hosted_invoice_url) ? 'Invoice' : (i.receipt_url ? 'Receipt' : ''); },
// created_at is naive UTC (the models._now convention), so it needs the Z that isoformat omits —
// without it the browser reads it as local time and a late-evening top-up shows the wrong day.
bhistDate(iso){ if(!iso) return '';
const d=new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : iso+'Z');
return isNaN(d)?'':d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); },
async addFunds(amount){ this.billingBusy=true; this.topupAmount=amount; this.err='';
// Named differently from the server's topup_started (same click, two vantage points —
// a shared name would double-count in trends); this one exists to link session replays.
this.track('topup_checkout_opened',{amount_usd:amount});
try{ const out=await this.api('/billing/topup',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({amount_usd:amount,checkout_source:window.TregTracking?.checkoutSource()||'app'})});
// Stripe's own hosted page does the payment; the balance moves when its webhook lands, never
// on the return redirect (which a payer could simply type). Same tab, so the return lands here.
window.location.href=out.url; }
catch(e){ this.err='Could not start the payment: '+(e.detail||e.status); this.billingBusy=false; } },
// ---- top-up modal ----
openTopup(){ this.topupPick=this.billing.topup.default_usd; this.topupOther=null; this.topupErr='';
// Default ON only for a team with no mandate; a team that already decided is not re-asked.
this.topupAuto=!(this.billing.autotopup.enabled||this.billing.autotopup.consented_at);
this.topupOpen=true; },
// "Other" starts one rung above the biggest card, not blank: the card row ends at $200, so the
// amount a payer reaches for here is larger than that, and a prefilled number is one keystroke
// away from theirs. Only on first pick - a value they typed survives re-selecting the card.
pickOther(){ if(this.topupPick!=='other'&&!this.topupOther){ const top=Math.max(...this.billing.topup.presets); this.topupOther=Math.min(this.billing.topup.max_usd, top>=200?500:top*2); } this.topupPick='other'; },
tierBonus(usd){ const t=this.billing&&this.billing.topup.bonus_tiers; if(!t||!usd) return 0;
// Keys arrive as strings from JSON; the highest floor at or below the amount applies.
let pct=0; Object.keys(t).map(Number).sort((a,b)=>a-b).forEach(k=>{ if(usd>=k) pct=t[k]; });
return Math.floor(usd*1e6*pct/100); },
async payTopup(){ const usd=this.topupUsd; if(!this.topupValid) return;
this.billingBusy=true; this.topupErr=''; this.topupAmount=usd;
this.track('topup_checkout_opened',{amount_usd:usd, auto_opt_in:this.topupAuto, bonus_micro:this.topupBonusMicro});
try{
const fresh=!(this.billing.autotopup.enabled||this.billing.autotopup.consented_at);
if(fresh&&this.topupAuto){
// Consent first, Checkout second: the mandate has to exist before the card that will be
// charged under it. The server stores the numbers and marks it "no_card"; Checkout's
// saved card then arms it from the setup webhook. A failure here stops the payment too -
// paying without the auto top-up the user just agreed to would be a silent downgrade.
this.billing=await this.api('/billing/autotopup',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({enabled:true, consent:true, amount_usd:this.autoAmount, threshold_usd:this.autoThreshold, monthly_cap_usd:this.autoCapUsd, setup_url:false})});
}
const out=await this.api('/billing/topup',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({amount_usd:usd,checkout_source:window.TregTracking?.checkoutSource()||'app'})});
window.location.href=out.url; }
catch(e){ this.topupErr='Could not start the payment: '+(e.detail||e.status); this.billingBusy=false; } },
// The toggle itself never arms auto top-up - it can only DISARM (off is safe) or open the
// settings panel; arming still goes through the consent checkbox + confirm button below.
autoToggled(){ if(this.billing.autotopup.enabled){ this.autoOpen=false; this.setAuto(false); return; }
this.autoOpen=!this.autoOpen; if(!this.autoOpen) this.autoConsent=false; },
async setAuto(on){ this.billingBusy=true; this.err='';
try{ const out=await this.api('/billing/autotopup',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({enabled:on, consent:on, amount_usd:on?this.autoAmount:null, threshold_usd:on?this.autoThreshold:null})});
// No card yet: consent is stored, and Stripe's hosted card page finishes the job. Auto top-up
// arms itself from the setup_intent.succeeded webhook, so there's nothing more to click here.
if(out.setup_url){ window.location.href=out.setup_url; return; }
this.billing=out; this.autoConsent=false; this.autoOpen=false; }
catch(e){ this.err='Auto top-up change failed: '+(e.detail||e.status); }
this.billingBusy=false; }
}
+36
View File
@@ -0,0 +1,36 @@
export default {
topupUsd(){ if(!this.billing) return 0; if(this.topupPick==='other'){ const v=Number(this.topupOther); return Number.isInteger(v)&&v>0?v:0; } return this.topupPick; },
topupValid(){ if(!this.billing||!this.topupUsd) return false; const t=this.billing.topup; return this.topupUsd>=t.min_usd&&this.topupUsd<=t.max_usd; },
topupBonusMicro(){ return this.tierBonus(this.topupUsd)+this.refPresetBonus(this.topupUsd); },
maxBonusPct(){ const t=this.billing&&this.billing.topup.bonus_tiers; return t?Math.max(0,...Object.values(t)):0; },
// The monthly cap is a server-side runaway guardrail, not something the payer is asked to pick:
// the modal sets it to the single-top-up ceiling (effectively unlimited) so a big payer is never
// locked out mid-month by a default sized for $10 refills. The manage panel below can lower it.
autoCapUsd(){ return this.billing?this.billing.topup.max_usd:0; },
// Marketplace shelves. /oauth/providers already returns providers grouped-then-alphabetical, so
// this walks the list once and starts a shelf whenever the category changes — re-sorting here
// would just be a second place to keep the order in step with the registry.
providerGroups(){
const hints={
'SEO/AEO':'search visibility and site analytics — treg holds the approved app',
'Advertising':'read spend and performance, or manage campaigns',
'Social media':'publish and read back as the connected account',
'Community':'bring your own workspace bot',
};
const out=[];
for(const p of this.providers){
const cat=p.category||'Other';
if(!out.length || out[out.length-1].category!==cat) out.push({category:cat, hint:hints[cat]||'', items:[]});
out[out.length-1].items.push(p);
}
return out;
},
// Filter chips narrow the shelves rather than flattening them — with one category picked the
// single remaining shelf header still says which, so the page never loses its place.
shownGroups(){ const gs=this.mkCat ? this.providerGroups.filter(g=>g.category===this.mkCat) : this.providerGroups;
const q=this.q.trim().toLowerCase();
if(!q) return gs;
const hit=p=>((p.name||'')+' '+(p.service||'')+' '+(p.summary||'')).toLowerCase().includes(q);
return gs.map(g=>({...g, items:g.items.filter(hit)})).filter(g=>g.items.length); },
connCount(){ const m={}; for(const c of this.connections){ if(c.provider) m[c.provider]=(m[c.provider]||0)+1; } return m; }
}
+117
View File
@@ -0,0 +1,117 @@
export default async function boot(){
const lifecycle = new AbortController();
let versionTimer;
this.stopLifecycle = () => { lifecycle.abort(); clearInterval(versionTimer); };
const listen = (target, event, handler, capture=false) => target.addEventListener(event, handler, { signal: lifecycle.signal, capture });
document.documentElement.dataset.theme=this.theme;
listen(document, 'click', ()=>{ if(this.mdMenu) this.mdMenu=false; if(this.keyMenu) this.keyMenu=null; }); // outside-click closes compact menus
listen(document, 'keydown', e=>{
// Escape closes any open modal/drawer (a universal expectation the modals didn't honour)
if(e.key==='Escape'){ const orgWasOpen=this.orgMenu; this.closeOverlays(); if(orgWasOpen) this.elements.orgmain?.focus(); if(this.startAgentOpen){ this.startAgentOpen=false; this.elements.startAgentTrigger?.focus(); } else if(this.elements.accountMenu?.open){ this.elements.accountMenu.open=false; this.elements.accountMenu.querySelector('summary').focus(); } return; }
// "/" focuses the search box (the "/" glyph in the box advertised a shortcut that didn't exist)
const t=e.target, typing = t && (t.tagName==='INPUT'||t.tagName==='TEXTAREA'||t.tagName==='SELECT'||t.isContentEditable);
if(e.key==='/' && !typing && this.authed && (this.view==='tools'||this.view==='connections')){ e.preventDefault(); this.elements.search && this.elements.search.focus(); }
});
listen(document, 'click', e=>{ // the org dropdown didn't close on an outside click
if(this.elements.accountMenu && !e.target.closest('.rd-account-menu')) this.elements.accountMenu.open=false;
if(this.startAgentOpen && !e.target.closest('.rd-agent-picker')) this.startAgentOpen=false;
if(this.orgMenu && !e.target.closest('.orgblock') && !e.target.closest('.dropdown')) this.orgMenu=false;
});
// the fixed-position dropdown must follow its trigger when anything scrolls or resizes
listen(window, 'scroll', ()=>{ if(this.orgMenu) this.placeOrgMenu(); if(this.keyMenu)this.keyMenu=null; }, true);
listen(window, 'resize', ()=>{ if(this.orgMenu) this.placeOrgMenu(); if(this.keyMenu)this.keyMenu=null; });
listen(window, 'popstate', e=>{ // browser Back moves between views (was exiting the app)
const mk=(e.state&&e.state.mk)||this.mkFromPath(location.pathname);
if(mk){ this.openProvider(mk, true); return; }
const pf=(e.state&&e.state.platform)||this.platformFromHash();
if(pf){ this.openPlatform(pf, true); return; }
const d=(e.state&&e.state.detail)||this.routeFromPath(location.pathname);
if(d){ this.openDetail(d.kind, d.name, true); return; }
let v=(e.state&&e.state.view)||(location.hash||'').replace('#','')||'tools';
if(v==='billing'){ this.orgTab='billing'; v='orgs'; }
if(['tools','orgs','activity','usage','admin','help','secrets','start','connections','referrals'].includes(v)) this.go(v, true);
});
this.meta = await fetch('/meta',{headers:{'ngrok-skip-browser-warning':'1'}}).then(r=>r.json()).catch(()=>this.meta);
this.proxy = this.meta.public_url || location.origin;
this.initAnalytics();
// deploy detection: long-lived tabs learn about a new bundle on tab focus + a slow poll,
// then offer a one-click refresh (index.html is no-cache, so a soft reload is enough)
this.bootVersion = this.meta.app_version || '';
listen(document, 'visibilitychange', ()=>{ if(!document.hidden) this.checkVersion(); });
versionTimer = setInterval(()=>this.checkVersion(), 5*60*1000);
// Invite-link params, parsed BEFORE the session check: ?invite_org= arrives freshly signed in
// from the email link's POST-confirm; ?invite= is the legacy code path (prefilled login).
const qs = new URLSearchParams(location.search);
const linkOrg = qs.get('invite_org'), inv = qs.get('invite'), ref = qs.get('ref'), oauthSignin = qs.get('signin')==='oauth';
this.oauthSignin=oauthSignin;
if(ref){ try{ localStorage.setItem('treg-ref', ref); }catch(e){} } // survives the sign-in reload so the welcome can preselect the agent the landing was about (see maybeOnboard)
if(linkOrg || inv || qs.get('invite_expired') || ref || oauthSignin){ history.replaceState(null,'',location.pathname+location.hash); } // strip one-shot params so reload/share doesn't replay them
if(linkOrg){ this.inviteLinkOrg=parseInt(linkOrg,10)||null; }
// A shared detail deep link (/app/skills/<x>, /app/tools/<x>) — from the URL itself, or stashed
// before an OAuth hop (the callback always lands on /app, which would otherwise drop the path).
let route=this.routeFromPath(location.pathname);
let mkRoute=this.mkFromPath(location.pathname);
// A public catalog URL renders the marketplace views with or without a session. Set BEFORE the
// /auth/me check so the first paint is already in public mode, and drop the server-rendered
// fallback (see `_spa_catalog_page`) now that the real UI is about to take over.
const catRoute=this.catalogFromPath(location.pathname);
if(catRoute){ this.publicCatalog=true; document.getElementById('prerender')?.remove(); }
const stashed=localStorage.getItem('treg-next');
if(stashed){ localStorage.removeItem('treg-next');
if(!route && !mkRoute){
route=this.routeFromPath(stashed); mkRoute=this.mkFromPath(stashed);
if(route||mkRoute) history.replaceState(null,'',stashed);
} }
this._restoreAgent();
const me = await fetch('/auth/me',{credentials:'include',headers:{'ngrok-skip-browser-warning':'1'}}).then(r=>r.ok?r.json():null).catch(()=>null);
if(me){ this.sessionMode=true; this.me=me.email; this.isAdmin=!!me.is_superadmin; this.onboarded=!!me.onboarded; this.icHash=me.intercom_user_hash||''; await this.loadAll(); this.analyticsIdentify(); this.initIntercom();
// Share-born arrival (/app/skills/x?invite_org=N from the invite email): accept silently and
// enter that team — the emailed "Sign in & accept" click was the consent. Otherwise the normal
// first-run / invite-banner flow.
if(mkRoute){ this.maybeOnboard(); this.openProvider(mkRoute, true); return; }
// Signed in on a /catalog URL: the same views, but as a member — so `publicCatalog` is
// dropped and the shell comes back in full (vault, activity, try-it).
if(catRoute){ this.publicCatalog=false; this.maybeOnboard();
if(catRoute.slug) this.openPlatform(catRoute.slug, true); else this.go('connections', true);
return; }
const pfRoute=this.platformFromHash();
if(pfRoute){ this.maybeOnboard(); this.openPlatform(pfRoute, true); return; }
let landed=false;
if(route) landed=await this.autoAcceptShare(route);
if(!landed) this.maybeOnboard();
if(route){ this.openDetail(route.kind, route.name, true);
// a ?invite=<email> share link opened by a DIFFERENT signed-in account: say why it won't resolve
if(!landed && inv && this.me && inv.toLowerCase()!==this.me.toLowerCase())
this.detailNote='This share link was sent to '+inv+' — you’re signed in as '+this.me+'. Sign out (⏻) and sign in with that email to accept the invite.'; }
else { const hv=this.viewFromHash(); if(hv) this.go(hv, true);
else { this.go('start', true); history.replaceState({view:'start'},'','/app#start'); } } // no deep link → Getting started is the default landing
return; } // GitHub/email session
if(this.token){ await this.loadAll(); this.initIntercom(); // token mode: no email/hash → anonymous visitor chat
// A token holder on a /catalog URL is a member, not a public visitor: same treatment as the
// session branch. Without this the route falls through to viewFromHash() — which is null for
// a path route — and a shelf link lands on Getting started instead.
if(catRoute){ this.publicCatalog=false;
if(catRoute.slug) this.openPlatform(catRoute.slug, true); else this.go('connections', true);
return; }
const pfTok=this.platformFromHash();
if(mkRoute) this.openProvider(mkRoute, true); else if(pfTok) this.openPlatform(pfTok, true); else if(route) this.openDetail(route.kind, route.name, true);
else { const hv=this.viewFromHash(); if(hv) this.go(hv, true);
else { this.go('start', true); history.replaceState({view:'start'},'','/app#start'); } }
return; } // token-in-browser fallback
// A marketplace link is only meaningful to a member, so a logged-out visitor gets the front
// door rather than the share gate (which exists to accept skill/tool shares).
// Logged out on a /catalog URL: render the catalog anyway. Its API is unauthenticated, so this
// is the SAME marketplace UI a member sees, minus what needs a session — not a second build of
// it. This is the branch that makes the catalog crawlable.
if(catRoute){
// loadConnections, not just loadPlatforms: /oauth/providers is an open endpoint, so the
// Platform tab (the provider shelf) fills for a signed-out visitor too — only /connections
// needs a session, and its failure is caught. Without this the tab reads "Platform 0" and
// renders blank in an incognito window.
if(catRoute.slug) this.openPlatform(catRoute.slug, true); else { this.view='connections'; this.loadConnections(); }
return; }
if(!inv && !linkOrg && !route && !qs.get('invite_expired') && !ref && !oauthSignin){ location.replace('/'); return; } // logged-out plain visit → the marketing landing owns the front door. `ref` is a use-case page's CTA (/app?ref=p1), so keep that attribution while opening sign-in in place.
if(route){ this.shareGate=route; this.demo.signin=true; } // shared link while logged out: the focused gate (no sandbox mint, no tour); after sign-in the boot lands on it (email verify reloads in place; OAuth restores via the treg-next stash)
else this.demo.signin=true; // OAuth returns, use-case CTA arrivals (?ref=) and every other logged-out flow open sign-in; nothing mints a sandbox any more
if(inv){ this.invitePrefill=inv; this.emailInput=inv; this.emailStage=false; this.demo.signin=true; } // legacy code link while logged out: prefill + open sign-in; the invite auto-accepts after login (maybeOnboard)
}
+329
View File
@@ -0,0 +1,329 @@
export default {
// ---- endpoint catalog (/catalog/*) ----
// The catalog is additive: every failure here leaves the marketplace exactly as it was, so a
// server that predates these routes shows no platform shelf rather than an error.
async loadPlatforms(){
if(this.plats.loaded || this.plats.loading) return;
this.plats.loading=true;
try{ const d=await this.api('/catalog/platforms'); this.plats.list=(d&&d.platforms)||[]; this.plats.loaded=true; }
catch(e){ this.plats.list=[]; }
finally{ this.plats.loading=false; } },
// Platform pages are hash routes (/app#platform/<slug>): unlike /app/marketplace/<service> there
// is no server route to serve the SPA on a hard reload of a /app/platforms/<slug> path.
platformFromHash(){ const m=/^#platform\/(.+)$/.exec(location.hash||''); return m?decodeURIComponent(m[1]):null; },
// The PUBLIC catalog lives at real paths (/catalog, /catalog/<slug>), not hash routes, because
// a hash is never a distinct URL to a crawler and the whole catalog was therefore unindexable.
// Same Vue views as the signed-in marketplace — this is one UI, not a second implementation.
catalogFromPath(p){
if(p==='/catalog' || p==='/catalog/') return {view:'connections', slug:null};
const m=/^\/catalog\/([^/]+)\/?$/.exec(p||'');
return m ? {view:'platform', slug:decodeURIComponent(m[1])} : null;
},
// A plain view hash (#usage, #orgs, …) so deep links land on the right pane on a FRESH load,
// not only via back/forward. '#billing' is the name 402 bodies and emails use for "add funds";
// billing lives on the Team pane's Billing tab, so it aliases there.
viewFromHash(){ let v=(location.hash||'').replace('#','');
if(v==='billing'){ this.orgTab='billing'; v='orgs'; }
return ['tools','orgs','activity','usage','admin','help','secrets','start','connections','referrals'].includes(v)?v:null; },
openPlatform(slug, fromPop){ this.resetConfirms();
this.detail=null; this.platSlug=slug; this.view='platform'; this.platOpen={}; this.epOpen={}; this.epTab={}; this.platEx={}; this.platActionsOpen=false;
this.platClearFilters(); this.platCopied='';
// A public visitor stays on the indexable /catalog/<slug> URL; a signed-in one keeps the
// in-app hash route. Same view either way — only the address bar differs.
if(!fromPop) history.pushState({platform:slug}, '', this.publicCatalog
? '/catalog/'+encodeURIComponent(slug)
: '/app#platform/'+encodeURIComponent(slug));
this.loadPlatforms(); // the header's provider links need the list
// Connected/not-connected is a member fact and the endpoint needs a session; a public
// visitor has none, so skip it rather than fire a guaranteed 401 on every shelf view.
if(!this.publicCatalog && !this.providers.length) this.loadConnections();
this.loadPlatform();
window.scrollTo(0,0); },
async loadPlatform(){ if(!this.platSlug) return;
this.platErr=''; this.platLoading=true; this.platData=null;
// include_hidden=1: pull the account/utility endpoints too. They render behind a per-section
// "N management endpoints" expander rather than in the main ledger — the page decides that,
// client-side, off each endpoint's `kind` (see platRowsAll / platLedger).
try{ this.platData=await this.api('/catalog/platforms/'+encodeURIComponent(this.platSlug)+'?include_hidden=1'); }
catch(e){ this.platErr = e.status===404
? 'No catalog for this platform on this server yet.'
: 'Could not load the endpoint catalog'+(e.detail?': '+e.detail:'.'); }
finally{ this.platLoading=false; } },
// Tile furniture. Catalog labels carry a parenthetical or an em-dash gloss ("Google Search
// (SERPs, keyword data)") that reads as noise under a logo — the tile shows the name, the
// title attribute keeps the whole thing.
platShort(label){ return String(label||'').split(' — ')[0].split(' (')[0].trim(); },
platInitial(pl){ return (this.platShort(pl.label)||pl.slug||'?').slice(0,1).toUpperCase(); },
// Deterministic hue from the slug: an undrawn platform keeps the same colour across reloads
// and differs from its neighbours, with no colour table to maintain.
platTileBg(slug){ let h=0; const s=String(slug||'');
for(let i=0;i<s.length;i++) h=(h*31+s.charCodeAt(i))%360;
return 'hsl('+h+' 44% 42%)'; },
// The catalog bills in several currencies, so every price the marketplace SHOWS is the server's
// computed `usd` — one unit, so two numbers on the same screen can actually be compared. The
// native amount rides along as a muted suffix wherever the provider bills in something else, so
// nobody has to wonder whether we invented the figure.
// Sub-cent rates get two significant figures ($0.015, $0.00015); a dollar or more gets cents.
usdNum(n){
n=Number(n);
if(n>=1) return n.toFixed(2).replace(/0+$/,'').replace(/\.$/,'');
const s=n.toPrecision(2);
if(s.indexOf('e')>=0) return n.toFixed(8).replace(/0+$/,'').replace(/\.$/,''); // < 1e-6
return s.replace(/0+$/,'').replace(/\.$/,''); },
priceUnit(type){ return {per_call:'call', per_success:'success', per_result:'result', quota_rows:'row'}[type]
|| String(type||'').replace(/_/g,' '); },
// "¥0.10" — the provider's own figure, shown next to the converted one, never instead of it.
nativeAmount(c){
if(!c || c.currency==='USD' || c.value==null) return '';
const n=Number(c.value);
// Counts read as counts ("5 credits", "40 API units"), money keeps its cents (¥0.10).
if(c.currency==='credit' || c.currency==='unit'){
const count=Number.isInteger(n)?String(n):n.toFixed(2);
let u = c.currency==='credit' ? 'credit' : (c.unit||'unit').replace(/_/g,' ').replace(/\bapi\b/,'API');
if(n!==1 && !/s$/.test(u)) u+='s';
return count+' '+u; }
const sym={CNY:'¥'}[c.currency];
const num=n>=0.01 ? n.toFixed(2) : n.toFixed(6).replace(/0+$/,'').replace(/\.$/,'');
return sym ? sym+num : num+' '+c.currency; },
costNative(c){ return c && c.display_unit ? '' : this.nativeAmount(c); },
// The card's starting price. `price_from` arrives as null OR as an empty object, and an empty
// one must read as "no price on the row" — otherwise it short-circuits the OAuth-free branch
// below and an OAuth-only platform silently loses its "free with your account".
platPrice(pl){
const raw=pl && pl.price_from;
const pf=(raw && Object.keys(raw).length) ? raw : null;
const paid=(pf && typeof pf.usd==='number')
? '$'+this.usdNum(typeof pf.display_usd==='number' ? pf.display_usd : pf.usd)+' / '+
(pf.display_unit || this.priceUnit(pf.type))
: null;
if(pf && pf.type==='free') return {free:true, text:'free with your account'};
// An OAuth integration among the providers means the floor price is $0: the account you
// connect IS the licence. Metered providers may serve the same platform (that rate moves to
// the tooltip) — but "from" is a floor, and the floor is free.
const provs=(pl && pl.providers)||[];
const hasOauth=provs.some(s=>{
const p=this.providers.find(x=>x.service===s);
return p && p.auth_kind==='oauth' && !p.metered; });
if(hasOauth) return {free:true, text:'free with your account', paid};
if(paid) return {free:false, text:paid, native:this.nativeAmount(pf)};
return null; },
// priced with no published number, or key-auth with no rate — unknown, not free
platPriceTitle(pl){
const p=this.platPrice(pl); if(!p) return '';
if(p.free) return 'Connect the account and the calls cost nothing extra'+
(p.paid ? ' — without it, metered providers serve this from '+p.paid : '');
const pf=pl.price_from||{};
return ['The cheapest published rate across this platform’s endpoints',
p.native ? 'billed as '+p.native+' / '+this.priceUnit(pf.type)+', converted at the catalog’s FX rate' : '',
pf.note].filter(Boolean).join(' — '); },
endpointAccessLabel(e){
if(e.kind==='routed') return 'Routed platform call';
const p=this.providers.find(p=>p.service===e.provider)
|| (this.platData&&this.platData.providers||{})[e.provider] || {};
if(p.auth_kind==='oauth') return p.metered ? 'OAuth · metered' : 'OAuth connection';
if(p.auth_kind==='key') return e.platform_eligible ? 'Platform + BYOK' : 'BYOK only';
return e.platform_eligible ? 'Platform access' : 'Own connection required';
},
catConnected(service){ return !!this.connCount[service]; },
endpointAuthMethods(e){ return [...new Set([e&&e.authorization_method,
...((e&&e.authorization_methods)||[]), ...Object.keys((e&&e.authorization_paths)||{})].filter(Boolean))]; },
endpointMethodSpec(e){
const methods=this.endpointAuthMethods(e); if(methods.length!==1) return null;
const p=this.providers.find(x=>x.service===e.provider);
return p && (p.authorization_methods||[]).find(m=>m.name===methods[0]);
},
catEndpointConnected(e){
const methods=this.endpointAuthMethods(e);
if(!methods.length) return this.catConnected(e.provider);
return (this.connections||[]).some(c=>c.provider===e.provider && methods.includes(c.authorization_method));
},
endpointConnectLabel(e){
const method=this.endpointMethodSpec(e);
return (method&&method.action_label)||('Connect '+(e.provider_display||e.provider));
},
// Whether a REGISTRY connection to this provider is still metered. Connecting usually ends the
// billing question — the account you connect is the licence — but a `metered` provider bills
// treg's own app per use (X since Feb 2026), so those calls are debited from the team balance
// no matter whose account made them. `/providers` carries the flag, and the deployment's
// TREG_OAUTH_BILLED_PROVIDERS decides whether it is set, so the price a browser shows follows
// the kill switch instead of hard-coding today's answer.
catMetered(service){
const p=this.providers.find(x=>x.service===service); return !!(p && p.metered); },
// A platform is callable today if ANY provider serving it is connected — the card is browsing,
// not routing, so which one it is stays a question for the platform page.
platConnected(pl){ return ((pl&&pl.providers)||[]).some(s=>this.catConnected(s)); },
platConnNames(pl){ return ((pl&&pl.providers)||[]).filter(s=>this.catConnected(s)).map(s=>this.provName(s)).join(', '); },
// The endpoint's inputs, grouped by where they go. Query first, then path, then body: the order
// you fill them in for the common GET, and the order the provider's own docs tend to use.
paramSections(e){
const i=(e&&e.input)||{}, out=[];
const add=(key,label,map,type)=>{
const names=Object.keys(map||{});
if(names.length) out.push({key, label, type, rows:names.map(n=>({name:n, ...map[n]}))}); };
add('query','Query', i.queryParams);
add('path','Path', i.pathParams);
add('body','Body', i.body, i.bodyType);
return out; },
// Examples arrive as real JSON values, so an array or object has to be stringified rather than
// rendered as "[object Object]".
fmtExample(v){ return (typeof v==='object') ? JSON.stringify(v) : String(v); },
// The comparable price, in USD, straight from the server's computed `usd` — the FX table lives
// in the catalog so a rate refresh re-prices every screen at once, and the dashboard cannot
// drift from the CLI by carrying its own constant. Anything with no published number, and a row
// quota (which is not a price at all), can never win "cheapest": showing "from —" would be
// worse than showing the cheapest thing we do know the price of.
costUsd(c){
if(!c || !c.type) return null;
if(c.type==='free') return 0;
if(c.type==='quota_rows') return null;
return typeof c.usd==='number' ? c.usd : null; },
// An OAuth endpoint you have already connected costs nothing MORE to call — the account is the
// licence — so a connected own_account row is the free path, and usually the right answer.
capFree(e){ return this.catEndpointConnected(e) && !this.catMetered(e.provider)
&& (e.scope==='own_account' || (e.cost&&e.cost.type==='free')); },
capCheapest(eps){
let best=null;
for(const e of (eps||[])){
const free=this.capFree(e), n=free?0:this.costUsd(e.cost);
if(n==null) continue;
if(best===null || n<best.n)
best={n, label:(free||n===0)?'free':this.costLabel(e.cost), native:(free||n===0||e.cost.display_unit)?'':this.nativeAmount(e.cost)};
}
return best; },
// "See Zhihu, Toutiao, and 6 more" — two names so the row says what KIND of thing is hiding,
// then a count, because eight more names is the wall the shelf exists to avoid.
moreLabel(rest){
const names=rest.slice(0,2).map(p=>this.platShort(p.label));
if(rest.length<=2) return 'See '+names.join(' and ');
return 'See '+names.join(', ')+', and '+(rest.length-2)+' more'; },
// Cut at the last word boundary before the limit, so a clipped line ends on a word rather than
// mid-token. A curated `name` is short by construction, so this is a no-op on those rows.
clip(text, n){ const s=String(text||'').trim(); if(s.length<=n) return s;
const cut=s.slice(0,n); const sp=cut.lastIndexOf(' ');
return (sp>n*0.6 ? cut.slice(0,sp) : cut).replace(/[\s,;:.—-]+$/,'')+'…'; },
platClearFilters(){ this.platDomain=''; this.platQ=''; this.platVerifiedOnly=false; },
// Every ledger row expands, merged or not: the row says what it does, the expansion says how to
// call it, and which of the two a visitor needs is not something the row shape can decide.
toggleRow(r){ this.platOpen[r.key] = !this.platOpen[r.key]; },
// The per-section "N management endpoints" expander: reveals the account/utility rows folded
// out of the browse ledger (see platLedger).
// Level two: a provider sub-row under a merged row opens its own instruction. Single rows have
// nothing to compare and skip this level entirely.
toggleEp(e){ this.epOpen[e.id] = !this.epOpen[e.id]; },
// One pill per provider on a merged row, carrying that provider's CHEAPEST priced endpoint —
// the number a comparison turns on — and a ✓ if any of its endpoints is verified.
provPills(eps){
const by=new Map();
for(const e of eps){
const cur=by.get(e.provider), n=this.costUsd(e.cost);
if(!cur) by.set(e.provider, {name:e.provider_display||e.provider, cost:e.cost, n, endpoint:e, verified:!!e.verified});
else { cur.verified = cur.verified || !!e.verified;
if(n!=null && (cur.n==null || n<cur.n)){ cur.cost=e.cost; cur.n=n; cur.endpoint=e; } }
}
// Cheapest first, then verified: only three of these are ever shown, so the three that
// survive have to be the ones worth seeing. Unpriced providers sort to the tail — they are
// exactly the ones whose pill would say nothing but a name.
return [...by.values()]
.sort((a,b)=>(a.n==null)-(b.n==null) || (a.n-b.n) || (b.verified-a.verified))
.map(p=>({name:p.name, verified:p.verified, price:p.endpoint.platform_eligible ? this.pillPrice(p.cost) : this.endpointAccessLabel(p.endpoint)})); },
// A pill prices an endpoint only when there IS a price: a published number, or "free". A
// credit-metered or dashboard-only rate has no number to show, and saying so at length is what
// broke the row — the pill just drops it, and the sub-row below carries the full story.
pillPrice(c){
if(!c || !c.type) return '';
if(c.type==='free') return 'free';
if(c.type==='quota_rows'){ const l=this.costLabel(c); return l.length<=8 ? l : ''; }
return typeof c.usd==='number' ? this.costLabel(c) : ''; },
// A price small enough for a collapsed line: "$0.024/call", "2 rows", "free", "credit-priced".
// The long form ("per success · price in provider dashboard") is true but belongs in the
// expanded detail — inline it wraps a row onto three lines, which is what broke the merged rows.
costShort(c){
if(!c || !c.type) return '—';
if(c.type==='free') return 'free';
// A price table has no scalar value but does have a range (and, for video, a per-second
// rate); only a truly unpublished number is "credit-priced".
if(c.value==null && !(c.table && typeof c.usd==='number')) return 'credit-priced';
return this.costLabel(c); },
// `providers` needs a session. Publicly, "is this a provider treg knows" is answered by the
// open catalog response instead — otherwise the whole action chain collapses and a signed-out
// reader sees no way to bring their own key. `mkOauth` stays false without the registry, so the
// public branch offers BYOK (which is true for every provider) rather than guessing Connect.
mkKnown(service){ return this.providers.some(p=>p.service===service)
|| (this.publicCatalog && !!((this.platData&&this.platData.providers||{})[service])); },
mkOauth(service){ const p=this.providers.find(x=>x.service===service); return !!p && p.auth_kind==='oauth'; },
// `providers` comes from /connections, which needs a session. On a public catalog URL there is
// none, so fall back to the display name the OPEN catalog response already carries — otherwise
// every provider on a public shelf would render as its bare slug.
provName(service){ const p=this.providers.find(x=>x.service===service); if(p) return p.display_name;
const c=(this.platData&&this.platData.providers||{})[service];
return (c&&c.display_name) || service; },
// Provider-wide facts, served once per provider on the platform response rather than copied
// onto every row.
provFact(service, key){ const p=(this.platData&&this.platData.providers||{})[service]; return (p&&p[key])||''; },
// The sentences an expanded row needs and a table cell can't hold: how it meters, what it
// rate-limits, where the rate card is. Only ones we actually have — an empty list hides the box.
epFacts(e){
const out=[], c=e.cost;
// Where a credit-metered price actually lives. The chip can only say "credit-priced"; this is
// the sentence that tells you the unit and where to read the rate.
if(c && c.type!=='free' && c.value==null)
out.push('Billed per '+this.priceUnit(c.type)+' — the provider does not publish the rate, so '
+'the number is in your plan on their dashboard.');
if(c && c.note) out.push(c.note);
const limits=this.provFact(e.provider,'limits'); if(limits) out.push('Limits: '+limits);
const pricing=this.provFact(e.provider,'pricing_url'); if(pricing) out.push('Rate card: '+pricing);
return out; },
async copyCall(e){
if(await this.toClipboard(e.call_template||'')){
this.platCopied=e.id; setTimeout(()=>{ if(this.platCopied===e.id) this.platCopied=''; },1500); } },
// Which pane of an endpoint's detail is showing. What you SEND and what comes BACK are two
// documents; stacking them made the expansion a page you scrolled rather than read.
epTabOf(e){ const t=this.epTab[e.id];
return (t==='res' && !e.has_example) ? 'req' : (t || 'req'); },
setEpTab(e, tab){
if(tab==='res' && !e.has_example) return; // no such tab; nothing to show
this.epTab[e.id]=tab;
if(tab==='res') this.loadExample(e); },
// Fetched when the response tab is FIRST opened, never with the page: a platform can carry
// hundreds of endpoints and the captured responses are the heaviest thing in the catalog.
async loadExample(e){
if(this.platEx[e.id]) return; // already loaded, loading, or failed
this.platEx[e.id]={loading:true, err:'', text:''};
const slot=this.platEx[e.id];
try{ const d=await this.api('/catalog/examples/'+encodeURIComponent(e.id)); slot.text=JSON.stringify(d,null,2); }
catch(err){ slot.err = err.status===404 ? 'No example was captured for this endpoint.' : 'Could not load the example response.'; }
finally{ slot.loading=false; } },
// Price, compact enough to sit in a table cell, and always in USD: "$0.015/success", "1 row",
// "free", "—". The provider's own figure follows as a muted suffix (see `costNative`).
costLabel(c){
if(!c || !c.type) return '—';
if(c.display_unit && typeof c.display_usd==='number') return (c.display_prefix||'')+'$'+this.usdNum(c.display_usd)+(c.display_suffix||'')+'/'+c.display_unit;
if(c.type==='free') return 'free';
// Known billing unit, unpublished number: say which, and where the number lives. A bare
// "per success" reads as free, and "—" hides that we do know how it's metered.
if(c.value==null && !c.table){ // a price table has no scalar `value`; its range renders below
const per={per_call:'call', per_success:'success', per_result:'result'}[c.type];
return per ? 'per '+per+' · price in provider dashboard' : '—'; }
// Rows convert like any meter once fx carries a rate — dollars first, native as the fallback.
if(c.type==='quota_rows' && typeof c.usd!=='number'){
const n=Number(c.value); return n+' row'+(n===1?'':'s')+'/call'; }
// A published number the FX table can't convert (an unknown currency) still beats silence —
// fall back to the native figure rather than dropping the price entirely.
if(typeof c.usd!=='number') return this.nativeAmount(c)+'/'+this.priceUnit(c.type);
const unit=(c.type==='quota_rows'?'call':this.priceUnit(c.type));
// A duration-priced table (video models) is quoted per second, the way the model is sold.
if(c.rate_unit && typeof c.rate_usd==='number'){
const lo=c.rate_usd_min, hi=c.rate_usd;
return (typeof lo==='number' && lo<hi ? '$'+this.usdNum(lo)+'-$'+this.usdNum(hi) : '$'+this.usdNum(hi))+'/'+c.rate_unit; }
// Any other price table (image models) is a range: cheapest row up to the validated ceiling.
if(typeof c.usd_min==='number' && c.usd_min<c.usd) return '$'+this.usdNum(c.usd_min)+'-$'+this.usdNum(c.usd)+'/'+unit;
return '$'+this.usdNum(c.usd)+'/'+unit; },
costTitle(c){ if(!c) return 'The catalog has no price for this endpoint';
if(c.display_unit) return this.costLabel(c)+(c.note ? ' — '+c.note : '');
const nat=this.nativeAmount(c);
return [c.note,
nat ? 'billed as '+nat+'/'+this.priceUnit(c.type)+', converted at the catalog’s FX rate' : '',
c.value==null&&c.type!=='free' ? 'billed '+c.type.replace(/_/g,' ')+' — the provider does not publish the rate, check your plan in their dashboard' : ''].filter(Boolean).join(' — ')
|| 'What this endpoint costs at the provider'; }
}
+271
View File
@@ -0,0 +1,271 @@
export default {
mkProvider(){ return this.providers.find(p=>p.service===this.mkService)||null; },
mkConns(){ return this.connections.filter(c=>c.provider===this.mkService); },
mkNeedsCred(){ return this.mkConns.filter(c=>c.extra_credential_note); },
// Which capabilities ANY account here already holds — a page-level "you have this" summary,
// since the permission list describes the integration, not one account.
mkGranted(){ const s=new Set(); for(const c of this.mkConns) for(const cap of (c.capabilities||[])) s.add(cap); return s; },
// ---- endpoint catalog ----
// Platform tiles grouped by the category the catalog assigns each platform. "Other" (the
// taxonomy's bucket for things like `account`) gets no tile — those capabilities are only
// meaningful inside a platform page, not as a destination of their own.
// The canonical reading order. It is only an ORDER, not the list: the categories themselves come
// from the data, so a category the catalog invents tomorrow still gets a shelf and a tab (sorted
// to the end) instead of vanishing. A hint is optional for the same reason.
platCategories(){
// The founder's reading order. The answer engines (ChatGPT, Perplexity, Gemini…) used to be
// their own shelf; they are SEO now, unfeatured, so they surface inside its "see more" row
// rather than as a category of six. The list is a lookup, not a schema — a category the
// catalog invents tomorrow still gets a shelf, sorted to the end.
const order=['Enrichment','SEO/AEO','Social','Advertising','E-commerce','Reviews & Apps','AI generation','Community'];
const hints={
'AI generation':'video, image and voice models, the same model over several routes priced side by side',
'SEO/AEO':'rankings, keywords and backlinks — what search engines know, and what the answer engines say',
'Social':'posts, profiles and comments, straight from the feeds',
'Enrichment':'people and company records, resolved from an email or a domain',
'Advertising':'ad libraries and creator marketplaces — what is being promoted, and for how much',
'E-commerce':'listings, prices and sellers across the marketplaces',
'Reviews & Apps':'app stores and review sites — what people rate, and what they say',
'Community':'forums and chat, where people answer each other',
};
const by={};
for(const pl of this.plats.list){ const c=pl.category||'Other'; if(c==='Other') continue; (by[c]=by[c]||[]).push(pl); }
const known=order.filter(c=>by[c]);
const rest=Object.keys(by).filter(c=>!order.includes(c)).sort();
return known.concat(rest).map(c=>({category:c, hint:hints[c]||'', items:by[c]}));
},
mkTabs(){
const out=[{key:'all', label:'All', n:this.platCategories.reduce((a,g)=>a+g.items.length,0)}];
for(const g of this.platCategories) out.push({key:g.category, label:g.category, n:g.items.length});
out.push({key:'platform', label:'Platform', n:this.providers.length});
return out;
},
// A build without /catalog has no tiles to show, so it falls back to the integration shelves
// rather than opening on an empty tab.
mkTabActive(){ return this.platCategories.length ? this.mkTab : 'platform'; },
// Shelves, with the long ones cut down to their featured tiles. A category of 14 platforms is a
// wall you scroll past rather than read, so past PLAT_SHELF_MAX only the catalog's `featured`
// ranks get a full tile and the tail collapses into one "See X, Y, and N more" row. Rank first,
// then endpoint count — the tail sorts by size alone, which is the only signal it has left.
platCatGroups(){
const groups = this.mkTabActive==='all' ? this.platCategories
: this.platCategories.filter(g=>g.category===this.mkTabActive);
// The top-nav search reaches here too: with a query, every match shows (no featured collapse —
// a hit hidden behind "N more" reads as no hit) and empty shelves drop away.
const q=this.q.trim().toLowerCase();
if(q){
const hit=p=>((p.label||'')+' '+(p.slug||'')+' '+(p.providers||[]).join(' ')).toLowerCase().includes(q);
return groups.map(g=>{ const items=g.items.filter(hit)
.sort((a,b)=>(b.endpoints||0)-(a.endpoints||0));
return {...g, items, rest:[], total:items.length}; }).filter(g=>g.items.length);
}
return groups.map(g=>{
const items=[...g.items].sort((a,b)=>
(a.featured==null?1e9:a.featured)-(b.featured==null?1e9:b.featured) || (b.endpoints||0)-(a.endpoints||0));
const feat=items.filter(p=>p.featured!=null);
// Nothing ranked means nothing to feature — showing an empty shelf over a "more" row would
// hide the whole category behind a click.
// `total` stays the whole category so the shelf's count matches its tab — a header reading
// "SEO 5" under a tab reading "SEO 10" looks like tiles went missing.
if(items.length<=8 || !feat.length || this.platShelfOpen[g.category]) return {...g, items, rest:[], total:items.length};
return {...g, items:feat, rest:items.filter(p=>p.featured==null), total:items.length};
});
},
mkPlatforms(){ return this.plats.list.filter(pl=>(pl.providers||[]).includes(this.mkService)); },
platRow(){ return this.plats.list.find(pl=>pl.slug===this.platSlug)||null; },
platLabel(){ return (this.platData&&this.platData.platform&&this.platData.platform.label)
|| (this.platRow&&this.platRow.label) || this.platSlug || 'Platform'; },
platProviders(){ // providers with endpoints here, in catalog order
const seen=[]; for(const g of (this.platData&&this.platData.capabilities||[])) for(const e of (g.endpoints||[])) if(!seen.includes(e.provider)) seen.push(e.provider);
for(const e of (this.platData&&this.platData.extended||[])) if(!seen.includes(e.provider)) seen.push(e.provider);
// Navigation is a fact of THIS platform response, not of the separately-loaded connection
// registry. Depending on that async registry made the entire row flicker away locally. `treg`
// is the synthetic router, not a provider page a person can open.
return seen.filter(s=>s!=='treg'); },
// ---- the ledger ----
// Sections, their order and the merged/single split are all decided by the server (see
// catalog_store.domain_rows) so the CLI, the API and this page can't disagree about what the
// platform contains. Everything below is presentation the server has no business knowing:
// the price label, whether the row is callable TODAY (which depends on who is logged in), and
// the haystack the filter box searches.
platRowsAll(){
if(!this.platData) return [];
const out=[];
for(const sec of (this.platData.domains||[])) for(const r of (sec.rows||[])){
const eps=r.endpoints||[]; if(!eps.length) continue;
const cheapest=this.capCheapest(eps);
const provs=[...new Set(eps.map(e=>e.provider_display||e.provider))];
const pills=this.provPills(eps);
out.push({...r, domain:sec.domain, endpoints:eps,
// What the row SHOWS. The server already picked `name` over `summary` where a curated
// name exists; this is the guard for the rows where one doesn't yet — a DataForSEO
// summary is documentation prose and would render a paragraph in a table cell. The full
// text is never lost: the expansion shows it whole.
title:this.clip(r.description, 90),
// Who serves it, as plain text — the collapsed row names the providers and prices none
// of them. Three names is what fits on one line; the rest become a count, with the whole
// list in the title attribute.
// Three pills and a +N. Never four, never a wrap: the strip is what decides whether a
// merged row is one line, and one line is the rule.
pills:pills.slice(0,3), pillsMore:Math.max(0, pills.length-3),
pillsMoreTitle:pills.slice(3).map(x=>x.name).join(', '),
// Providers AND endpoints, always both — one provider can offer the same job three ways,
// so the two numbers differ and the endpoint count standing in for the provider count read
// "6 providers" on a 3-provider row. Both live in the cell's tooltip rather than on a
// second line, because a second line is exactly what made these rows look broken.
provN:provs.length,
provTitle:provs.length+' provider'+(provs.length===1?'':'s')+' · '+eps.length+
' endpoint'+(eps.length===1?'':'s')+' — '+provs.join(', '),
// the capability is the join key when there is one; an unmapped row is its endpoint
key:(r.capability||'')+'|'+eps[0].id,
// a "management" row is one whose every endpoint is plumbing (account/utility) — those
// fold behind a per-section expander instead of sitting in the main ledger.
mgmt: eps.every(e=>e.kind==='account'||e.kind==='utility'),
// "from $0.001" on a merged row, the flat label on a single one — and never "from free",
// which reads as a hedge on the one price that needs none.
price: cheapest ? ((r.kind==='merged'&&eps.length>1&&cheapest.n>0?'from ':'')+cheapest.label)
// No dollar figure anywhere, but a cost note: the credit-metered providers
// (Apollo, PDL, Hunter…) whose price IS documented, in their own units.
: (eps.some(e=>e.cost&&e.cost.note) ? 'see provider' : '—'),
// the provider's own figure, so nobody has to wonder whether we invented the converted one
priceNative: cheapest ? cheapest.native : '',
priceTitle: r.kind==='merged'
? 'The cheapest of the '+eps.length+' providers on this row — open it for each one'
: this.costTitle(eps[0].cost),
verified: eps.some(e=>!!e.verified),
ready: eps.some(e=>this.catEndpointConnected(e)),
hay: (r.description+' '+r.domain+' '+(r.capability||'')+' '+eps.map(e=>
e.provider+' '+(e.provider_display||'')+' '+(e.name||'')+' '+e.path+' '+e.summary).join(' ')).toLowerCase()});
}
return out; },
// The text box and the verified checkbox narrow the row list; the domain chips then narrow it
// again. Splitting it here is what lets each chip carry the count it would actually show.
platRowsPreDomain(){
const q=this.platQ.trim().toLowerCase();
return this.platRowsAll.filter(r=>(!this.platVerifiedOnly||r.verified) && (!q||r.hay.includes(q))); },
platDomainTabs(){
// Only the browse surface counts on the chips: management rows (account/utility) live behind
// a per-section expander, not in the domain tally.
const n={}; for(const r of this.platRowsPreDomain) if(!r.mgmt) n[r.domain]=(n[r.domain]||0)+1;
// The server's order (busiest first, "other" last) is the one the chips keep — a chip bar
// that reshuffles as you type is unusable.
return (this.platData&&this.platData.domains||[]).filter(s=>n[s.domain]).map(s=>({domain:s.domain, n:n[s.domain]})); },
platLedger(){
// Browse rows (data/action) make the domain sections; a section with no visible row does not
// render AT ALL. Filing management rows per-domain conjured sections that existed only
// because a hidden endpoint carried that capability id — CAMPAIGNS 0, SCHOOL 0, TITLE 0.
const vis={};
for(const r of this.platRowsPreDomain){
if(r.mgmt || (this.platDomain && r.domain!==this.platDomain)) continue;
(vis[r.domain]=vis[r.domain]||[]).push(r); }
const out=(this.platData&&this.platData.domains||[]).filter(s=>vis[s.domain])
.map(s=>({domain:s.domain, rows:vis[s.domain]}));
// ...and every management endpoint on the platform lands in ONE collapsed section at the
// bottom, its domain ignored: account/utility routes are the provider's own plumbing, and
// which capability id they happen to carry is not a fact worth a heading.
const acts=this.platActionRows;
if(acts.length) out.push({domain:'Actions', actions:true, count:acts.length,
rows:this.platActionsOpen?acts:[]});
return out; },
// What "All" counts: the same population the domain chips add up to. Counting the management
// rows here made the All chip disagree with both the chips beside it and the stat line under it.
platBrowseCount(){ return this.platRowsPreDomain.filter(r=>!r.mgmt).length; },
// Platform-wide, so a domain chip (which is a browse axis) hides it rather than filtering it.
platActionRows(){
return this.platDomain ? [] : this.platRowsPreDomain.filter(r=>r.mgmt); },
// Two numbers, because a merged row stands for several endpoints — the browse surface only, so
// the header count never jumps when the management expander is opened.
platStats(){
let rows=0, eps=0;
for(const r of this.platRowsPreDomain){ if(r.mgmt || (this.platDomain && r.domain!==this.platDomain)) continue;
rows++; eps+=r.endpoints.length; }
return {rows, eps}; },
catalogDeny(){ const c=this.tForm.cli; return (c && this.catalogClis && this.catalogClis[c.bin]) || []; },
catalogExtra(){ // catalog patterns not already in the own list — the union dedupes at run time, so showing both would double up
const own=new Set((this.tForm.cli&&this.tForm.cli.deny||[]).map(p=>p.trim())); return this.catalogDeny.filter(p=>!own.has(p)); },
sortedInvites(){ // the clicked email link's team first, then newest-first (the API's order)
return [...this.pendingInvites].sort((a,b)=>(b.org_id===this.inviteLinkOrg?1:0)-(a.org_id===this.inviteLinkOrg?1:0)); },
selectedInvites(){ return this.sortedInvites.filter(i=>this.inviteSel[i.id]); },
inviteFirstRun(){ return !this.myOrgs.some(o=>!this.isPersonal(o)); },
// no real team yet → decline offers create-team
activityRows(){ // proxy calls + server CLI runs, one time-sorted feed (ISO strings compare fine)
// Local runs now arrive via /runs (where:'local'); drop them from the calls feed so they aren't double-counted.
// Cost precedence: what was CHARGED (settle amount; 0 on a release — the estimate alone would
// over-report a refunded call as spend), else observed, else the estimate (old rows / own-key).
// A metered async task (video/image generation) is the one row whose charge is decided AFTER
// the call returned: the server nulls the charge while the task is pending, so show the hold.
const calls=(this.calls||[]).filter(c=>c.kind!=='local_run').map(c=>({kind:'call', id:c.id, created_at:c.created_at, user_email:c.user_email, client:c.client, tool:c.tool_name, action:c.method, status:c.status_code, ok:c.status_code<400,
task:c.async_task||null, held:!!(c.async_task&&c.async_task.status==='pending'),
cost:(c.async_task&&c.async_task.status==='pending')?c.async_task.reserved_micro:(c.cost_charged_micro!=null?c.cost_charged_micro:(c.cost_observed_micro!=null?c.cost_observed_micro:c.cost_estimated_micro)), tier:c.credential_tier, tags:c.tags,
has_result:!!c.has_result, endpoint_id:c.endpoint_id, call_ref:c.call_ref, path:c.path, cached:c.cached, api_key_id:c.api_key_id, api_key_name:c.api_key_name, api_key_prefix:c.api_key_prefix}));
const runs=(this.runs||[]).map(r=>({kind:'run', where:r.where, id:r.id, created_at:r.created_at, user_email:r.user_email, client:r.client, tool:r.tool, action:(r.argv||[]).join(' ').slice(0,48)||'-', status:(r.where==='local'?'local run':'exit '+r.exit_code), ok:(r.where==='local'?true:r.exit_code===0), api_key_id:r.api_key_id, api_key_name:r.api_key_name, api_key_prefix:r.api_key_prefix}));
return calls.concat(runs).sort((a,b)=>a.created_at<b.created_at?1:-1);
},
activityCallCount(){ return this.activityRows.filter(a=>a.kind==='call').length; },
activityCachedCount(){ return this.activityRows.filter(a=>a.kind==='call' && a.cached).length; },
apiKeyGroups(){
const groups=[], humanByIdentity={}, standaloneAgents={};
for(const row of this.apiKeys||[]){
if(row.assigned_type!=='human') continue;
const identity=(row.identity||'').toLowerCase();
let group=humanByIdentity[identity];
if(!group){ group={identity:row.identity,name:row.assigned_name||row.identity,type:'human',rows:[]}; humanByIdentity[identity]=group; groups.push(group); }
group.rows.push(row);
}
for(const row of this.apiKeys||[]){
if(row.assigned_type!=='agent') continue;
const owner=humanByIdentity[(row.created_by||'').toLowerCase()];
if(owner){ owner.rows.push(row); continue; }
let group=standaloneAgents[row.identity];
if(!group){ group={identity:row.identity,name:row.assigned_name||row.identity,type:'agent',rows:[]}; standaloneAgents[row.identity]=group; groups.push(group); }
group.rows.push(row);
}
return groups;
},
activityShown(){ return this.actOkOnly ? this.activityRows.filter(a=>a.ok) : this.activityRows; },
callBodyPretty(){ const t=this.callView&&this.callView.response&&this.callView.response.body_text; return t?this.pretty(t):''; },
// JSON pretty-printed when it parses (a 2 MB parse is ~20 ms)
callBodyTruncated(){ return !this.callViewFull && this.callBodyPretty.length>262144; },
// cap the RENDERED text at 256 KB unless asked for all of it
callBodyShown(){ const t=this.callBodyPretty; return this.callBodyTruncated ? t.slice(0,262144)+'\n… (truncated)' : t; },
anyTagged(){ // hide the column entirely for teams that never send X-Treg-Meta
return (this.calls||[]).some(c=>c.tags && Object.keys(c.tags).length);
},
// Try-it drawer: the filled query string + the three "how to run it" recipes (agent / CLI / API)
epTryAuthMethods(){ if(!this.epTry) return [];
return [...new Set([this.epTry.authorization_method, ...(this.epTry.authorization_methods||[]),
...Object.keys(this.epTry.authorization_paths||{})].filter(Boolean))]; },
epTryConnectedMethods(){ return this.epTryAuthMethods.filter(m=>{
const a=this.epTryAccessByMethod[m]; return a && (a.tier==='tool'||a.tier==='credential'); }); },
epTryShowAuthSelector(){ return this.epTryAuthMethods.length>1 && this.epTryConnectedMethods.length>1; },
epTryDisplayPath(){ if(!this.epTry) return '';
return (this.epTry.authorization_paths||{})[this.epTryAuthMethod]||this.epTry.path; },
epTryVisibleParams(){ return (this.epTryParams||[]).filter(p=>this.epTryParamAllowed(p)); },
epTryQuery(){ return this.epTryVisibleParams.filter(p=>p.value!=='' && p.value!=null)
.map(p=>encodeURIComponent(p.name)+'='+encodeURIComponent(p.value)).join('&'); },
epTryShellBody(){ return "'"+String(this.epTryBody).replace(/'/g,"'\"'\"'")+"'"; },
epTryCliCall(){ if(!this.epTry) return '';
const args=this.epTryVisibleParams.filter(p=>p.value!=='' && p.value!=null)
.map(p=>`--query ${p.name}=${/\s/.test(String(p.value))?JSON.stringify(String(p.value)):p.value}`).join(' ');
const method=(this.epTry.method||'GET').toUpperCase();
let s=`treg call ${this.epTry.id}${args?' '+args:''}`;
if(method!=='GET') s+=` --method ${method}`;
if(this.epTryAuthMethod) s+=` --authorization-method ${this.epTryAuthMethod}`;
if(method!=='GET' && this.epTryBody.trim()) s+=` --data ${this.epTryShellBody}`;
return s; },
epTryCurl(){ if(!this.epTry) return '';
const q=this.epTryQuery; const url=`${this.proxy}/call/${this.epTry.id}${q?'?'+q:''}`;
const tok=this.myToken||'$TREG_TOKEN', method=(this.epTry.method||'GET').toUpperCase();
let s=`curl -X ${method} "${url}" \\\n -H "X-Treg-Token: ${tok}"`;
if(this.sessionMode && this.activeSlugNow) s+=` \\\n -H "X-Treg-Org: ${this.activeSlugNow}"`; // minted identity token needs the org header
if(this.epTryAuthMethod) s+=` \\\n -H "X-Treg-Authorization-Method: ${this.epTryAuthMethod}"`;
if(method!=='GET' && this.epTryBody.trim()) s+=` \\\n -H "Content-Type: application/json" \\\n --data ${this.epTryShellBody}`;
return s; },
// token + team embedded HERE ONLY (a copy-and-run-now context) — the setup line elsewhere stays clean
epTrySetupLine(){ const S=this.activeSlugNow||'<team-slug>', T=this.myToken||'<YOUR_TOKEN>';
return `set up treg — ${this.proxy}/llms.txt with team ${S} token: ${T}`; },
epTryAgentUse(){ if(!this.epTry) return '';
const what=this.epTry.summary ? this.epTry.summary.replace(/\.$/,'') : this.epTry.id;
const auth=this.epTryAuthMethod ? ` Use authorization_method=${this.epTryAuthMethod}.` : '';
return `Use treg to call ${this.epTry.id} — ${what}.${auth}`; }
}
+206
View File
@@ -0,0 +1,206 @@
export default {
// ---- connections (registry OAuth) ----
async loadConnections(){
this.connErr='';
try{
const [ps, cs]=await Promise.all([
fetch('/oauth/providers').then(r=>r.json()).catch(()=>[]),
this.api('/connections').catch(()=>[]),
]);
this.providers=ps||[]; this.connections=cs||[];
}catch(e){ this.connErr=String(e.message||e); }
this.loadPlatforms(); // fire-and-forget: the catalog must never hold up the connect UI
},
authorizationMethodSpec(providerName, methodName){
const provider=(this.providers||[]).find(item=>item.service===providerName);
return provider && (provider.authorization_methods||[]).find(method=>method.name===methodName);
},
authorizationMethodLabel(providerName, methodName){
const method=this.authorizationMethodSpec(providerName,methodName);
return (method&&method.display_name)||methodName;
},
async connectProvider(p, capability, conn){
// Consent happens in a popup so the dashboard keeps its state; we poll for the result
// rather than depending on the popup being able to talk back to us.
this.connErr=''; this.connBusy=true;
try{
const d=await this.api('/oauth/start',{method:'POST',headers:{'content-type':'application/json'},
// connection_id present = reconnect/widen THAT account; absent = attach another one.
body:JSON.stringify({provider:p.service, capability, connection_id:conn?conn.id:null})});
const w=window.open(d.consent_url,'treg-connect','width=560,height=720');
if(!w){ this.connErr='Popup blocked — allow popups, or open: '+d.consent_url; this.connBusy=false; return; }
for(let i=0;i<150;i++){
await new Promise(r=>setTimeout(r,2000));
let s; try{ s=await this.api('/oauth/status/'+d.state); }catch(e){ continue; }
if(s.status==='done'){
try{w.close();}catch(e){}
await this.loadConnections(); await this.loadAll(); this.connBusy=false;
// Connecting is only half the job — a connection with no target can't be used. If this
// provider has something to choose between, ask straight away rather than leaving the
// user staring at a row that looks finished but isn't.
const fresh=(this.connections||[]).find(c=>c.id===s.secret_id);
if(fresh && fresh.supports_discovery && !fresh.resource_ref) await this.openResources(fresh);
return;
}
if(s.status==='error'){ this.connErr='Connect failed: '+(s.detail||'unknown'); this.connBusy=false; return; }
}
this.connErr='Timed out waiting for authorization.';
}catch(e){ this.connErr=String(e.message||e); }
this.connBusy=false;
},
async enableCapability(c, cap){
// Providers never backfill scopes onto an issued grant, so widening access means re-running
// consent for the bigger scope set. The callback rebinds the existing tool, so nothing
// downstream is rewired — the user just sees the new capability appear.
const p=this.providers.find(x=>x.service===c.provider);
if(!p){ this.connErr='Unknown provider for this connection: '+c.provider; return; }
await this.connectProvider(p, cap);
},
async reconnect(c){
// Re-run consent for the same provider. The callback rebinds the existing tool to the new
// credential, so nothing downstream has to be rewired.
const p=this.providers.find(x=>x.service===c.provider);
if(!p){ this.connErr='Unknown provider for this connection: '+c.provider; return; }
const method=(p.authorization_methods||[]).find(m=>m.name===c.authorization_method);
const options=method ? method.capabilities : (c.capabilities||p.capabilities||['read']);
const granted=new Set(c.capabilities||[]);
const cap=[...options].filter(option=>granted.has(option)).sort((a,b)=>
((p.scope_detail[a]||[]).length-(p.scope_detail[b]||[]).length)).slice(-1)[0]
|| this.methodCapability(p,method);
await this.connectProvider(p, cap, c);
},
capOptions(ask){
// Authorization-method capabilities are declared narrowest → broadest for scope
// containment. Present the choice broadest → narrowest, matching every existing provider.
if(ask && ask.method) return [...(ask.method.capabilities||[])].reverse();
if(!ask || !ask.conn) return (ask&&ask.provider.capabilities)||[];
const method=(ask.provider.authorization_methods||[]).find(m=>m.name===ask.conn.authorization_method);
return method ? method.capabilities : (ask.provider.capabilities||[]);
},
article(w){ return /^[aeiou]/i.test(w||'') ? 'an' : 'a'; },
// "an account", not "a account"
mkCapabilityMethod(cap){ return ((this.mkProvider&&this.mkProvider.authorization_methods)||[]).find(m=>
m.capability_details && (m.capability_details[cap]||[]).length); },
mkCapabilityLabel(cap){ const m=this.mkCapabilityMethod(cap); return (m&&m.capability_labels&&m.capability_labels[cap])||cap; },
mkCapabilityIntro(cap){ const m=this.mkCapabilityMethod(cap); return (m&&m.capability_intros&&m.capability_intros[cap])||''; },
mkCapabilityDetails(cap){
const m=this.mkCapabilityMethod(cap), labels=m&&m.capability_details[cap];
return labels ? labels.map(label=>({scope:'',label})) : (((this.mkProvider&&this.mkProvider.scope_detail)||{})[cap]||[]);
},
capMethod(ask, cap){
if(ask&&ask.method) return ask.method;
if(ask&&ask.conn) return (ask.provider.authorization_methods||[]).find(m=>m.name===ask.conn.authorization_method);
return (ask&&ask.provider.authorization_methods||[]).find(m=>(m.capabilities||[]).includes(cap));
},
capLabel(cap, ask){ const m=this.capMethod(ask,cap), custom=m&&m.capability_labels&&m.capability_labels[cap]; return custom || {read:'Read only', draft:'Read and draft', post:'Read and publish', write:'Read and write', manage:'Full access'}[cap] || cap; },
capHelp(cap, ask){ const m=this.capMethod(ask,cap), custom=m&&m.capability_help&&m.capability_help[cap]; return custom || {
read:'Your agent can view data. It can never change anything.',
// TikTok's `draft` is a genuinely weaker grant, not a softer word for post: video.upload can
// only drop a video into the creator's inbox for them to finish by hand, and TikTok bins it
// after 24h if they don't. Nothing reaches the profile without a human.
draft:'Your agent can view data and prepare content, but only as a draft you finish and post yourself.',
// `post` is the YouTube middle ground: uploading a video and being able to edit or delete
// one are separate Google scopes, so publish-without-touching-the-back-catalogue is a real
// choice rather than a hedge.
post:'Your agent can view data and publish new content. It cannot edit or delete anything already there.',
write:'Your agent can view data and also create, update or publish.',
manage:'Your agent can view and manage everything in the account.',
}[cap] || 'Requests the '+cap+' scopes.'; },
capInReview(cap, ask){ const m=this.capMethod(ask,cap); return !!(m&&(m.capabilities_in_review||[]).includes(cap)); },
capDefault(ask){
if(ask&&ask.method&&ask.method.connect_capability) return ask.method.connect_capability;
return ask&&(ask.provider.connect_default_capability||ask.provider.default_capability);
},
isRecommendedMethod(p, method){ return !!(method && (method.capabilities||[]).includes(p.connect_default_capability||p.default_capability)); },
selectedMethod(ask){ return ask && (ask.provider.authorization_methods||[]).find(m=>m.name===ask.selected); },
methodCapability(p, method){
const caps=(method&&method.capabilities)||[];
const preferred=p.connect_default_capability||p.default_capability;
return method&&method.connect_capability || (caps.includes(preferred) ? preferred : (caps[caps.length-1]||preferred));
},
startConnect(p, conn){
// A pasted-secret provider has no consent screen — the user brings their own bot token (Slack)
// or API key (Apollo, TikHub, …), so setup is a form, not a redirect.
if(p.auth_kind==='token' || p.auth_kind==='key') return void (this.tokenAsk={provider:p, token:'', err:'', busy:false, conn});
// Several separate grants are one Add-account decision. Providers with zero or one method
// keep the old one-click behavior, so LinkedIn and every existing single-method flow do not
// inherit an extra dialog. Reconnects also stay pinned to their stored method.
const methods=p.authorization_methods||[];
if(methods.length>1 && !conn){
const available=methods.filter(method=>method.configured);
const recommended=available.find(m=>this.isRecommendedMethod(p,m)) || available[0] || methods[0];
this.methodAsk={provider:p,selected:recommended.name}; return;
}
if(methods.length===1 && !conn) return this.connectProvider(p,this.methodCapability(p,methods[0]));
// One capability means there's nothing to ask — don't put a dialog in the way.
if((p.capabilities||[]).length<2) return this.connectProvider(p, p.default_capability, conn);
this.capAsk={provider:p, conn};
},
async continueMethod(){
const ask=this.methodAsk, method=this.selectedMethod(ask); if(!method || !method.configured) return;
this.methodAsk=null;
if((method.capabilities||[]).length>1){ this.capAsk={provider:ask.provider,conn:null,method}; return; }
await this.connectProvider(ask.provider,this.methodCapability(ask.provider,method));
},
async submitToken(){
const t=this.tokenAsk; if(!t.token.trim()) return;
t.busy=true; t.err='';
try{
await this.api('/connections/token',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({provider:t.provider.service, token:t.token.trim()})});
this.tokenAsk=null; await this.loadConnections(); await this.loadAll();
}catch(e){ t.err=(e.detail||e.message||e); t.busy=false; }
},
async chooseCapability(cap){
const p=this.capAsk.provider, conn=this.capAsk.conn; this.capAsk=null;
await this.connectProvider(p, cap, conn);
},
connProvider(c){ return (this.providers||[]).find(p=>p.service===c.provider)||null; },
async saveExtraCred(c){
const v=(this.extraCred[c.id]||'').trim(); if(!v) return;
this.extraBusy=c.id; this.connErr='';
try{
await this.api('/connections/'+c.id+'/extra-credential',{method:'POST',
headers:{'content-type':'application/json'}, body:JSON.stringify({value:v})});
this.extraCred[c.id]=''; await this.loadConnections(); await this.loadAll();
}catch(e){ this.connErr=(e.detail||e.message||e); }
this.extraBusy=null;
},
async openResources(c){
this.connErr='';
const pv=this.connProvider(c)||{}; const label=pv.resource_label||'resource'; const plural=pv.resource_plural||(label+'s');
// Open FIRST. Discovery is a live upstream round-trip and can take seconds; waiting for it
// before showing anything makes the button look dead and invites a second click.
this.resPick={id:c.id, label, plural, rows:[], selected:c.resource_ref||'', loading:true, err:''};
try{
const d=await this.api('/connections/'+c.id+'/resources');
if(!this.resPick || this.resPick.id!==c.id) return; // user closed it or opened another
this.resPick={id:c.id, label:d.resource_label||label, plural:d.resource_plural||plural,
rows:d.resources||[], selected:d.selected||'', loading:false,
err:d.setup_required?(d.setup_detail||'Account setup is required.'):''};
// Discovery can change the row underneath us — it backfills a missing label and records
// that the credential works — so pull the list again rather than leaving stale text on screen.
this.loadConnections();
}catch(e){
// e.detail carries the server's (and often the upstream's) actual words; e.message is just 'http'.
if(!this.resPick || this.resPick.id!==c.id) return;
this.resPick.loading=false;
this.resPick.err='Could not list '+plural+': '+(e.detail||e.message||e);
}
},
async chooseResource(r){
try{
await this.api('/connections/'+this.resPick.id+'/resource',{method:'POST',
headers:{'content-type':'application/json'},
body:JSON.stringify({resource_ref:r.id, resource_name:r.label||''})});
this.resPick=null; await this.loadConnections();
}catch(e){ this.connErr=String(e.message||e); }
},
async disconnect(c){
if(this.confirmDisc!==c.id){ this.confirmDisc=c.id; setTimeout(()=>{ if(this.confirmDisc===c.id) this.confirmDisc=null; },4000); return; }
this.confirmDisc=null;
try{ await this.api('/connections/'+c.id,{method:'DELETE'}); await this.loadConnections(); await this.loadAll(); }
catch(e){ this.connErr=String(e.message||e); }
}
}
+1
View File
@@ -0,0 +1 @@
export const LS = 'treg-dash'
+35
View File
@@ -0,0 +1,35 @@
import { inject, markRaw, type ComponentPublicInstance, type InjectionKey } from 'vue'
// Compatibility boundary for the existing Options API feature modules. New components should
// expose typed props/events; the migrated pages share the original per-application state until
// their use cases can be moved independently. Never make this a module-level singleton.
type Dashboard = ComponentPublicInstance & Record<string, any>
const dashboardKey: InjectionKey<Dashboard> = Symbol('dashboard')
export function provideDashboard(vm: Dashboard) {
return { [dashboardKey as symbol]: vm }
}
export function useDashboard() {
const vm = inject(dashboardKey)
if (!vm) throw new Error('Dashboard provider is missing')
const bindings: Record<string, any> = {}
const names = new Set([
...Object.keys(vm.$data),
...Object.keys(vm.$options.computed ?? {}),
...Object.keys(vm.$options.methods ?? {}),
])
for (const name of names) {
Object.defineProperty(bindings, name, {
enumerable: true,
get: () => vm[name],
set: value => { vm[name] = value },
})
}
bindings.$nextTick = vm.$nextTick.bind(vm)
return bindings
}
export function createElements() {
return markRaw({} as Record<string, Element | null>)
}
+50
View File
@@ -0,0 +1,50 @@
import { provideDashboard } from './context'
import data from './data.js'
import boot from './boot.js'
import session from './session.js'
import team from './team.js'
import keys from './keys.js'
import agents from './agents.js'
import projects from './projects.js'
import governance from './governance.js'
import activity from './activity.js'
import billing from './billing.js'
import referrals from './referrals.js'
import secrets from './secrets.js'
import tools from './tools.js'
import skills from './skills.js'
import format from './format.js'
import onboarding from './onboarding.js'
import analytics from './analytics.js'
import help from './help.js'
import connections from './connections.js'
import sharing from './sharing.js'
import navigation from './navigation.js'
import catalog from './catalog.js'
import details from './details.js'
import admin from './admin.js'
import snippets from './snippets.js'
import tryTool from './tryTool.js'
import lifecycle from './lifecycle.js'
import billingComputed from './billingComputed.js'
import catalogComputed from './catalogComputed.js'
import sessionComputed from './sessionComputed.js'
import agentsComputed from './agentsComputed.js'
import onboardingComputed from './onboardingComputed.js'
import detailsComputed from './detailsComputed.js'
export default {
data,
computed: {...billingComputed, ...catalogComputed, ...sessionComputed, ...agentsComputed, ...onboardingComputed, ...detailsComputed},
methods: {setElement(name, element) { this.elements[name] = element }, ...session, ...team, ...keys, ...agents, ...projects, ...governance, ...activity, ...billing, ...referrals, ...secrets, ...tools, ...skills, ...format, ...onboarding, ...analytics, ...help, ...connections, ...sharing, ...navigation, ...catalog, ...details, ...admin, ...snippets, ...tryTool, ...lifecycle},
watch:{
// a11y (WCAG 2.4.3): when a dialog/drawer opens, move focus INTO it (was left on the trigger)
newTool(v){ this.focusOverlay(v); }, newSkill(v){ this.focusOverlay(v); }, newOrg(v){ this.focusOverlay(v); },
showJoin(v){ this.focusOverlay(v); }, addOrg(v){ this.focusOverlay(v); }, copyTool(v){ this.focusOverlay(v); },
tryTool(v){ this.focusOverlay(v); }, 'welcome.on'(v){ this.focusOverlay(v); }, reqAsk(v){ this.focusOverlay(v); },
'welcome.agent'(v){ try{ localStorage.setItem('treg-agent', v); }catch(e){} }, // see _restoreAgent
activeOrgId(){ this.resetRenameForm(); }, // team switch or first load: prefill the rename form
},
provide() { return provideDashboard(this) },
mounted: boot,
beforeUnmount() { this.stopLifecycle?.() },
}
+118
View File
@@ -0,0 +1,118 @@
import { createElements } from './context'
import { LS } from './constants.js'
export default function data(){
let cfg={active:null,orgs:{}}; try{ cfg=JSON.parse(localStorage.getItem(LS))||cfg; }catch(e){}
return {
elements: createElements(),
theme: localStorage.getItem('treg-theme')||'light',
mobileNav: false, // mobile sidebar toggle
// True when this load is a PUBLIC catalog URL (/catalog, /catalog/<slug>). The catalog API is
// unauthenticated, so the same marketplace views render for a signed-out visitor — that is
// what makes the catalog indexable without maintaining a second copy of the UI. Everything
// that needs a session (org switcher, vault, activity, team, try-it) is hidden on `authed`.
publicCatalog:false, oauthSignin:false,
cfg, tokenInput:'', busy:false, loginErr:'', addOrg:false, orgMenu:false, orgMenuStyle:null,
emailInput:'', codeInput:'', emailStage:false, devCode:'', pendingInvites:[], invitePrefill:'', inviteChoice:false, inviteBusy:false,
inviteSel:{}, inviteLinkOrg:null, inviteErr:'', // multi-select accept: checked ids, the org_id the clicked email link was for, partial-failure note
tut:{i:0, panel:null}, tutCopied:false, tourI:0, helpMode:null, xtut:{i:0},
newOrg:false, newOrgName:'', orgBusy:false, orgErr:'', orgMsg:'',
orgMembers:[], orgInvites:[], inviteEmail:'', inviteRole:'member', lastInvite:null,
editAccess:null, accessDraft:{}, inviteCustomize:false, inviteLocalRun:true, inviteToolSel:{}, accessNote:'',
// agents (machine identities), projects (sub-scope) and deny rules (policy)
orgTab:'members', showInvite:false, showAddAgent:false,
apiKeys:[], keyName:'', keyNameInvalid:false, keyBusy:false, keyErr:'', keyMsg:null, newApiKey:null, editKey:null, editKeyName:'', keyMenu:null, keyConfirm:null, activityKey:'',
agentSnip:'prompt', // which paste-ready snippet the agent card shows (prompt = hand-to-agent, first)
snipAgent:null, // an EXISTING agent whose setup snippets are open (no token — placeholder)
agents:[], agentName:'', agentRole:'member', agentCap:-1, agentBusy:false, agentErr:'', agentProjSel:{}, agentAccessMode:null, agentToolSel:{},
observedAgents:[], promoteHint:'', promotePending:null, agentTokens:{}, // minted tokens, THIS page-load only — the server stores hashes
newAgent:null, confirmAgent:null,
projects:[], projectName:'', projBusy:false, confirmProj:null, projDraft:{},
editProj:null, projToolDraft:{}, projToolBusy:false,
denyRules:[], denyForm:{host:'',path_prefix:'',method:'',user_id:null,project_id:null,note:''}, denyBusy:false, confirmDeny:null, cliDeny:[],
bootVersion:'', newVersion:false,
usage:null, usageDays:30, myUsage:null, actTab:'feed', buildTab:'vendor', // Activity page: 'feed' | 'usage'
tagUsage:{}, tagKeys:[], // spend per X-Treg-Meta key: {customer:{…}, workspace:{…}}
// usage-metering: rollups + the member's own used/cap
// Billing (Stripe top-ups). `billing` null = not loaded / not an admin; billing.configured
// false = this deployment sells no balance, so the whole block stays hidden.
billing:null, billingBusy:false, topupAmount:10, autoAmount:10, autoThreshold:5, autoConsent:false, autoOpen:false,
topupOpen:false, topupPick:10, topupOther:null, topupAuto:true, topupErr:'',
capCfg:null, capUsd:0, capBusy:false, capErr:'',
renameName:'', renameSlug:'', renameBusy:false, renameErr:'',
budgets:[], budDims:[], budDim:'', budVal:'', budDaily:'', budBusy:false, budErr:'',
bhist:{items:[],loading:false,ok:true}, // past top-ups + their invoice/receipt links; ok=false means Stripe was unreachable, amounts are still right
// Referral program. Seeded with the same SHAPE the API returns (terms/totals/cap present and
// zeroed) so the template can read ref.terms.hold_days on the very first paint — a v-if on
// `loading` guards the table, but the subtitle above it renders immediately.
refTab:'friend', // 'friend' | 'partner' — the fork at the top of the Referrals view
ref:{loading:false,eligible:false,code:'',link:'',credit_org:null,referrals:[],
terms:{referrer_micro:0,referred_micro:0,min_topup_micro:0,hold_days:0},
totals:{signed_up:0,topped_up:0,earned_micro:0,pending_micro:0},cap:{paid:0,limit:0}},
refCopied:false,
confirmDel:'', confirmLeave:false, confirmRemove:null,
showJoin:false, joinCode:'', joinBusy:false, joinErr:'',
secrets:[], showSecrets:false, secretRows:[{name:'',value:'',kind:'env'}], secretBusy:false, secretErr:'', runNote:'',
newTool:false, toolBusy:false, toolErr:'', confirmDelTool:null, confirmDelSecret:null,
tForm:{id:null, name:'', base_url:'', bindings:[]},
addToolMenu:false, // the + Add tool [endpoint | cli] chooser
// Connections: registry OAuth connects (see oauth_providers.py). `providers` is what treg
// holds an approved app for; `connections` is what this org has actually connected.
providers:[], connections:[], connErr:'', connBusy:false, confirmDisc:null, resPick:null,
mkCat:'', mkService:null, // marketplace: active category filter, and the open integration
byokFocus:null, // provider row to flash after a "Bring your own key" jump to the Platform tab
// Marketplace tab bar: 'all' + one key per catalog category, plus 'platform' for the
// original integration shelves. Data-first is the default view.
mkTab:'all',
platLogoBad:{}, // platform slug → we have no /logos/platforms/<slug>.svg, so draw the initial tile
// Endpoint catalog (GET /catalog/*): the platform axis of the marketplace. Everything here is
// optional — a server without the catalog routes just renders no platform shelf.
plats:{list:[], loaded:false, loading:false},
platSlug:null, platData:null, platErr:'', platLoading:false,
platShelfOpen:{}, // category → its featured shelf has been expanded to the full tile list
// The ledger's filter bar. All three narrow the SAME row list, and a section with no
// surviving rows disappears rather than showing an empty heading.
platDomain:'', platQ:'', platVerifiedOnly:false,
platOpen:{}, // ledger row key → row expanded
platActionsOpen:false, // the single platform-wide account/utility ("Actions") section is open
epOpen:{}, // endpoint id → its provider sub-row (level two, merged rows only) is open
epTab:{}, // endpoint id → which pane of its detail is showing ('req' | 'res')
platEx:{}, // endpoint id → {open, loading, err, text} for the lazily-fetched example
platCopied:'', // endpoint id whose `treg call` line was just copied
capAsk:null, // the access question, asked when a one-method provider has several scope levels
methodAsk:null, // separate grants behind one provider: one selected method, then Continue
tokenAsk:null, // bring-your-own-bot setup (Slack): a form, not a redirect
extraCred:{}, extraBusy:null, // second credential a provider needs on top of OAuth (Google Ads' developer token)
cfgMenu:false, // detail-page ⚙ Configure tool-picker (skills with >1 tool)
tryMenu:false, // detail-page ▶ Try it tool-picker (skills with >1 tool)
// marketplace endpoint Try-it drawer
callView:null, callViewFull:false, callCopied:'', // Activity → one call's request/response drawer
actOkOnly:true, // Activity feed: successes only by default; the toggle shows failed/refused too
epTry:null, epTryTab:'agent', epTryAccess:null, epTryAccessByMethod:{}, epTryAuthMethod:'', epTryParams:[], epTryBody:'', epTryResp:'', epTryStatus:null,
epTryMs:null, epTryCost:null, epTryBusy:false,
catalogClis:null, // bin → catalog default deny patterns (lazy, from /providers.json)
newSkill:false, skillJson:'', skillBusy:false, skillErr:'', skillMode:'folder',
skillFiles:[], detected:null, skillSel:{}, skillVals:{}, skillResults:null,
sessionMode:false, activeSlug: localStorage.getItem('treg-active')||null, meta:{github:false, public_url:location.origin},
view:'tools', q:'', err:'', loading:false, toolTab:'all', bundles:[], confirmDelBundle:null, installCopied:null,
copyRecipe:null, recipeTab:'cURL', viewRecipe:null, recipeSaved:false,
// detail pages (shareable /app/skills/<name> + /app/tools/<name> deep links)
detail:null, detailData:null, detailErr:'', detailLoading:false, detailFile:'SKILL.md', detailCopied:'', detailNote:'',
shareGate:null, // logged-out arrival at a shared detail link: {kind,name} → focused sign-in page (no sandbox/tour)
share:{on:false, email:'', role:'viewer', full:true, busy:false, err:'', sent:null, member:null}, // detail-page "Share…" (invite + land on this page)
me:'', icHash:'', myOrgs:[], isAdmin:false,
onboarded:true, // first-run onboarding done (server flag; gates the welcome modal)
welcome:{on:false, step:0, name:'', agent:'openclaw', moreOpen:false, busy:false, err:''}, // first-run: name your team → pick your agent → setup line
emptyTab:'agent',
tools:[], health:{}, calls:[], runs:[], adminStats:null, adminOrgs:[], adminUsers:[],
adminBusy:false, confirmAdmUser:null, confirmAdmOrg:null,
proxy: location.origin, copyTool:null, snippetTab:'cURL', snippetTabs:['cURL','CLI','Claude Code','Python','Node'], copied:false,
exPath:'<PATH>', exMethod:'GET',
tryTool:null, tryMethod:'GET', tryPath:'', tryBody:'', trying:false, tryResp:null, tryStatus:0, tryMs:0,
useMode:'call', runArgsStr:'', running:false, runOut:null,
startCopyError:'', startCopied:'', startTab:'access', startAgentOpen:false, startTokenShow:false, mdMenu:false, llmsCopied:false, myToken:null, _myTokenOrg:null, defaultKeyId:null, defaultKeyState:null, tokenCopied:false,
agentGuide:null, agentGuideCopied:false, agentRowCopied:null,
vendorAsk:false, vendorCopied:false,
reqAsk:false, reqDone:false, reqBusy:false, reqErr:'', reqForm:{capability:'', note:'', contact:''},
demo:{signin:false},
};
}
+89
View File
@@ -0,0 +1,89 @@
export default {
openDetail(kind, name, fromPop){ this.resetConfirms();
this.detail={kind, name}; this.view='detail'; this.detailFile='SKILL.md'; this.detailCopied=''; this.detailNote='';
if(!fromPop) history.pushState({detail:{kind,name}}, '', '/app/'+(kind==='skill'?'skills':'tools')+'/'+encodeURIComponent(name));
this.loadDetail(); },
async loadDetail(){ if(!this.detail) return; this.detailErr=''; this.detailLoading=true; this.detailData=null;
try{ this.detailData=await this.api((this.detail.kind==='skill'?'/bundles/by-name/':'/tools/by-name/')+encodeURIComponent(this.detail.name)); }
catch(e){
if(e.status===404 && await this.findDetailOrg()) return void (this.detailLoading=false); // it lives in another of my teams — switched
this.detailErr = e.status===404
? 'Not found in your teams. This link needs an invite — ask the person who shared it to invite you (Share… on their side), then click the link again.'
: 'Could not load: '+(e.detail||e.status); }
finally{ this.detailLoading=false; } },
async fullTool(t){ // skill pages carry tool SUMMARIES — resolve the full record (bindings + cli) before acting on it
let full=(this.tools||[]).find(x=>x.id===t.id);
if(!full && !(t.bindings||t.cli)){ try{ full=(await this.api('/tools')).find(x=>x.id===t.id); }catch(e){} }
return full||t; },
async configureTool(t){ this.cfgMenu=false; this.openEditTool(await this.fullTool(t)); },
async tryDetailTool(t){ this.tryMenu=false; this.openUse(await this.fullTool(t)); },
async copyDetail(text, tag){ if(!(await this.toClipboard(text))) return; this.detailCopied=tag; setTimeout(()=>{ if(this.detailCopied===tag) this.detailCopied=''; },1500); },
rowTarget(t){ // where a tools-list row leads: a skill-born tool opens its SKILL (the shareable thing), a bare endpoint opens the tool page
if(t.bundle_id){ const b=this.bundles.find(x=>x.id===t.bundle_id); if(b) return {kind:'skill', name:b.name}; }
return {kind:'tool', name:t.name}; },
rowHref(t){ const d=this.rowTarget(t); return '/app/'+(d.kind==='skill'?'skills':'tools')+'/'+encodeURIComponent(d.name); },
rowOpen(t){ const d=this.rowTarget(t); this.openDetail(d.kind, d.name); },
async autoAcceptShare(route){ // a share-link invite: clicking the emailed "Sign in & accept" IS the consent — accept silently, enter that team
// Match by the email link's org param, or — for a DM'd bare link — by an invite whose landing IS this page.
const path=route?'/app/'+(route.kind==='skill'?'skills':'tools')+'/'+encodeURIComponent(route.name):null;
const inv=this.pendingInvites.find(i=>i.org_id===this.inviteLinkOrg)
||(path&&this.pendingInvites.find(i=>i.landing===path));
if(!inv) return false;
try{
const r=await this.api('/invites/'+inv.id+'/accept',{method:'POST'});
this.pendingInvites=this.pendingInvites.filter(i=>i.id!==inv.id);
this.onboarded=true; try{ await this.api('/onboard/skip',{method:'POST'}); }catch(e){}
if(r&&r.org){ this.activeSlug=r.org; localStorage.setItem('treg-active',r.org); }
await this.loadAll();
this.inviteLinkOrg=null;
this.orgMsg='You joined '+((r&&r.name)||inv.name)+' — this page was shared with you.';
return true;
}catch(e){ return false; } // revoked/used → the detail page's not-found message explains
},
async findDetailOrg(){ // the link may belong to another of MY teams — probe them, switch silently on a unique hit
if(!this.detail) return false;
const others=(this.myOrgs||[]).filter(o=>o.slug!==this.activeSlugNow);
const path=(this.detail.kind==='skill'?'/bundles/by-name/':'/tools/by-name/')+encodeURIComponent(this.detail.name);
for(const o of others){
let h;
if(this.sessionMode) h={'X-Treg-Org':o.slug};
else { const t=(this.cfg.orgs[o.slug]||{}).token; if(!t) continue; h={'X-Treg-Token':t}; }
try{
const r=await fetch(path,{credentials:'include',headers:{'ngrok-skip-browser-warning':'1',...h}});
if(!r.ok) continue;
const data=await r.json();
if(this.sessionMode){ this.activeSlug=o.slug; localStorage.setItem('treg-active',o.slug); }
else { this.cfg.active=o.slug; this.save(); }
this.detailData=data; this.detailErr='';
this.orgMsg='Switched to '+(o.name||o.slug)+' — this '+this.detail.kind+' lives there.';
await this.loadAll();
return true;
}catch(_){}
}
return false;
},
openShare(){
this.share={on:true, email:'', role:'viewer', full:true, busy:false, err:'', sent:null, member:null};
if(this.canAdmin) this.loadOrgAdmin().catch(()=>{}); },
async sendShare(){ const email=(this.share.email||'').trim(); if(!email){ this.share.err='Enter their email.'; return; }
this.share.member=(this.orgMembers||[]).find(m=>(m.email||'').toLowerCase()===email.toLowerCase())||null;
if(this.share.member) return; // already on the team — the modal now says "just send the link"
this.share.busy=true; this.share.err='';
const landing='/app/'+(this.detail.kind==='skill'?'skills':'tools')+'/'+encodeURIComponent(this.detail.name);
// Full access (default) = no restriction. Unchecked = scope to the skill itself + its
// bundled tools (the access list also gates which skills a restricted member can SEE).
let tool_access=null;
if(!this.share.full){
tool_access = this.detail.kind==='skill'
? [this.detail.name].concat((((this.detailData&&this.detailData.tools)||[]).map(t=>t.name)))
: [this.detail.name];
tool_access=[...new Set(tool_access)];
}
try{
const r=await this.api('/orgs/'+this.activeOrgId+'/invites',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({email, role:this.share.role, landing, tool_access, local_run_enabled:false})});
this.share.sent=r;
}catch(e){ this.share.err='Invite failed: '+(e.detail||e.status); }
finally{ this.share.busy=false; } }
}
+44
View File
@@ -0,0 +1,44 @@
export default {
detailShareUrl(){ if(!this.detail) return ''; return location.origin+'/app/'+(this.detail.kind==='skill'?'skills':'tools')+'/'+encodeURIComponent(this.detail.name); },
shareInviteUrl(){ // the DM-able one-click link: page URL + the invited email (prefills sign-in; the
// invite then auto-accepts on landing-match). Deliberately NOT the emailed token — that secret can
// mint a session and must never leave the inbox; this link still requires proving the email.
return this.share.sent ? this.detailShareUrl+'?invite='+encodeURIComponent(this.share.sent.email) : ''; },
detailTree(){ // the bundle's files as a flat, indent-rendered tree: synthesized dir rows + file rows
const files=Object.keys((this.detailData&&this.detailData.files)||{}).sort();
const rows=[{path:'SKILL.md', name:'SKILL.md', depth:0, dir:false}]; const seen=new Set();
for(const p of files){ const parts=p.split('/');
for(let i=0;i<parts.length-1;i++){ const d=parts.slice(0,i+1).join('/');
if(!seen.has(d)){ seen.add(d); rows.push({path:d, name:parts[i]+'/', depth:i, dir:true}); } }
rows.push({path:p, name:parts[parts.length-1], depth:parts.length-1, dir:false}); }
return rows; },
detailFileContent(){ if(!this.detailData) return '';
if(this.detailFile==='SKILL.md') return this.detailData.recipe||'';
return (this.detailData.files||{})[this.detailFile]||''; },
detailParentSkill(){ if(!this.detail||this.detail.kind!=='tool'||!this.detailData||!this.detailData.bundle_id) return null;
return this.bundles.find(b=>b.id===this.detailData.bundle_id)||null; },
detailPrompt(){ // the copyable "give this to your agent" instruction — no token embedded, the
// recipient signs in as themselves (this text is meant to be forwarded)
if(!this.detail) return '';
const B=(this.proxy||location.origin).replace(/\/$/,''); const n=this.detail.name;
const head=[`I want to use the shared ${this.detail.kind==='skill'?'skill':'tool'} "${n}" from my team's treg at ${B} .`,``,
`1. Install the treg CLI (skip if \`treg\` already works) and sign me in:`,
` curl -fsSL ${B}/install.sh | sh`,` treg login`,``];
if(this.detail.kind==='skill'){
const t=(this.detailData&&this.detailData.tools&&this.detailData.tools[0])||null;
return head.concat([
`2. Install the skill into this project (writes the skill folder into my agent skills dir):`,
` treg skill install ${n}`,``,
...(t?[`3. The skill's API calls go through treg's proxy — the credential is injected server-side, never on this machine:`,
` treg call ${t.name} <PATH> # or prefix the real URL: ${B}/call/<full upstream URL>`,``]:[]),
`Then use the "${n}" skill for my request.`,``,
`Full protocol reference: ${B}/llms.txt`]).join('\n');
}
const t=this.detailData||{};
return head.concat([
`2. Call it through the proxy — the credential is injected server-side, never on this machine:`,
` treg call ${n} <PATH> # PATH = the ${t.host||'upstream'} path you'd normally call`,
...(t.cli?[``,` # it's also a CLI — run it with the key injected:`,` treg run ${n} -- <cli args>`]:[]),``,
`Full protocol reference: ${B}/llms.txt`]).join('\n');
}
}
+11
View File
@@ -0,0 +1,11 @@
export default {
dot(s){ return s==='unknown'||s==='-'?'○':'●'; },
short(e){ return (e||'').split('@')[0]; },
activityAgentKey(a){ return (this.apiKeys||[]).find(k=>k.id===a.api_key_id && k.assigned_type==='agent')||null; },
activityWho(a){ const k=this.activityAgentKey(a); return k?(k.assigned_name||this.short(k.identity)):this.short(a.user_email); },
activityOwner(a){ const k=this.activityAgentKey(a); return k&&k.created_by?this.short(k.created_by):''; },
isPersonal(o){ return !!o && o.name===this.me; },
// personal org = the auto-created one named after your email
jumpToTeam(){ const t=this.myOrgs.find(o=>!this.isPersonal(o)); if(t) this.switchOrg(t); }
}
+16
View File
@@ -0,0 +1,16 @@
export default {
// ---- deny rules ----
denyWho(uid){ const m=(this.orgMembers||[]).find(x=>x.user_id===uid); return m?m.email:('user '+uid); },
async addDeny(){ const f=this.denyForm;
if(!f.host && !f.path_prefix && !f.method){ this.orgErr='Give at least a host, a path, or a method.'; return; }
this.denyBusy=true; this.orgErr='';
try{ await this.api('/orgs/'+this.activeOrgId+'/deny',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(f)});
this.denyForm={host:'',path_prefix:'',method:'',user_id:null,project_id:null,note:''}; }
catch(e){ this.orgErr='Add rule failed: '+(e.detail||e.status); }
this.denyBusy=false; await this.loadOrgAdmin(); },
async removeDeny(r){ if(this.confirmDeny!==r.id){ this.confirmDeny=r.id; return; }
try{ await this.api('/orgs/'+this.activeOrgId+'/deny/'+r.id,{method:'DELETE'}); }
catch(e){ this.orgErr='Remove rule failed: '+(e.detail||e.status); }
this.confirmDeny=null; await this.loadOrgAdmin(); }
}
+21
View File
@@ -0,0 +1,21 @@
export default {
async resetDemo(){ try{ await this.api('/onboard/reset',{method:'POST'}); this.onboarded=true; await this.loadAll(); this.orgMsg='Demo teammates removed.'; }
catch(e){ this.err='Reset failed: '+(e.detail||e.status); } },
readMore(section){ try{ window.open('/tutorial'+(section?('#'+section):''),'_blank'); }catch(e){} },
tutGo(i){ this.tut.i=Math.max(0,Math.min(this.tutSteps.length-1,i)); this.tutCopied=false; },
xtutGo(i){ this.xtut.i=Math.max(0,Math.min(this.xtutSteps.length-1,i)); this.tutCopied=false; },
tutHL(text,kind){ return (window.tregHL?window.tregHL(text, window.tregLang(text,kind)):text); },
async tutCopy(text){ if(!(await this.toClipboard(text))) return; this.tutCopied=true; setTimeout(()=>this.tutCopied=false,1500); },
async toClipboard(text){ // guarded copy with a legacy fallback - navigator.clipboard is undefined on non-secure contexts
try{ if(navigator.clipboard){ await navigator.clipboard.writeText(text); return true; } }catch(e){}
try{ const ta=document.createElement('textarea'); ta.value=text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); const ok=document.execCommand('copy'); document.body.removeChild(ta); return ok; }catch(e){ return false; } },
personaLabel(who){ return ((this.tutData.personas||{})[who]||{}).label || who; },
personaTour(who){ return (this.tourData.personas||{})[who] || who; },
tourMatColor(part){ const c=this.tourData.colors; return `var(${c[this.tourParts.indexOf(part)%c.length]})`; },
when(iso){ try{ const d=new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso)?iso:iso+'Z'); const s=(Date.now()-d)/1000; if(s<90)return 'just now'; if(s<5400)return Math.round(s/60)+'m ago'; if(s<172800)return Math.round(s/3600)+'h ago'; return d.toISOString().slice(0,10);}catch(e){return iso;} },
until(iso){ try{ const d=new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso)?iso:iso+'Z'); const s=(d-Date.now())/1000; if(s<=0)return 'expired'; if(s<5400)return 'in '+Math.round(s/60)+'m'; if(s<172800)return 'in '+Math.round(s/3600)+'h'; return 'in '+Math.round(s/86400)+'d';}catch(e){return iso;} },
// future countdown - when() is an "ago" formatter and mislabels a future expiry as "just now"
toolStatus(t){ const ids=(t.bindings||[]).map(b=>b.secret_id).filter(x=>x!=null); if(!ids.length) return '-';
const st=ids.map(i=>this.health[i]||'unknown'); if(st.includes('invalid'))return 'invalid'; if(st.every(s=>s==='ok'))return 'ok'; return 'unknown'; }
}
+37
View File
@@ -0,0 +1,37 @@
export default {
async loadApiKeys(){ if(!this.activeOrgId) return; this.keyErr='';
try{ this.apiKeys=await this.api('/orgs/'+this.activeOrgId+'/api-keys'); }
catch(e){ this.keyErr='Could not load keys: '+(e.detail||e.status); } },
async loadDefaultToken(){
const issued=await this.api('/auth/cli-token').catch(()=>null);
this._myTokenOrg=this.activeSlugNow;
this.defaultKeyId=issued&&issued.default_key_id;
this.defaultKeyState=issued&&issued.default_key_state;
this.myToken=(this.defaultKeyState==='disabled')?null:((issued&&issued.token)||null);
},
async enableDefaultKey(){ if(!this.defaultKeyId) return; this.keyBusy=true; this.keyErr='';
try{ await this.api('/orgs/'+this.activeOrgId+'/api-keys/'+this.defaultKeyId+'/enable',{method:'POST'}); await this.loadDefaultToken(); await this.loadApiKeys(); }
catch(e){ this.keyErr='Could not enable key: '+(e.detail||e.status); } finally{ this.keyBusy=false; } },
keyKind(kind){ return ({default_human:'Default',additional_human:'Additional',legacy_human:'Legacy',agent:'Agent'})[kind]||kind; },
maskedKey(k){ return k.safe_prefix ? k.safe_prefix+'••••••••' : (k.kind==='legacy_human'?'prefix unavailable — older key':'prefix unavailable'); },
async createApiKey(){ const name=(this.keyName||'').trim(); if(!name){ this.keyErr=''; this.keyNameInvalid=true; return; } this.keyBusy=true; this.keyErr='';
try{ this.newApiKey=await this.api('/orgs/'+this.activeOrgId+'/api-keys',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name})}); this.keyName=''; this.keyNameInvalid=false; await this.loadApiKeys(); }
catch(e){ this.keyErr='Could not create key: '+(e.detail||e.status); } finally{ this.keyBusy=false; } },
async renameApiKey(k){ const name=(this.editKeyName||'').trim(); if(!name){ this.keyErr='Enter a key name.'; return; } this.keyBusy=true;
try{ await this.api('/orgs/'+this.activeOrgId+'/api-keys/'+k.id,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({name})}); this.editKey=null; await this.loadApiKeys(); }
catch(e){ this.keyErr='Could not rename key: '+(e.detail||e.status); } finally{ this.keyBusy=false; } },
keyHasMore(k){ return k.can_rename||k.can_disable||k.can_enable||k.can_revoke||(k.can_hide&&k.state==='revoked'); },
toggleKeyMenu(k,e){ if(this.keyMenu&&this.keyMenu.key.id===k.id){ this.keyMenu=null; return; } const r=e.currentTarget.getBoundingClientRect(); this.keyMenu={key:k,top:r.bottom+6,right:Math.max(12,innerWidth-r.right)}; },
requestKeyAction(k,action){ this.keyMenu=null; if(['rotate','disable','revoke','hide'].includes(action)){ this.keyConfirm={key:k,action}; return; } this.keyAction(k,action); },
confirmKeyAction(){ if(!this.keyConfirm)return; const c=this.keyConfirm; this.keyConfirm=null; this.keyAction(c.key,c.action); },
async keyAction(k,action){ this.keyBusy=true; this.keyErr=''; this.keyMsg=null;
try{ const r=await this.api('/orgs/'+this.activeOrgId+'/api-keys/'+k.id+'/'+action,{method:'POST'});
if(r.secret && k.kind==='default_human'){ this.myToken=r.secret; this.defaultKeyId=k.id; this.defaultKeyState='active'; this._myTokenOrg=this.activeSlugNow; this.startTokenShow=false; this.newApiKey={...r,rotated:true}; }
else if(r.secret){ this.newAgent=null; this.snipAgent=null; this.agentSnip='prompt'; this.newApiKey={...r, assigned_name:k.assigned_name, assigned_type:k.assigned_type, user_id:k.user_id, org:this.activeSlugNow, rotated:true}; }
if(r.agent_revoked) this.keyMsg={agent:true,text:k.assigned_name+' was removed from this team, and all its keys were revoked. Historical Activity remains available.'};
if(k.kind==='default_human' && action!=='rotate') await this.loadDefaultToken();
if(r.agent_revoked) await this.loadOrgAdmin(); else await this.loadApiKeys(); }
catch(e){ this.keyErr='Key action failed: '+(e.detail||e.status); } finally{ this.keyBusy=false; } },
showKeyActivity(k){ this.activityKey=String(k.id); this.go('activity'); }
}
+20
View File
@@ -0,0 +1,20 @@
export default {
reloadApp(){ location.reload(); },
async checkVersion(){ // index.html is served no-cache, so a plain reload picks up a new deploy —
// this just detects one: compare /meta's bundle stamp against the one this tab booted with
if(!this.bootVersion || this.newVersion) return;
try{
const m = await fetch('/meta',{headers:{'ngrok-skip-browser-warning':'1'}}).then(r=>r.json());
if(m.app_version && m.app_version !== this.bootVersion) this.newVersion = true;
}catch(_){}
},
placeOrgMenu(){ // the sidebar is a scroll container that clips overflow, so the dropdown is
// position:fixed (viewport coords) and glued to its trigger here on open/scroll/resize
const t=this.elements.orgmain; if(!t) return;
const r=t.getBoundingClientRect();
this.orgMenuStyle={position:'fixed', top:(r.bottom+6)+'px', left:r.left+'px'};
},
toggleOrgMenu(){ this.orgMenu=!this.orgMenu; if(this.orgMenu) this.placeOrgMenu(); },
closeOverlays(){ this.newTool=false; this.newSkill=false; this.showJoin=false; this.newOrg=false; this.addOrg=false; this.tryTool=null; this.copyTool=null; this.orgMenu=false; this.keyMenu=null; this.keyConfirm=null; this.share.on=false; this.methodAsk=null; }
}
+54
View File
@@ -0,0 +1,54 @@
export default {
switchTo(o){ this.switchOrg(o); },
// dropdown "Switch" button - just switch, stay where you are
orgSettings(o){ this.orgMenu=false; // dropdown ⚙ - go INTO that team, then open its settings
if(o.slug!==this.activeSlugNow){ if(!this.sessionMode && !this.connected(o.slug)){ this.addOrg=true; return; } this.switchOrg(o); }
this.go('orgs'); },
resetConfirms(){ this.confirmDelTool=null; this.confirmDelSecret=null; this.confirmDelBundle=null; this.confirmRemove=null; this.confirmAgent=null; this.keyMenu=null; this.keyConfirm=null; this.confirmLeave=false; this.confirmDel=''; this.confirmAdmUser=null; this.confirmAdmOrg=null; },
go(v, fromPop){ this.resetConfirms(); this.mobileNav=false; // stale inline "Confirm" states must not survive a view switch (accidental-delete risk)
// `usage` is a TAB of the activity page now, not a view of its own — keep the old route
// working so an existing /app#usage link, and the balance card's deep link, still land right.
if(v==='usage'){ this.actTab='usage'; v='activity'; this.loadUsage(); }
else if(v==='activity'){ this.actTab='feed'; }
this.detail=null; this.view=v; if(v==='activity')this.loadCalls(); if(v==='admin')this.loadAdmin(); if(v==='orgs'){this.loadOrgAdmin(); this.loadMyUsage(); this.loadBilling();} if(v==='usage')this.loadUsage(); if(v==='secrets'){this.loadSecrets(); if(!this.providers.length)this.loadConnections();} if(v==='connections')this.loadConnections(); if(v==='referrals')this.loadReferrals();
// push history so browser Back navigates BETWEEN views instead of leaving the app; the '/app'
// pathname also walks back from a /app/skills/<x> detail URL so reload doesn't reopen the detail
if(!fromPop) history.pushState({view:v}, '',
this.publicCatalog && v==='connections' ? '/catalog' : '/app#'+v);
if(!fromPop) window.scrollTo(0,0);
this.startAgentOpen=false; this.orgMenu=false;
if(this.elements.accountMenu) this.elements.accountMenu.open=false; },
// ---- detail pages (shareable deep links) ----
routeFromPath(path){ const m=/^\/app\/(skills|tools)\/(.+)$/.exec(path||'');
return m ? {kind:m[1]==='skills'?'skill':'tool', name:decodeURIComponent(m[2])} : null; },
// Marketplace deep links are their own route: they resolve against `providers` (already in
// memory) rather than fetching a detail payload, so they can't share openDetail's loader.
mkFromPath(path){ const m=/^\/app\/marketplace\/([^/]+)$/.exec(path||'');
return m ? decodeURIComponent(m[1]) : null; },
// "Bring your own key", from anywhere: land on the Catalog's Platform tab — the shelf of every
// integration a key can be pasted into — rather than a single provider's detail page. With a
// service, the shelf scrolls to that provider's row and flashes it; the flash clears itself so
// a later visit to the tab doesn't replay a stale highlight.
// A signed-out visitor who opens a provider goes to its PUBLIC page (/tools/<service>) — a
// real navigation, as a method because Vue template expressions cannot reach the `location`
// global (it is not on the template-expression allowlist, so an inline use fails silently).
goPublicTool(service){ location.href='/tools/'+encodeURIComponent(service); },
goByok(service){
this.mkTab='platform'; this.mkCat='';
this.byokFocus=service||null; this.epTry=null;
this.go('connections');
if(!service) return;
this.$nextTick(()=>{ const el=document.getElementById('prov-'+service);
if(el) el.scrollIntoView({block:'center', behavior:'smooth'}); });
setTimeout(()=>{ if(this.byokFocus===service) this.byokFocus=null; }, 4000);
},
openProvider(service, fromPop){ this.resetConfirms();
this.detail=null; this.mkService=service; this.view='provider';
if(!fromPop) history.pushState({mk:service}, '', '/app/marketplace/'+encodeURIComponent(service));
// The consent popup can return before /connections has been re-read, and a deep link may
// arrive before the first load — either way the page needs the data it renders from.
if(!this.connections.length || !this.providers.length) this.loadConnections();
this.loadPlatforms();
window.scrollTo(0,0); }
}
+85
View File
@@ -0,0 +1,85 @@
export default {
maybeOnboard(){ // first-run: a brand-new user with no team yet is asked to NAME THEIR TEAM upfront
// An invite LINK landed here (?invite_org= from /auth/invite-signin's POST)? Open the accept
// modal even for an onboarded user already in teams — a second-team invite must surface too.
if(this.inviteLinkOrg!==null && this.pendingInvites.length){ this.openInviteChoice(); return; }
if(this.inviteLinkOrg!==null && !this.pendingInvites.length && this.sessionMode){
this.orgMsg='That invite was already used or revoked — ask your teammate to re-invite you if you still need access.'; }
if(this.onboarded || !this.sessionMode) return;
if(this.myOrgs.some(o=>!this.isPersonal(o))) return;
// Invited here? Show an ACCEPT-INVITE page (join those teams) instead of forcing them to create a
// throwaway team of their own (confusing: they'd end up with two). Decline → create-team.
if(this.pendingInvites.length){ this.openInviteChoice(); return; }
this.welcome.name=this._suggestTeamName(); this._welcomeAgentFromRef(); this.welcome.on=true; },
_welcomeAgentFromRef(){ // /grokbot's "Setup treg" CTA → the welcome already has Grok Bot picked; any other ref is ignored
let r=null; try{ r=localStorage.getItem('treg-ref'); localStorage.removeItem('treg-ref'); }catch(e){}
if(r && this.welcomeAgents.concat(this.welcomeMoreAgents).some(a=>a.id===r)) this.welcome.agent=r; },
_restoreAgent(){ // the picked agent survives a reload, so Getting started keeps showing the right setup steps
let r=null; try{ r=localStorage.getItem('treg-agent'); }catch(e){}
if(r && this.welcomeAgents.concat(this.welcomeMoreAgents).some(a=>a.id===r)) this.welcome.agent=r; },
openInviteChoice(){ // seed the multi-select: ALL pending invites checked by default
this.inviteErr=''; this.inviteSel={}; this.pendingInvites.forEach(i=>{ this.inviteSel[i.id]=true; });
this.inviteChoice=true; },
declineInvite(){ this.inviteChoice=false; this.inviteLinkOrg=null;
if(this.inviteFirstRun){ this.welcome.name=this._suggestTeamName(); this._welcomeAgentFromRef(); this.welcome.on=true; } },
// first-run: "create my own team instead"; otherwise just close
async acceptSelectedInvites(){ // accept every checked invite, then drop into the linked (or first) team
const picked=this.selectedInvites; if(!picked.length) return;
this.inviteBusy=true; this.inviteErr='';
const joined=[], failed=[];
for(const inv of picked){
try{ const r=await this.api('/invites/'+inv.id+'/accept',{method:'POST'});
joined.push({inv, r}); this.pendingInvites=this.pendingInvites.filter(i=>i.id!==inv.id); delete this.inviteSel[inv.id]; }
catch(e){ failed.push(inv.name); }
}
try{
if(!joined.length){ // everything failed → keep the modal open with the error (or fall back on first run)
this.inviteErr='Could not join '+failed.join(', ')+' — the invite may have been revoked.';
if(this.inviteFirstRun && !this.pendingInvites.length){ this.inviteChoice=false; this.welcome.name=this._suggestTeamName(); this.welcome.on=true; }
return; }
this.onboarded=true; try{ await this.api('/onboard/skip',{method:'POST'}); }catch(e){}
await this.loadAll();
// Enter the team the email link pointed at when it was accepted; otherwise the first accepted.
const linked=joined.find(j=>j.inv.org_id===this.inviteLinkOrg)||joined[0];
if(linked.r&&linked.r.org) this.switchOrg({slug:linked.r.org});
this.inviteChoice=false; this.inviteLinkOrg=null;
if(this.detail) this.openDetail(this.detail.kind, this.detail.name, true); else this.go('tools'); // accepting while on a shared page = stay on it
this.orgMsg='You joined '+joined.map(j=>(j.r&&j.r.name)||j.inv.name).join(', ')
+(failed.length?' (could not join '+failed.join(', ')+')':'')
+'. Here are the shared tools & skills — call any with no key on your machine.';
}finally{ this.inviteBusy=false; }
},
_suggestTeamName(){ // a friendly default from the email domain: sam@acme.dev → "Acme"
const dom=((this.me||'').split('@')[1]||'').split('.')[0]||'';
const generic=['gmail','outlook','hotmail','yahoo','icloud','proton','protonmail','me','qq','163'];
return (dom && !generic.includes(dom.toLowerCase())) ? dom.charAt(0).toUpperCase()+dom.slice(1) : ''; },
async welcomeCreate(){ const name=(this.welcome.name||'').trim(); if(!name){ this.welcome.err='Give your team a name.'; return; }
this.welcome.busy=true; this.welcome.err='';
try{ const o=await this.api('/orgs',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name})});
this.onboarded=true; try{ await this.api('/onboard/skip',{method:'POST'}); }catch(e){} // don't re-prompt
await this.loadAll(); this.switchOrg({slug:o.org});
this.analyticsIdentify(); this.intercomUpdate(); this.track('onboarding_team_created',{team:o.org});
this.welcome.step=1; } // stay in the modal: pick your agent → get the setup line
catch(e){ this.welcome.err='Could not create the team: '+(e.detail||e.status); }
finally{ this.welcome.busy=false; } },
welcomeFinish(){ this.track('onboarding_finished',{agent:this.welcome.agent, step:this.welcome.step}); this.welcome.on=false; this.go('start');
this.orgMsg='Team created. Send your agent the setup line any time — it lives on Getting started.'; },
agentIcon(icon){ if(icon.startsWith('/')) return icon; // bundled under /logos — same mark in both themes
return 'https://unpkg.com/@lobehub/icons-static-png@latest/'+(this.theme==='dark'?'dark':'light')+'/'+icon+'.png'; },
// For logos that sit ON a .btn.primary: its background is the theme's INVERSE, so the icon
// variant has to flip too or a dark glyph lands on a dark button.
agentIconInv(icon){ if(icon.startsWith('/')) return icon;
return 'https://unpkg.com/@lobehub/icons-static-png@latest/'+(this.theme==='dark'?'light':'dark')+'/'+icon+'.png'; },
welcomeTryProvider(service,group){this.track('tryit_oauth_clicked',{service,group,from:'onboarding'});this.welcome.on=false;this.openProvider(service);},
async copyStart(text, tag){
this.startCopyError=''; this.startCopied='';
try {
await navigator.clipboard.writeText(text);
this.startCopied=tag;
setTimeout(()=>{ if(this.startCopied===tag) this.startCopied=''; },1400);
} catch(_) {
this.startCopyError='Could not copy. Select the text and copy it manually. For an API key, choose Show key first.';
}
}
}
+14
View File
@@ -0,0 +1,14 @@
export default {
// first-run welcome: the agent picker (step 1) and the per-agent setup line (step 2)
welcomeAgents(){ return TregAgentSetup.agents; },
welcomeMoreAgents(){ return TregAgentSetup.moreAgents; },
welcomeAgent(){ return this.welcomeAgents.concat(this.welcomeMoreAgents).find(a=>a.id===this.welcome.agent) || this.welcomeAgents[0]; },
welcomeIsMore(){ return this.welcomeMoreAgents.some(a=>a.id===this.welcome.agent); },
welcomeSetupCmd(){ return this.buildAgentPrompt('agent', true); },
// onboarding modal: the setup line + team/token as ONE copyable block (token masked until shown)
welcomeSetupFull(){ return this.welcomeSetupCmd+'\n\nwith team '+(this.activeSlugNow||'<team-slug>')+' token: '+(this.myToken||'<YOUR_TOKEN>'); },
welcomeSetupMasked(){ const t=this.myToken?(this.startTokenShow?this.myToken:(this.myToken.slice(0,14)+'••••••••••••••••')):'<YOUR_TOKEN>';
return this.welcomeSetupCmd+'\n\nwith team '+(this.activeSlugNow||'<team-slug>')+' token: '+t; },
tryExamples(){ return TregAgentSetup.examples; },
tryOauth(){ return TregAgentSetup.oauthGroups; }
}
+29
View File
@@ -0,0 +1,29 @@
export default {
async createProject(){ const name=(this.projectName||'').trim();
if(!name){ this.orgErr='Give the project a name.'; return; }
this.projBusy=true; this.orgErr='';
try{ await this.api('/orgs/'+this.activeOrgId+'/projects',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name})});
this.projectName=''; }
catch(e){ this.orgErr='Add project failed: '+(e.detail||e.status); }
this.projBusy=false; await this.loadOrgAdmin(); },
async deleteProject(p){ if(this.confirmProj!==p.id){ this.confirmProj=p.id; return; }
try{ await this.api('/orgs/'+this.activeOrgId+'/projects/'+p.id,{method:'DELETE'}); }
catch(e){ this.orgErr='Delete project failed: '+(e.detail||e.status); }
this.confirmProj=null; this.editProj=null; await this.loadOrgAdmin(); },
projName(pid){ const p=(this.projects||[]).find(x=>x.id===pid); return p?p.name:('project '+pid); },
openProjTools(p){ if(this.editProj===p.id){ this.editProj=null; return; }
const d={}; (this.tools||[]).forEach(t=>{ d[t.id]=(t.project_id===p.id); });
this.projToolDraft=d; this.editProj=p.id; },
async saveProjTools(p){ this.projToolBusy=true; this.orgErr='';
try{ for(const t of (this.tools||[])){
const inP=(t.project_id===p.id), want=!!this.projToolDraft[t.id];
if(inP===want) continue;
// unchecking only frees a tool that is IN this project — never touches another project's tools
const body={project: want ? p.slug : null};
await this.api('/tools/'+t.id,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
t.project_id = want ? p.id : null; }
this.editProj=null; }
catch(e){ this.orgErr='Save project tools failed: '+(e.detail||e.status); }
this.projToolBusy=false; await this.loadOrgAdmin(); }
}
+36
View File
@@ -0,0 +1,36 @@
export default {
// ---- referrals ----
// GET /referrals also runs the payout sweep server-side, so simply opening this page is what
// makes a reward whose hold has elapsed actually land. That is deliberate: treg has no
// scheduler, so the work rides on a request someone is already making.
async loadReferrals(){ if(!this.authed) return;
this.ref={...this.ref, loading:true};
// One call: GET mints the code if this is the first visit (asking for the page IS the lazy
// trigger), so there is no POST-then-GET round trip and no window where `link` is empty.
const out=await this.api('/referrals').catch(()=>null);
if(out) this.ref={...out, loading:false}; else this.ref={...this.ref, loading:false}; },
// How much extra THIS preset earns a referred team, or 0. Guarded on the offer existing, so a
// team that arrived on its own sees the buttons exactly as before.
refPresetBonus(usd){ const o=this.billing&&this.billing.referral_offer;
// NULL-GUARD FIRST. `referral_offer` is null for every team that was not referred — i.e. most
// of them — and reading through it throws inside a render, which in Vue blanks the ENTIRE
// dashboard rather than just this row. Cost one blank page during review; keep it on its own
// line rather than folded into the expression below, where deleting a clause drops it silently.
if(!o) return 0;
// Against what is REMAINING, not the full minimum: a team that already added $5 unlocks the
// bonus with another $5, and marking that button "one-time" would be simply wrong.
return (usd*1000000 >= o.remaining_micro) ? o.referred_micro : 0; },
async copyRefLink(){ try{ await navigator.clipboard.writeText(this.ref.link); }catch(e){}
this.refCopied=true; this.track('referral_link_copied');
setTimeout(()=>{ this.refCopied=false; }, 1600); },
// The row's own status, in the words a referrer thinks in. `capped`/`rejected` say WHY rather
// than showing a blank — "I referred someone and got nothing" is the support ticket this
// program generates, and the answer belongs on the page, not in an email to us.
refStatus(r){
if(r.status==='paid') return 'Paid';
if(r.status==='qualified') return 'Added funds — pays '+this.bhistDate(r.pays_at);
if(r.status==='pending') return 'Signed up';
if(r.status==='capped') return 'Over your limit';
return 'Not eligible'; }
}
+55
View File
@@ -0,0 +1,55 @@
export default {
// ---- Phase 2b: resource registration (secrets + tools) ----
async loadSecrets(){ try{ this.secrets=await this.api('/secrets'); }catch(e){ this.secrets=[]; } },
// Paste a whole .env into the name/value field (Render/Vercel-style): it splits into rows
// client-side — comments/blank lines skipped, `export ` stripped, one balanced quote pair removed.
parseEnvText(t){ const out=[];
for(let line of t.split(/\r?\n/)){ line=line.trim();
if(!line||line.startsWith('#')||!line.includes('=')) continue;
let k=line.slice(0,line.indexOf('=')).trim().replace(/^export\s+/i,''), v=line.slice(line.indexOf('=')+1).trim();
if(v.length>=2&&v[0]===v[v.length-1]&&(v[0]==='"'||v[0]==="'")) v=v.slice(1,-1);
if(k) out.push({name:k,value:v,kind:'env'}); }
return out; },
// The nudge under a secret row: an exact provider name gets a confirmation, a near miss
// (APOLLO_API_KEY, tikhub-key, DATAFORSEO_TOKEN) gets the exact name the ladder matches on.
// Anything else — a genuinely custom secret — gets silence, not a warning: most secrets are
// for the org's own tools and have every right to any name.
secretNameHint(row){
const raw=(row.name||'').trim(); if(!raw) return null;
const provs=this.keyNameSuggestions;
const exact=provs.find(p=>p.service===raw); // the ladder compares exactly — "Apollo" is a near miss, not a hit
if(exact) return {ok:true, service:exact.service,
text:(exact.display_name||exact.service)+' catalog calls will use this key automatically'};
const norm=raw.toLowerCase().replace(/[^a-z0-9]+/g,'');
const near=provs.find(p=>{ const s=p.service.replace(/[^a-z0-9]+/g,'');
return norm===s+'apikey'||norm===s+'key'||norm===s+'token'||norm===s+'api'||norm===s; });
if(near) return {ok:false, service:near.service,
text:(near.display_name||near.service)+' catalog calls only find a key named exactly “'+near.service+'” —'};
return null;
},
pasteEnv(e,i,field){ const t=(e.clipboardData||window.clipboardData).getData('text')||'';
const rows=this.parseEnvText(t);
if(!rows.length) return; // not env-shaped → normal paste
// Single-line pastes: only a NAME=value into the *name* field splits; into the value field it's
// a secret that happens to contain '=' (base64 pad, connection strings) → normal paste.
if(!t.includes('\n')&&(field!=='name'||t.indexOf('=')<1)) return;
e.preventDefault();
const seen=new Set(this.secretRows.filter((r,j)=>j!==i&&r.name).map(r=>r.name));
const fresh=rows.filter(r=>!seen.has(r.name));
this.secretRows.splice(i,1,...(fresh.length?fresh:[{name:'',value:'',kind:'env'}])); },
async addSecrets(){ const rows=this.secretRows.filter(r=>(r.name||'').trim()&&r.value);
if(!rows.length){ this.secretErr='A secret needs both a name and a value.'; return; }
this.secretBusy=true; this.secretErr='';
const errs=[];
for(const r of rows){ const n=r.name.trim();
if(this.secrets.some(s=>s.name===n)){ errs.push(n+': already exists'); continue; } // dup-name guard
try{ await this.api('/secrets',{method:'POST',encode:true,headers:{'content-type':'application/json'},body:JSON.stringify({name:n,value:r.value,kind:r.kind})}); }
catch(e){ errs.push(n+': '+(e.detail||e.status)); } }
this.secretRows=[{name:'',value:'',kind:'env'}]; await this.loadSecrets();
if(errs.length) this.secretErr='Some secrets failed — '+errs.join(' · ');
this.secretBusy=false; },
async deleteSecret(s){ if(this.confirmDelSecret!==s.id){ this.confirmDelSecret=s.id; return; } this.confirmDelSecret=null; this.secretErr='';
try{ await this.api('/secrets/'+s.id,{method:'DELETE'}); await this.loadSecrets(); }
catch(e){ this.secretErr='Delete secret failed: '+(e.detail||e.status); } }
}
+82
View File
@@ -0,0 +1,82 @@
import { requestJson } from '../api'
import { LS } from './constants.js'
export default {
focusOverlay(open){ if(!open) return; this.$nextTick(()=>{ const el=document.querySelector('.scrim input,.scrim textarea,.scrim button,.drawer input,.drawer button'); el&&el.focus(); }); },
servedOn(tier){ return ({anonymous:'public provider route (no key)',platform:'treg key',credential:'your key',tool:'your registered tool','platform-overflow':'treg overflow'})[tier]||tier; },
headers(tok){
const h={'ngrok-skip-browser-warning':'1'};
if(tok){ h['X-Treg-Token']=tok; } // explicit (token login validation)
else if(this.sessionMode){ const a=this.activeOrg; if(a) h['X-Treg-Org']=a.slug; } // cookie carries identity
else if(this.token){ h['X-Treg-Token']=this.token; } // token mode
return h; },
async api(path, opts={}){
return requestJson(path, opts, this.headers(), () => {
this.sessionMode=false; location.reload();
}, () => this.sessionMode);
},
save(){ localStorage.setItem(LS, JSON.stringify(this.cfg)); },
connected(slug){ return this.sessionMode || !!this.cfg.orgs[slug]; },
toggleTheme(){ this.theme=this.theme==='dark'?'light':'dark'; document.documentElement.dataset.theme=this.theme; localStorage.setItem('treg-theme',this.theme); },
_stashNext(){ // OAuth callbacks land on /app, losing a /app/skills/<x> deep link — stash it to restore after boot
const d=this.routeFromPath(location.pathname)||this.mkFromPath(location.pathname);
if(d) localStorage.setItem('treg-next', location.pathname); },
githubLogin(){ this._stashNext(); location.href='/auth/github'; },
googleLogin(){ this._stashNext(); location.href='/auth/google'; },
openSignin(){ this.demo.signin=true; },
async emailStart(){ const e=(this.emailInput||'').trim(); if(!e){ this.loginErr='Enter your email address.'; return; } this.busy=true; this.loginErr='';
try{ const r=await this.api('/auth/email/start',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({email:e})});
this.emailStage=true; this.devCode=r.dev_code||''; this.codeInput=''; }
catch(err){ this.loginErr='Could not send a code - try again.'; } finally{ this.busy=false; } },
async emailVerify(){ const code=(this.codeInput||'').trim(); if(!code) return; this.busy=true; this.loginErr='';
try{ await this.api('/auth/email/verify',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({email:this.emailInput.trim(),code})});
location.reload(); } // verify sets the session cookie → reload lands in session mode (like GitHub)
catch(err){ this.loginErr = err.status===401?'Wrong or expired code.':'Sign-in failed.'; } finally{ this.busy=false; } },
async acceptInvite(inv){ try{ await this.api('/invites/'+inv.id+'/accept',{method:'POST'});
this.pendingInvites=this.pendingInvites.filter(i=>i.id!==inv.id); await this.loadAll(); }
catch(e){ this.err='Accept failed: '+(e.detail||e.status); } },
logout(){ window.TregTracking?.identify('',''); try{ if(window.Intercom) window.Intercom('shutdown'); }catch(e){} // drop the Intercom cookie so the next user on this machine can't read these conversations
if(this.sessionMode){ fetch('/auth/logout',{method:'POST',credentials:'include'}).finally(()=>{localStorage.removeItem('treg-active');location.reload();}); } else { this.cfg={active:null,orgs:{}}; this.save(); location.reload(); } },
async addToken(tok, isAdd){ tok=(tok||'').trim(); if(!tok) return; this.busy=true; this.loginErr='';
try{ const orgs=await this.api('/orgs',{headers:this.headers(tok)}); const active=orgs.find(o=>o.active)||orgs[0];
if(!active){ this.loginErr='Token has no org.'; return; }
this.cfg.orgs[active.slug]={token:tok, role:active.role, name:active.name, org_id:active.org_id};
this.cfg.active=active.slug; this.save(); this.tokenInput=''; this.addOrg=false; await this.loadAll();
}catch(e){ this.loginErr = e.status===401?'Invalid token.':('Error: '+(e.detail||e.status)); }
finally{ this.busy=false; } },
switchOrg(o){ this.orgMenu=false; this.newAgent=null; this.snipAgent=null; this.newApiKey=null; this.keyMsg=null;
if(this.sessionMode){ this.activeSlug=o.slug; localStorage.setItem('treg-active',o.slug); this.loadAll(); this.intercomUpdate(); return; }
if(!this.connected(o.slug)){ this.addOrg=true; return; }
this.cfg.active=o.slug; this.save(); this.loadAll(); this.intercomUpdate(); },
async loadAll(){ this.err=''; this.loading=true;
try{
this.myOrgs=await this.api('/orgs');
if(!this.sessionMode && !this.me){ const who=await this.api('/auth/me').catch(()=>null); if(who){ this.me=who.email; this.isAdmin=!!who.is_superadmin; } } // token mode: learn our own email + superadmin flag (isPersonal / join-by-code)
// Re-mint the bearer whenever the ACTIVE org changes: the token now bakes the org slug in
// (so it works as a bare MCP Authorization bearer), and a stale one would name the old team.
// Signed derivation — cheap; the selected Default row contributes its team-local generation.
if(this.myToken===null || this._myTokenOrg!==this.activeSlugNow) await this.loadDefaultToken();
if(this.sessionMode){ this.pendingInvites=await this.api('/invites/mine').catch(()=>[]); } // BEFORE the no-orgs early return: an invited user has 0 orgs but DOES have a pending invite — maybeOnboard needs it to offer joining instead of forcing create-team
if(!this.myOrgs.length){ this.tools=[]; this.bundles=[]; this.health={}; return; } // brand-new user: no team yet → the mandatory welcome (maybeOnboard) creates the first one; skip org-scoped fetches (they'd 400). finally{} clears loading.
if(this.sessionMode && (!this.activeSlug || !this.myOrgs.some(o=>o.slug===this.activeSlug)) && this.myOrgs.length){
// Land on the org that actually has tools (most first). Tie / all-empty -> prefer a TEAM over
// the personal org (first-run confusion killer). Fixes: imports living in the personal space
// while the default opened an empty team.
const byTools=[...this.myOrgs].sort((a,b)=> (b.tool_count||0)-(a.tool_count||0)
|| ((this.isPersonal(a)?1:0)-(this.isPersonal(b)?1:0)) );
this.activeSlug=byTools[0].slug; localStorage.setItem('treg-active',this.activeSlug);
}
this.loadConnections(); // fire-and-forget: connections must never block the tools view
const [tools, health, bundles]=await Promise.all([this.api('/tools'), this.api('/health').catch(()=>[]), this.api('/bundles').catch(()=>[])]);
this.tools=tools; this.bundles=bundles||[]; this.health={}; (health||[]).forEach(h=>this.health[h.secret_id]=h.status);
// isAdmin (super-admin) comes from /auth/me at boot - NOT a /admin/stats probe, which 403s on
// every load/switch for the 99% of users who aren't super-admins (console-error noise + wasted request).
this.loadBilling(); // fire-and-forget, self-guards on canAdmin — feeds the sidebar balance card on every load/switch
if(this.view==='orgs'){ this.loadOrgAdmin(); this.loadMyUsage(); } // refresh team panel after a switch
// Activity is one view with two tabs now, so a team switch has to refresh whichever is open
// — both are org-scoped and would otherwise keep showing the previous team's numbers.
if(this.view==='activity'){ this.loadCalls(); if(this.actTab==='usage') this.loadUsage(); }
if(this.view==='secrets') this.loadSecrets(); // …and the Secrets view (was showing the previous org's)
}catch(e){ this.err='Failed to load: '+(e.detail||e.status); }
finally{ this.loading=false; } }
}
+59
View File
@@ -0,0 +1,59 @@
export default {
token(){ return this.cfg.active ? (this.cfg.orgs[this.cfg.active]||{}).token : null; },
authed(){ return this.sessionMode || !!this.token; },
activeSlugNow(){ return this.sessionMode ? this.activeSlug : this.cfg.active; },
activeOrg(){
if(this.sessionMode){ return this.myOrgs.find(o=>o.slug===this.activeSlug) || this.myOrgs[0] || null; }
const a=this.cfg.orgs[this.cfg.active]; return a ? {slug:this.cfg.active, name:a.name, role:a.role, org_id:a.org_id} : null;
},
activeName(){ const a=this.activeOrg; return a ? (a.name||a.slug) : ''; },
renameDirty(){ const a=this.activeOrg||{}, n=this.renameName.trim(), s=this.renameSlug.trim();
return !!((n && n!==a.name) || (s && s!==a.slug)); },
activeRole(){ const a=this.activeOrg; return a ? a.role : 'member'; },
initials(){ return (this.me||'?').slice(0,2).toUpperCase(); },
filteredTools(){ const q=this.q.toLowerCase(); return this.tools.filter(t=>!q||t.name.toLowerCase().includes(q)||t.base_url.toLowerCase().includes(q)); },
// Home is segregated into three groups. Endpoints = tools you added directly (no bundle); Skills =
// tools that came from a skill package (a bundle, so they carry a recipe); Recipes = recipe-only
// bundles (a knowledge skill with no callable tool).
endpoints(){ const q=this.q.toLowerCase(); return this.tools.filter(t=>!t.bundle_id && (!q||t.name.toLowerCase().includes(q)||(t.base_url||'').toLowerCase().includes(q))); },
skillTools(){ const q=this.q.toLowerCase(); return this.tools.filter(t=>t.bundle_id && (!q||t.name.toLowerCase().includes(q)||(t.base_url||'').toLowerCase().includes(q))); },
recipes(){ const q=this.q.toLowerCase(); const withTool=new Set(this.tools.filter(t=>t.bundle_id!=null).map(t=>t.bundle_id));
return this.bundles.filter(b=>!withTool.has(b.id) && (!q||b.name.toLowerCase().includes(q))); },
hasAnyTools(){ return this.endpoints.length||this.skillTools.length||this.recipes.length; },
staleConns(){ return (this.connections||[]).filter(c=>c.needs_reconnect); },
// dying credentials treg can't renew itself
needSecondCred(){ return (this.connections||[]).filter(c=>c.extra_credential_note); },
// connected, but not yet callable (Google Ads' developer token)
toolGroups(){ return [
{key:'endpoints', label:'Endpoints/CLI', hint:'APIs and CLIs you registered directly', rows:this.endpoints},
{key:'skills', label:'Integration Skills', hint:'tools from a skill package (carry a recipe)', rows:this.skillTools},
]; },
cliShowHtml(){
const t = [
'$ treg login',
'✓ Signed in as you@team.dev (org: team)',
'',
'$ treg add stripe --base-url https://api.stripe.com --secret STRIPE_KEY',
"✓ Registered 'stripe' - the key is stored server-side, never on your machine",
'',
'$ treg call https://api.stripe.com/v1/balance',
'{ "object": "balance", "available": [ { "amount": 4210, "currency": "usd" } ] }',
].join('\n');
return this.tutHL(t,'cmd');
},
activeOrgId(){ return this.activeOrg ? this.activeOrg.org_id : null; },
canAdmin(){ return ['admin','owner'].includes(this.activeRole); },
canRegister(){ return this.activeRole!=='viewer'; },
// members+ may create secrets/tools
secretRowsReady(){ return this.secretRows.filter(r=>(r.name||'').trim()&&r.value).length; },
// The names the marketplace credential ladder actually matches: tier 2 finds an org secret
// NAMED exactly for the provider (Secret.name == service), so these are the names worth
// suggesting — a key called APOLLO_API_KEY works as a plain secret but the catalog never sees it.
keyNameSuggestions(){ return (this.providers||[])
.filter(p=>p.auth_kind==='key'||p.auth_kind==='token')
.slice().sort((a,b)=>a.service.localeCompare(b.service)); },
// ---- detail pages ----
accessNames(){ // the grantable universe: tool names + recipe-only skill names (an integration
// skill is reachable through its tool's name; recipe-only bundles need their own entry)
return [...new Set([...this.tools.map(t=>t.name), ...this.recipes.map(r=>r.name)])].sort(); }
}
+30
View File
@@ -0,0 +1,30 @@
export default {
copyMd(url){ fetch(url).then(r=>r.text()).then(t=>navigator.clipboard.writeText(t)).catch(()=>{}); },
// copy raw markdown
openMd(url){ window.open(url,'_blank'); },
// open the .md in a new tab (method, not a template global)
copyLlms(){ navigator.clipboard.writeText(this.proxy+'/llms.txt').then(()=>{ this.llmsCopied=true; setTimeout(()=>{ this.llmsCopied=false; },1500); }).catch(()=>{}); },
async copyToken(){ if(!this.myToken) return; if(!(await this.toClipboard(this.myToken))) return; this.tokenCopied=true; setTimeout(()=>{ this.tokenCopied=false; },1500); },
// ONE instruction, not two. The old pair (admin "Setup" / consumer "Connect") each re-explained
// the catalog, the credential ladder, prices and the balance — knowledge that now lives in the
// skill install.sh installs, and in llms.txt. A prompt can only carry what a FILE cannot: the
// token, the team, permission to run things, and "do it now". `kind` is ignored (call sites keep
// their argument) so there is a single text to keep true.
buildAgentPrompt(kind, inclToken){ // ONE line — llms.txt carries the whole setup flow (auth included)
return TregAgentSetup.command(this.proxy);
},
async copyAgentPrompt(){ if(!(await this.toClipboard(this.agentPromptText))) return; this.agentGuideCopied=true; setTimeout(()=>this.agentGuideCopied=false,1500); },
async copyVendorPrompt(){ if(!(await this.toClipboard(this.vendorPromptText))) return; this.vendorCopied=true; setTimeout(()=>this.vendorCopied=false,1500); },
openToolRequest(){ this.reqDone=false; this.reqErr=''; this.reqForm.capability=(this.q||'').trim(); this.reqAsk=true; },
// pre-fill with the search that came up short
async submitToolRequest(){ const cap=(this.reqForm.capability||'').trim();
if(!cap){ this.reqErr='Say what tool or capability you need.'; return; }
this.reqBusy=true; this.reqErr='';
try{ await this.api('/tool-requests',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({capability:cap, query:(this.q||'').trim(), note:this.reqForm.note, contact:this.reqForm.contact, source:'web'})});
this.reqDone=true; this.reqForm={capability:'', note:'', contact:''}; }
catch(e){ this.reqErr = e.status===429?'Too many requests from here — try again later.':('Could not send: '+(e.detail||e.status)); }
finally{ this.reqBusy=false; } },
async copyAgentGuide(kind){ if(!(await this.toClipboard(this.buildAgentPrompt(kind, true)))) return; this.agentRowCopied=kind; setTimeout(()=>{ if(this.agentRowCopied===kind) this.agentRowCopied=null; },1500); }
}
+79
View File
@@ -0,0 +1,79 @@
export default {
openRecipeCopy(r){ this.copyRecipe=r; this.recipeTab='cURL'; this.copied=false; },
async openRecipeView(r){ this.err='';
try{ const b=await this.api('/bundles/'+r.id); const rec=b.recipe||''; this.viewRecipe={id:r.id, name:r.name, recipe:rec, orig:rec}; this.copied=false; this.recipeSaved=false; }
catch(e){ this.err='Could not load recipe: '+(e.detail||e.status); } },
async saveRecipe(){ if(!this.viewRecipe) return; this.err='';
try{ await this.api('/bundles/'+this.viewRecipe.id,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({recipe:this.viewRecipe.recipe})});
this.viewRecipe.orig=this.viewRecipe.recipe; this.recipeSaved=true; setTimeout(()=>{ this.recipeSaved=false; },1400); await this.loadAll(); }
catch(e){ this.err='Save failed: '+(e.detail||e.status); } },
recipeSnippet(tab){ const r=this.copyRecipe; if(!r) return {text:'',html:''};
const proxy=(this.proxy||location.origin).replace(/\/$/,'');
const tok=this.myToken||'$TREG_TOKEN', tokS=tok; // full token in the preview — matches the copied text
const esc=s=>String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
const P=s=>`<span class="s-proxy">${esc(s)}</span>`, A=s=>`<span class="s-path">${esc(s)}</span>`,
T=s=>`<span class="s-token">${esc(s)}</span>`, C=s=>`<span class="s-cmt">${esc(s)}</span>`, K=s=>`<span class="s-kw">${esc(s)}</span>`;
switch(tab){
case 'cURL': return {
text:`TREG_API_TOKEN=${tok}\ncurl -fsSL "${proxy}/skills/${r.name}/install.sh?token=$TREG_API_TOKEN" | sh`,
html:`TREG_API_TOKEN=${T(tokS)}\n${K('curl')} -fsSL "${P(proxy)}${C('/skills/')}${A(r.name)}${C('/install.sh?token=')}${T('$TREG_API_TOKEN')}" | sh` };
case 'CLI': return { text:`treg skill install ${r.name}`, html:`${P('treg skill install')} ${A(r.name)}` };
case 'Claude Code': return {
text:`# Install once - Claude Code then loads it from .claude/skills/ automatically.\ntreg skill install ${r.name}\n# ...then just ask Claude to use the "${r.name}" skill.`,
html:`${C('# Install once - Claude Code then loads it from .claude/skills/ automatically.')}\n${P('treg skill install')} ${A(r.name)}\n${C('# ...then just ask Claude to use the "'+r.name+'" skill.')}` };
} },
openAddSkill(){ this.skillErr=''; this.skillMode='folder'; this.skillFiles=[]; this.detected=null;
this.skillSel={}; this.skillVals={}; this.skillResults=null; this.newSkill=true; this._seedSkillJson(); },
_seedSkillJson(){ this.skillJson = JSON.stringify({
name:"my-skill", recipe:"# my-skill\nWhat it does.",
secrets:[{local_name:"key", value:"sk-…", kind:"env"}],
tools:[{name:"my-tool", base_url:"https://api.example.com",
bindings:[{secret:"key", injector:"env", location:"header", name:"Authorization", format:"Bearer {secret}"}]}]
}, null, 2); this.newSkill=true; },
async addSkill(){ let payload; try{ payload=JSON.parse(this.skillJson); }catch(e){ this.skillErr='Not valid JSON: '+e.message; return; }
// Structural validation (the tour promises the JSON is checked before it's sent): a name, at
// least one well-formed tool, and every binding.secret must match a declared local_name.
if(!payload || !payload.name){ this.skillErr='A skill needs a "name".'; return; }
if(!Array.isArray(payload.tools) || !payload.tools.length){ this.skillErr='A skill needs at least one tool.'; return; }
const locals=new Set((payload.secrets||[]).map(s=>s.local_name));
for(const t of payload.tools){
if(!t.name || !t.base_url){ this.skillErr='Each tool needs a name and base_url.'; return; }
for(const b of (t.bindings||[])){
if(b.secret!=null && !locals.has(b.secret)){ this.skillErr='Binding references unknown secret "'+b.secret+'" - declare it in secrets[].local_name.'; return; }
}
}
this.skillBusy=true; this.skillErr='';
try{ await this.api('/skills',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)});
this.newSkill=false; await this.loadAll(); }
catch(e){ this.skillErr='Register failed: '+(e.detail||e.status); } finally{ this.skillBusy=false; } },
async onSkillFolder(e){ this.skillErr=''; this.detected=null; this.skillResults=null;
const want=p=>/(^|\/)(SKILL\.md|skill\.md|treg\.json)$/i.test(p) || /\.(sh|js|mjs|ts|py)$/i.test(p)
|| /(^|\/)\.env$/i.test(p) || /(^|\/)\.secrets?\//i.test(p) || /(^|\/)(client_secret|token|developer_token)/i.test(p);
const picked=[...e.target.files].filter(f=>want(f.webkitRelativePath) && f.size<524288).slice(0,600);
if(!picked.length){ this.skillErr='No skill files here - a skill folder needs a SKILL.md.'; return; }
this.skillFiles=await Promise.all(picked.map(async f=>({path:f.webkitRelativePath, content:await f.text()})));
await this.analyzeSkills(); },
async analyzeSkills(){ if(!this.skillFiles.length) return;
const payload=JSON.stringify({files:this.skillFiles});
if(payload.length>8*1048576){ this.skillErr='That folder is large ('+Math.round(payload.length/1048576)+' MB, '+this.skillFiles.length+' files). Pick a single skill folder or a smaller subfolder.'; return; }
this.skillBusy=true; this.skillErr='';
try{ const r=await this.api('/skills/analyze',{method:'POST',headers:{'content-type':'application/json'},body:payload,encode:true});
this.detected=r.skills; this.skillSel={}; this.skillVals={};
for(const s of r.skills){ this.skillSel[s.name]=s.ready && !s.already; }
if(!r.skills.length) this.skillErr='No SKILL.md found in that folder.'; }
catch(e){ this.skillErr='Analyze failed: '+(e.detail||e.message||(e.status?('HTTP '+e.status):'request failed - try a single skill or a smaller subfolder')); } finally{ this.skillBusy=false; } },
skillCliNote(s){ const c=s.cli; if(!c) return '';
if(c.source==='unsupported') return '⌘ '+c.bin+' CLI: not supported for local runs - '+c.reason;
if(c.source==='contract') return '⌘ local runs: members can `treg run '+s.name+'`'+(c.enabled?'':' (enable after import)');
return '⌘ '+c.bin+' CLI: local runs available via `treg run` once an owner enables it'+(c.verified?' (verified)':''); },
skillSummary(s){ return s.kind==='recipe_only' ? 'Recipe-only - installs the SKILL.md as a shareable recipe.'
: 'API tool - registers a tool calling '+(s.base_url||'?')+' + the recipe.'; },
async importSkills(){ const select=Object.keys(this.skillSel).filter(n=>this.skillSel[n]);
if(!select.length){ this.skillErr='Select at least one skill to register.'; return; }
this.skillBusy=true; this.skillErr='';
try{ const r=await this.api('/skills/import',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({files:this.skillFiles, select, env_values:this.skillVals}),encode:true});
this.skillResults=r.results; await this.loadAll();
if(r.results.length && r.results.every(x=>x.ok)){ setTimeout(()=>{ this.newSkill=false; }, 1000); } }
catch(e){ this.skillErr='Import failed: '+(e.detail||e.message||(e.status?('HTTP '+e.status):'request failed - try registering fewer skills at once')); } finally{ this.skillBusy=false; } }
}
+57
View File
@@ -0,0 +1,57 @@
export default {
samplePath(host){ // known-good, runnable GET paths (relative to each tool's base_url) for common hosts
return ({'api.trykitt.ai':'credit','httpbin.org':'headers','postman-echo.com':'get',
'api.intercom.io':'me','api.stripe.com':'balance','api.render.com':'services?limit=1',
'api.vercel.com':'v2/user','app.posthog.com':'api/projects/@current','eu.posthog.com':'api/projects/@current',
'searchconsole.googleapis.com':'webmasters/v3/sites','googleads.googleapis.com':'v25/customers:listAccessibleCustomers',
'tagmanager.googleapis.com':'tagmanager/v2/accounts',
// part=snippet rather than the probe's part=id: same 1 quota unit, but it returns the channel
// title, so the panel shows something a human recognises instead of an opaque UC… id.
'youtube.googleapis.com':'youtube/v3/channels?part=snippet&mine=true',
// identity endpoints: cheap, and they return a handle/name a human recognises
'api.x.com':'2/users/me','api.linkedin.com':'v2/userinfo',
// Both Meta providers share this host, so one entry covers Facebook Pages and Instagram.
// /me is the person behind the token and needs no scope, so it prefills usefully whichever
// asset the connection ended up bound to.
'graph.facebook.com':'me?fields=id,name',
'mybusinessaccountmanagement.googleapis.com':'v1/accounts','slack.com':'auth.test'})[host]||''; },
openCopy(t){ this.copyTool=t; this.snippetTab='cURL'; this.copied=false;
// Prefer a real, runnable sample path so a copied snippet returns a result: a declared example,
// else the health-check path, else a known-good sample for a well-known host, else the placeholder.
const ex=(t.examples||[])[0], hp=t.health_check&&t.health_check.path;
this.exPath=ex?ex.path:(hp||this.samplePath(t.host)||'<PATH>'); this.exMethod=ex?(ex.method||'GET'):'GET'; },
pickEx(ex){ this.exPath=ex.path; this.exMethod=ex.method||'GET'; },
buildSnippet(tab){
const t=this.copyTool; if(!t) return {html:'',text:''};
const proxy=(this.proxy||location.origin).replace(/\/$/,''); const up=t.base_url.replace(/\/$/,'');
const path=this.exPath||'<PATH>'; const m=this.exMethod||'GET';
// Escape before wrapping - the html goes through v-html, so a literal "<PATH>" (or any '<' in a
// path/name) would otherwise be parsed as an (empty) element and vanish from the preview.
const esc=s=>String(s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
const P=s=>`<span class="s-proxy">${esc(s)}</span>`, H=s=>`<span class="s-host">${esc(s)}</span>`,
A=s=>`<span class="s-path">${esc(s)}</span>`, T=s=>`<span class="s-token">${esc(s)}</span>`,
C=s=>`<span class="s-cmt">${esc(s)}</span>`, K=s=>`<span class="s-kw">${esc(s)}</span>`;
const urlT=`${proxy}/call/${up}/${path}`;
const urlH=`${P(proxy)}${C('/call/')}${H(up)}/${A(path)}`;
const hint=(t.examples||[]).length?`e.g. ${t.examples[0].path}`:'the part after the domain';
const tok=this.myToken||'$TREG_TOKEN', org=(this.activeOrg&&this.activeOrg.slug)||'$TREG_ORG'; // real token+org so a copied snippet runs as-is
const tokShort=tok; // show the full real token — the preview must match what Copy puts on the clipboard
switch(tab){
case 'Claude Code': return {
text:`# Call "${t.name}" through treg - your key is injected server-side.\n${m} ${urlT}\nX-Treg-Token: ${tok}\nX-Treg-Org: ${org}\n\n# PATH = the ${t.host} path you'd normally call (${hint}).`,
html:`${C('# Call "'+t.name+'" through treg - your key is injected server-side.')}\n${K(m)} ${urlH}\n${C('X-Treg-Token:')} ${T(tokShort)}\n${C('X-Treg-Org:')} ${T(org)}\n\n${C('# PATH = the '+t.host+" path you'd normally call ("+hint+').')}` };
case 'CLI': return { text:`treg call ${t.name} ${path}${m!=='GET'?' --method '+m:''}`, html:`${P('treg call')} ${H(t.name)} ${A(path)}${m!=='GET'?' '+K('--method '+m):''}` };
case 'Python': return {
text:`import httpx\nr = httpx.request("${m}", "${urlT}", headers={"X-Treg-Token": "${tok}", "X-Treg-Org": "${org}"})\nprint(r.json())`,
html:`import httpx\nr = httpx.request(${A('"'+m+'"')}, "${urlH}", headers={"X-Treg-Token": ${T('"'+tokShort+'"')}, "X-Treg-Org": ${T('"'+org+'"')}})\nprint(r.json())` };
case 'Node': return {
text:`const r = await fetch("${urlT}", {\n method: "${m}",\n headers: { "X-Treg-Token": "${tok}", "X-Treg-Org": "${org}" },\n});\nconsole.log(await r.json());`,
html:`const r = await fetch("${urlH}", {\n method: ${A('"'+m+'"')},\n headers: { "X-Treg-Token": ${T('"'+tokShort+'"')}, "X-Treg-Org": ${T('"'+org+'"')} },\n});\nconsole.log(await r.json());` };
case 'cURL': return {
text:`TREG_API_TOKEN=${tok}\ncurl -X ${m} \\\n -H "X-Treg-Token: $TREG_API_TOKEN" \\\n -H "X-Treg-Org: ${org}" \\\n "${urlT}"`,
html:`TREG_API_TOKEN=${T(tokShort)}\n${K('curl')} -X ${K(m)} \\\n -H "X-Treg-Token: ${T('$TREG_API_TOKEN')}" \\\n -H "X-Treg-Org: ${T(org)}" \\\n "${urlH}"` };
}
},
async copy(text){ if(!(await this.toClipboard(text))) return; this.copied=true; setTimeout(()=>this.copied=false,1500); }
}
+105
View File
@@ -0,0 +1,105 @@
export default {
// ---- Phase 2a: org lifecycle (writes; all endpoints already exist) ----
async joinByCode(){ const code=(this.joinCode||'').trim(); if(!code){ this.joinErr='Enter the invite code.'; return; } this.joinBusy=true; this.joinErr='';
try{ const r=await this.api('/invites/accept',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({code, email:this.me})});
this.showJoin=false; this.joinCode=''; await this.loadAll(); this.switchOrg({slug:r.org}); }
catch(e){ this.joinErr = e.status===404?'Invalid or already-used code.':(e.status===403?'That code is for a different email.':(e.status===409?'You are already a member.':('Join failed: '+(e.detail||e.status)))); }
finally{ this.joinBusy=false; } },
async createOrg(){ const name=(this.newOrgName||'').trim(); if(!name){ this.orgErr='Enter a team name.'; return; } this.orgBusy=true; this.orgErr='';
try{ const o=await this.api('/orgs',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name})});
// Token mode has no session - persist the new org's token (returned once) or switchOrg would
// pop the "paste a token" modal for a token we just discarded.
if(!this.sessionMode && o.token){ this.cfg.orgs[o.org]={token:o.token, role:o.role, name:o.name, org_id:o.org_id}; this.save(); }
this.newOrg=false; this.newOrgName=''; await this.loadAll(); this.switchOrg({slug:o.org}); }
catch(e){ this.orgErr='Could not create: '+(e.detail||e.status); } finally{ this.orgBusy=false; } },
async loadOrgAdmin(){ this.orgMembers=[]; this.orgInvites=[]; this.lastInvite=null;
if(!this.canAdmin && !['keys','danger'].includes(this.orgTab)) this.orgTab='keys';
this.orgErr=''; this.confirmDel=''; this.confirmLeave=false; this.confirmRemove=null;
if(!this.activeOrgId) return; const id=this.activeOrgId;
await this.loadApiKeys();
if(!this.canAdmin) return;
this.agentErr=''; this.confirmAgent=null;
try{ this.orgMembers=await this.api('/orgs/'+id+'/members'); this.orgInvites=await this.api('/orgs/'+id+'/invites');
this.projects=await this.api('/orgs/'+id+'/projects'); this.denyRules=await this.api('/orgs/'+id+'/deny');
this.cliDeny=await this.api('/orgs/'+id+'/policy/cli-deny').catch(()=>[]);
// agents live in the same roster now (an agent IS a membership)
this.agents=await this.api('/orgs/'+id+'/agents').catch(()=>[]);
const sel={}; this.projects.forEach(p=>{ sel[p.id]=true; }); this.agentProjSel=sel;
this.observedAgents=await this.api('/orgs/'+id+'/agents/observed').catch(()=>[]); }
catch(e){ this.orgErr='Load team failed: '+(e.detail||e.status); } },
async loadMyUsage(){ if(!this.activeOrgId){ this.myUsage=null; return; }
this.myUsage=await this.api('/usage/me').catch(()=>null); },
// the caller's own used/cap (any member)
async setCap(m, val){ const cap=parseInt(val,10);
if(isNaN(cap)||cap<-1){ this.orgErr='Daily cap must be -1 (unlimited) or 0 and above.'; await this.loadOrgAdmin(); return; }
if(cap===m.daily_call_cap) return;
try{ await this.api('/orgs/'+this.activeOrgId+'/members/'+m.user_id+'/cap',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({daily_call_cap:cap})}); }
catch(e){ this.orgErr='Set cap failed: '+(e.detail||e.status); }
await this.loadOrgAdmin(); },
// reload → reverts the input to the true cap on failure
async sendInvite(){ const email=(this.inviteEmail||'').trim(); if(!email){ this.orgErr='Enter an email address to invite.'; return; }
// Invites attach to an email - the server only trims/lowercases, so guard the format here or a
// typo like "foo bar" becomes a permanently-unacceptable dead invite cluttering the list.
if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)){ this.orgErr='Enter a valid email address.'; return; }
this.orgBusy=true; this.orgErr='';
const chosen=this.accessNames.filter(n=>this.inviteToolSel[n]);
const tool_access = !this.inviteCustomize ? null : (chosen.length===this.accessNames.length ? null : chosen);
try{ this.lastInvite=await this.api('/orgs/'+this.activeOrgId+'/invites',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({email,role:this.inviteRole,tool_access,local_run_enabled:this.inviteLocalRun})});
this.inviteEmail=''; this.inviteCustomize=false; this.orgInvites=await this.api('/orgs/'+this.activeOrgId+'/invites'); }
catch(e){ this.orgErr='Invite failed: '+(e.detail||e.status); } finally{ this.orgBusy=false; } },
openInviteCustomize(){ this.inviteCustomize=true; const d={}; this.accessNames.forEach(n=>d[n]=true); this.inviteToolSel=d; },
openAccess(m){ if(m.role==='owner') return;
if(this.editAccess===m.user_id){ this.editAccess=null; return; }
this.editAccess=m.user_id; const d={}; this.accessNames.forEach(n=>{ d[n]=(m.tool_access===null || m.tool_access.includes(n)); }); this.accessDraft=d;
const pd={}; this.projects.forEach(p=>{ pd[p.id]=(m.project_access===null || m.project_access.includes(p.id)); }); this.projDraft=pd; },
setAllAccess(v){ const d={}; this.accessNames.forEach(n=>d[n]=v); this.accessDraft=d; },
async saveAccess(m){ const names=this.accessNames.filter(n=>this.accessDraft[n]);
const tool_access = (names.length===this.accessNames.length) ? null : names; // all checked → 'all' (NULL)
const picked=this.projects.filter(p=>this.projDraft[p.id]).map(p=>p.id);
const project_access = (!this.projects.length || picked.length===this.projects.length) ? null : picked; // all checked → 'all'
try{ await this.api('/orgs/'+this.activeOrgId+'/members/'+m.user_id+'/access',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({tool_access, project_access, local_run_enabled:m.local_run_enabled})}); this.editAccess=null;
if(m.is_agent) this.orgMsg=m.name+' access updated. Its key is unchanged, and the new tool and project permissions apply immediately.'; }
catch(e){ this.orgErr='Set access failed: '+(e.detail||e.status); }
await this.loadOrgAdmin(); },
async setLocalRun(m, val){
try{ await this.api('/orgs/'+this.activeOrgId+'/members/'+m.user_id+'/access',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({tool_access:m.tool_access, local_run_enabled:val})}); }
catch(e){ this.orgErr='Set local-run failed: '+(e.detail||e.status); }
await this.loadOrgAdmin(); },
async setRole(m, role){ if(role===m.role) return;
let err='';
try{ await this.api('/orgs/'+this.activeOrgId+'/members/'+m.user_id,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({role})}); }
catch(e){ err='Role change failed: '+(e.detail||e.status); }
await this.loadOrgAdmin(); // ALWAYS reload → reverts the (:value-bound) select to the true role on failure
if(err) this.orgErr=err; // set the message AFTER the reload (loadOrgAdmin resets orgErr)
},
async removeMember(m){ if(this.confirmRemove!==m.user_id){ this.confirmRemove=m.user_id; return; } this.confirmRemove=null;
try{ await this.api('/orgs/'+this.activeOrgId+'/members/'+m.user_id,{method:'DELETE'}); await this.loadOrgAdmin(); }
catch(e){ this.orgErr='Remove failed: '+(e.detail||e.status); } },
async revokeInvite(inv){ try{ await this.api('/orgs/'+this.activeOrgId+'/invites/'+inv.id,{method:'DELETE'});
this.orgInvites=this.orgInvites.filter(i=>i.id!==inv.id); }
catch(e){ this.orgErr='Revoke failed: '+(e.detail||e.status); } },
forgetActiveOrg(){ // drop the now-dead active org in whichever mode we're in, then fall back to another
if(this.sessionMode){ this.activeSlug=null; localStorage.removeItem('treg-active'); }
else { const s=this.cfg.active; if(s) delete this.cfg.orgs[s]; this.cfg.active=Object.keys(this.cfg.orgs)[0]||null; this.save(); } },
async leaveOrg(){ if(!this.confirmLeave){ this.confirmLeave=true; return; } this.confirmLeave=false;
try{ await this.api('/orgs/'+this.activeOrgId+'/leave',{method:'POST'});
this.forgetActiveOrg(); await this.loadAll(); }
catch(e){ this.orgErr='Leave failed: '+(e.detail||e.status); } },
resetRenameForm(){ const a=this.activeOrg||{}; this.renameName=a.name||''; this.renameSlug=a.slug||''; this.renameErr=''; },
async renameOrg(){ this.renameBusy=true; this.renameErr='';
const body={}, a=this.activeOrg||{}, n=this.renameName.trim(), sl=this.renameSlug.trim();
if(n && n!==a.name) body.name=n; if(sl && sl!==a.slug) body.slug=sl;
try{ const r=await this.api('/orgs/'+this.activeOrgId,{method:'PATCH', headers:{'content-type':'application/json'}, body:JSON.stringify(body)});
const old=this.activeSlugNow;
if(this.sessionMode){ this.activeSlug=r.org; localStorage.setItem('treg-active',r.org); }
else if(r.org!==old){ this.cfg.orgs[r.org]=Object.assign({},this.cfg.orgs[old],{name:r.name}); delete this.cfg.orgs[old]; this.cfg.active=r.org; this.save(); }
else { this.cfg.orgs[old].name=r.name; this.save(); }
await this.loadAll(); this.resetRenameForm(); }
catch(e){ this.renameErr=this._errMsg(e, 'could not rename the team'); }
finally{ this.renameBusy=false; } },
async deleteOrg(){ if(this.confirmDel!==this.activeSlugNow) return;
try{ await this.api('/orgs/'+this.activeOrgId+'?confirm='+encodeURIComponent(this.activeSlugNow),{method:'DELETE'});
this.confirmDel=''; this.forgetActiveOrg(); await this.loadAll(); }
catch(e){ this.orgErr='Delete failed: '+(e.detail||e.status); } }
}
+100
View File
@@ -0,0 +1,100 @@
export default {
defaultBinding(){ return {secret_id:(this.secrets[0]?this.secrets[0].id:null), injector:'env', location:'header', name:'Authorization', format:'Bearer {secret}', secret_field:'access_token'}; },
async loadProjectsIfNeeded(){ if(this.projects.length || !this.activeOrgId || !this.canAdmin) return;
this.projects = await this.api('/orgs/'+this.activeOrgId+'/projects').catch(()=>[]); },
openAddTool(mode){ this.addToolMenu=false; this.loadProjectsIfNeeded(); this.loadSecrets().then(()=>{
this.tForm = mode==='cli'
? {id:null, mode:'cli', name:'', base_url:'', bindings:[],
cli:{bin:'', package:'', enabled:true, auth_mechanism:'env', deny:[], deny_defaults:true,
inject:[{via:'env', name:'', secret_id:(this.secrets[0]?this.secrets[0].id:null)}]}}
: {id:null, mode:'endpoint', name:'', base_url:'', bindings:[this.defaultBinding()], project:null};
if(mode==='cli') this.tForm.project=null;
if(mode==='cli') this.loadCatalogClis();
this.toolErr=''; this.newTool=true; }); },
openEditTool(t){ this.loadProjectsIfNeeded(); this.loadSecrets().then(()=>{ const cur=(this.projects||[]).find(p=>p.id===t.project_id);
this.tForm={ id:t.id, mode:(t.cli?'cli':'endpoint'), name:t.name, base_url:t.base_url, project:(cur?cur.slug:null),
bindings:(t.bindings||[]).map(b=>({secret_id:b.secret_id, injector:b.injector||'env', location:b.location||'header', name:b.name||'Authorization', format:b.format||'Bearer {secret}', secret_field:b.secret_field||'access_token'})),
// deep-copy the cli profile — the PATCH replaces it wholesale, so every field must round-trip
cli: t.cli ? Object.assign(JSON.parse(JSON.stringify(t.cli)), {package:t.cli.package||'', deny:(t.cli.deny||[]).slice(), deny_defaults:t.cli.deny_defaults!==false,
inject:(t.cli.inject||[]).map(e=>Object.assign({via:'env',name:'',secret_id:null},e))}) : null };
if(this.tForm.cli){ if(!this.tForm.cli.inject.length) this.tForm.cli.inject=[{via:'env', name:'', secret_id:null}]; this.loadCatalogClis(); }
else if(!this.tForm.bindings.length) this.tForm.bindings=[this.defaultBinding()];
this.toolErr=''; this.newTool=true; }); },
async loadCatalogClis(){ if(this.catalogClis) return; // bin → catalog deny patterns, fetched once
try{ const cat=await (await fetch('/providers.json')).json();
const m={}; (cat.providers||cat||[]).forEach(e=>{ const c=e.cli; if(c&&c.bin) m[c.bin]=(c.deny||[]); }); this.catalogClis=m; }
catch(e){ this.catalogClis={}; } },
addBinding(){ const b=this.defaultBinding();
// avoid cloning a duplicate header name (which would fail on save) - pick a fresh one
const used=new Set(this.tForm.bindings.filter(x=>(x.location||'header')==='header').map(x=>(x.name||'Authorization').toLowerCase()));
if(used.has('authorization')){ b.name='X-Api-Key'; b.format='{secret}'; }
this.tForm.bindings.push(b); },
removeBinding(i){ this.tForm.bindings.splice(i,1); },
async saveTool(){ const f=this.tForm;
if(!/^https?:\/\/.+/i.test(f.base_url.trim())){ this.toolErr=(f.mode==='cli'?'The provider API base URL':'Base URL')+' must start with http:// or https://'; return; } // client-side URL check
let body;
if(f.mode==='cli'){
const bin=(f.cli.bin||'').trim();
if(!f.id && !bin){ this.toolErr='The CLI command (e.g. gh) is required.'; return; }
const inject=f.cli.inject.filter(e=>(e.name||'').trim()||e.secret_id).map(e=>({via:'env', name:(e.name||'').trim(), secret_id:e.secret_id}));
if(inject.some(e=>!e.name||!e.secret_id)){ this.toolErr='Each binding key needs a secret AND the env var to inject it as.'; return; }
const deny=f.cli.deny.map(p=>p.trim()).filter(Boolean);
for(const p of deny){ try{ new RegExp(p); }catch(e){ this.toolErr='Deny pattern is not a valid regex: '+p; return; } }
const cli=Object.assign({},f.cli,{bin, inject, deny, deny_defaults:!!f.cli.deny_defaults, enabled:!!f.cli.enabled});
if(!(cli.package||'').trim()) delete cli.package; else cli.package=cli.package.trim();
// PATCH omits `bindings` on purpose: editing the CLI must not clobber any HTTP bindings the tool also has
body = f.id ? {base_url:f.base_url.trim(), cli, project:f.project||null} : {name:bin.replace(/_/g,'-'), base_url:f.base_url.trim(), bindings:[], cli, project:f.project||null};
} else {
if(!f.id && !f.name.trim()){ this.toolErr='Name and base URL are required.'; return; }
if(!f.bindings.length || f.bindings.some(b=>!b.secret_id)){ this.toolErr='Every binding needs a secret.'; return; }
const hdr=f.bindings.filter(b=>(b.location||'header')==='header').map(b=>(b.name||'Authorization').toLowerCase());
if(new Set(hdr).size!==hdr.length){ this.toolErr='Two bindings target the same header - give each a distinct name.'; return; } // catch the collision inline, clearly
const bindings=f.bindings.map(b=>({secret_id:b.secret_id, injector:b.injector, location:b.location, name:b.name, format:b.format, secret_field:b.secret_field}));
body = f.id ? {base_url:f.base_url.trim(), bindings, project:f.project||null} : {name:f.name.trim(), base_url:f.base_url.trim(), bindings, project:f.project||null};
}
this.toolBusy=true; this.toolErr='';
try{
if(f.id){ await this.api('/tools/'+f.id,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); }
else { await this.api('/tools',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); }
const created = f.id ? null : body.name;
this.newTool=false; await this.loadAll();
if(this.view==='detail') this.loadDetail(); // a detail-page ⚙ Configure save must show the new values
if(created) await this.remindCustomizedAccess(created); }
catch(e){ this.toolErr='Save tool failed: '+(e.detail||e.status); } finally{ this.toolBusy=false; } },
async remindCustomizedAccess(toolName){ // a NEW tool auto-applies to 'all-tools' members but NOT to customized ones
try{ const members=await this.api('/orgs/'+this.activeOrgId+'/members');
const n=(members||[]).filter(m=>m.tool_access!==null && m.role!=='owner').length;
if(n>0) this.accessNote='New tool "'+toolName+'" is available to members who have access to all tools. '+n+' member(s) have a customized selection and won\'t see it until you add it (Team → their Tools).';
}catch(e){/* non-admin creator can't list members → no reminder */} },
localRunTitle(t){ const bin=(t.cli&&t.cli.bin)||t.name; return t.cli&&t.cli.enabled ? ('Local runs ON - members can: treg run '+t.name+'. Click to disable.') : ('Local runs OFF - click to let members run '+bin+' locally with this key injected.'); },
async toggleLocalRun(t){ if(!t.cli) return; const turningOn=!t.cli.enabled; const cli=Object.assign({},t.cli,{enabled:turningOn});
try{ await this.api('/tools/'+t.id,{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({cli})}); await this.loadAll();
this.runNote = turningOn ? t.name : ''; }
catch(e){ this.err='Could not change local-run: '+(e.detail||e.status); } },
canCall(t){ return !t.cli || (t.bindings&&t.bindings.length); },
// an HTTP verb needs a binding (or it's a plain HTTP tool)
canRun(t){ return !!t.server_runnable; },
// server-computed: cli profile + allow-listed bin
credChips(t){ // one chip per credential KIND (env/oauth/…), covering HTTP bindings + cli.inject alike
const kinds=new Map();
(t.bindings||[]).forEach(b=>{ const k=b.injector||'env'; kinds.set(k,(kinds.get(k)||0)+1); });
((t.cli&&t.cli.inject)||[]).forEach(()=>{ kinds.set('env',(kinds.get('env')||0)+1); });
return [...kinds.keys()].map(k=>({label:k, kind:k, title:'credential injected by the registry ('+kinds.get(k)+' wire'+(kinds.get(k)>1?'s':'')+': proxy call and/or CLI run) - the value never appears here'}));
},
openUse(t){ this.tryTool=t; this.useMode=this.canRun(t)?'run':(this.canCall(t)?'call':'run'); this.runOut=null; // last resort: run mode, whose server 422 explains itself (never an unauthenticated HTTP call)
const ex=(t.examples||[])[0], hp=t.health_check&&t.health_check.path; // prefill a real, runnable path so Send just works
this.tryPath=ex?ex.path:(hp||this.samplePath(t.host)||''); this.tryMethod=ex?(ex.method||'GET'):'GET'; this.tryResp=null; this.tryBody=''; },
async doRun(){ if(this.running || !this.tryTool) return; this.running=true; this.runOut=null;
// shell-ish tokenizer so quoted arguments ("a b") survive as one argv entry
const args=(this.runArgsStr.match(/"([^"]*)"|'([^']*)'|\S+/g)||[]).map(a=>a.replace(/^["']|["']$/g,''));
try{ this.runOut=await this.api('/run',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({tool:this.tryTool.name,args,timeout_s:60}),encode:true}); }
catch(e){ this.runOut={exit_code:-1, duration_ms:0, detail:(e&&e.detail)||('request failed: '+(e&&e.status||e))}; }
finally{ this.running=false; } },
async deleteTool(t){ if(this.confirmDelTool!==t.id){ this.confirmDelTool=t.id; return; } this.confirmDelTool=null; this.err=''; this.toolErr='';
try{ await this.api('/tools/'+t.id,{method:'DELETE'}); await this.loadAll(); }
catch(e){ this.err='Delete tool failed: '+(e.detail||e.status); } },
async deleteRecipe(r){ if(this.confirmDelBundle!==r.id){ this.confirmDelBundle=r.id; return; } this.confirmDelBundle=null; this.err='';
try{ await this.api('/bundles/'+r.id,{method:'DELETE'}); await this.loadAll(); }
catch(e){ this.err='Delete recipe failed: '+(e.detail||e.status); } }
}
+92
View File
@@ -0,0 +1,92 @@
export default {
// ---- marketplace endpoint Try-it ----
openEpTry(e){
this.epTry=e; this.epTryTab='agent'; this.epTryResp=''; this.epTryStatus=null; this.epTryMs=null; this.epTryCost=null;
this.epTryAccess=null; this.epTryAccessByMethod={}; this.epTryBusy=false;
this.epTryAuthMethod=e.authorization_method||((e.authorization_methods||[])[0])||'';
const secs=this.paramSections(e);
const tr=e.test_request||{}; // the live-verified request — the best possible prefill
const trq={...(tr.queryParams||{}), ...(tr.pathParams||{})};
const qp=[]; // query + path params (path placeholders are consumed from query server-side)
for(const s of secs) if(s.key!=='body') for(const p of s.rows)
qp.push({name:p.name, required:!!p.required, authorization_methods:p.authorization_methods||[],
value: trq[p.name]!=null ? this.fmtExample(trq[p.name])
: (p.example!=null ? this.fmtExample(p.example) : '')});
// test_request may carry params the input spec doesn't list — keep them, they made the call work
for(const [k,v] of Object.entries(trq)) if(!qp.some(p=>p.name===k)) qp.push({name:k, required:false, value:this.fmtExample(v)});
this.epTryParams=qp;
let body='';
if((e.method||'GET')!=='GET'){
if(tr.body!=null){ body=JSON.stringify(tr.body,null,2); } // verbatim — array-vs-object is ground truth here
else{
const bodySec=secs.find(s=>s.key==='body');
if(bodySec){ const o={}; for(const p of bodySec.rows) if(p.example!=null||p.required) o[p.name]=p.example!=null?p.example:null;
body=JSON.stringify(o,null,2); }
}
}
this.epTryBody=body;
// the authenticated dry-run: which ladder rung would serve this org, and at what price
this.loadEpTryAccessPolicy();
},
epTryParamAllowed(p){ return !p.authorization_methods || !p.authorization_methods.length
|| p.authorization_methods.includes(this.epTryAuthMethod); },
async loadEpTryAccessPolicy(){
const e=this.epTry; if(!e) return;
const methods=this.epTryAuthMethods;
if(!methods.length){ this.loadEpTryAccess(); return; }
const rows=await Promise.all(methods.map(async m=>{
try{ return [m, await this.api('/catalog/endpoints/'+e.id+'/access?authorization_method='+encodeURIComponent(m))]; }
catch(_){ return [m, null]; }
}));
if(this.epTry!==e) return;
this.epTryAccessByMethod=Object.fromEntries(rows);
const connected=this.epTryConnectedMethods;
if(connected.length===1) this.epTryAuthMethod=connected[0];
else this.epTryAuthMethod=e.authorization_method||methods[0]||'';
this.epTryAccess=this.epTryAccessByMethod[this.epTryAuthMethod]||null;
},
loadEpTryAccess(){
if(!this.epTry) return;
if(this.epTryAccessByMethod[this.epTryAuthMethod]){
this.epTryAccess=this.epTryAccessByMethod[this.epTryAuthMethod]; return;
}
const q=this.epTryAuthMethod?'?authorization_method='+encodeURIComponent(this.epTryAuthMethod):'';
this.epTryAccess=null;
this.api('/catalog/endpoints/'+this.epTry.id+'/access'+q).then(a=>{
this.epTryAccess=a;
if(this.epTryAuthMethod) this.epTryAccessByMethod={...this.epTryAccessByMethod,[this.epTryAuthMethod]:a};
}).catch(()=>{});
},
async runEpTry(){
const e=this.epTry; if(!e) return; this.epTryBusy=true; const t0=performance.now();
try{
const qs=this.epTryVisibleParams.filter(p=>String(p.value)!=='').map(p=>encodeURIComponent(p.name)+'='+encodeURIComponent(p.value)).join('&');
const opts={method:e.method||'GET', credentials:'include', headers:{...this.headers()}};
if(this.epTryAuthMethod) opts.headers['X-Treg-Authorization-Method']=this.epTryAuthMethod;
if(opts.method!=='GET' && this.epTryBody.trim()){ opts.body=this.epTryBody; opts.headers['content-type']='application/json'; }
const r=await fetch('/call/'+e.id+(qs?'?'+qs:''), opts);
this.epTryStatus=r.status; this.epTryMs=Math.round(performance.now()-t0);
const txt=await r.text();
if(!txt){ this.epTryResp='(empty response body - status '+r.status+')'; }
else{ try{ this.epTryResp=JSON.stringify(JSON.parse(txt),null,2).slice(0,20000); }catch(_){ this.epTryResp=txt.slice(0,8000); } }
// The charge, read back from the same telemetry Activity renders. The audit writer is
// fire-and-forget, so give it a beat; also refresh the balance pill.
setTimeout(async()=>{ try{
const rows=await this.api('/calls?limit=5');
const row=(rows||[]).find(c=>c.endpoint_id===e.id);
if(row && row.cost_charged_micro!=null) this.epTryCost=row.cost_charged_micro;
this.loadBilling();
}catch(_){}} , 900);
}catch(err){ this.epTryResp='Request failed: '+err; this.epTryStatus=0; this.epTryMs=Math.round(performance.now()-t0); }
finally{ this.epTryBusy=false; }
},
async runTry(){ this.trying=true; const t0=performance.now();
try{ const opts={method:this.tryMethod, credentials:'include', headers:{...this.headers()}};
if(this.tryMethod!=='GET' && (this.tryBody||'').trim()){ opts.body=this.tryBody; opts.headers['content-type']='application/json'; } // send the body for POST/PUT/DELETE
const r=await fetch(`/call/${this.tryTool.name}/${this.tryPath}`,opts);
this.tryStatus=r.status; this.tryMs=Math.round(performance.now()-t0); const txt=await r.text();
if(!txt){ this.tryResp='(empty response body - status '+r.status+')'; } // e.g. a 404 with no body left the box blank
else { try{ this.tryResp=JSON.stringify(JSON.parse(txt),null,2); }catch(_){ this.tryResp=txt.slice(0,4000); } }
}catch(e){ this.tryResp='Request failed: '+e; this.tryStatus=0; this.tryMs=Math.round(performance.now()-t0); } finally{ this.trying=false; } }
}
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
import { afterEach, expect, test, vi } from 'vitest'
import { ApiError, requestJson } from '../src/api'
afterEach(() => vi.unstubAllGlobals())
test('cookie and team headers accompany a JSON request', async () => {
const fetcher = vi.fn().mockResolvedValue(Response.json({ ok: true }))
vi.stubGlobal('fetch', fetcher)
expect(await requestJson('/tools', {}, { 'X-Treg-Org': 'team' }, vi.fn(), () => true)).toEqual({ ok: true })
expect(fetcher).toHaveBeenCalledWith('/tools', expect.objectContaining({
credentials: 'include', headers: { 'X-Treg-Org': 'team' },
}))
})
test('a WAF HTML rejection retries a Unicode body once with base64', async () => {
const fetcher = vi.fn()
.mockResolvedValueOnce(new Response('<html>blocked</html>', { status: 403, headers: { 'content-type': 'text/html' } }))
.mockResolvedValueOnce(Response.json({ ok: true }))
vi.stubGlobal('fetch', fetcher)
const body = JSON.stringify({ recipe: '你好 🌍' })
await requestJson('/skills', { method: 'POST', body }, { 'X-Treg-Token': 'test' }, vi.fn(), () => false)
expect(fetcher).toHaveBeenCalledTimes(2)
const retry = fetcher.mock.calls[1]![1]
expect(Buffer.from(retry.body, 'base64').toString()).toBe(body)
expect(retry.headers).toMatchObject({ 'X-Treg-Token': 'test', 'X-Treg-Body-Encoding': 'base64' })
})
test('an upstream JSON refusal is never retried', async () => {
const fetcher = vi.fn().mockResolvedValue(Response.json({ detail: 'forbidden' }, { status: 403 }))
vi.stubGlobal('fetch', fetcher)
await expect(requestJson('/skills', { method: 'POST', body: '{}' }, {}, vi.fn(), () => false))
.rejects.toMatchObject({ status: 403, detail: 'forbidden' })
expect(fetcher).toHaveBeenCalledTimes(1)
})
test('login 401 is an ordinary error, not a reload loop', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ detail: 'expired' }, { status: 401 })))
const expired = vi.fn()
await expect(requestJson('/auth/email/verify', {}, {}, expired, () => true)).rejects.toBeInstanceOf(ApiError)
expect(expired).not.toHaveBeenCalled()
})
test('session expiry reloads once without returning a successful result', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 401 })))
const expired = vi.fn(), settled = vi.fn()
void requestJson('/tools', {}, {}, expired, () => true).then(settled, settled)
await vi.waitFor(() => expect(expired).toHaveBeenCalledTimes(1))
expect(settled).not.toHaveBeenCalled()
})
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"allowJs": true,
"checkJs": false,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
export default defineConfig({
plugins: [vue({ template: { transformAssetUrls: { includeAbsolute: false } } })],
base: '/app/ui/',
// Shared onboarding components also serve unbundled public pages and still use templates.
resolve: { alias: { vue: 'vue/dist/vue.esm-bundler.js' } },
build: {
outDir: fileURLToPath(new URL('../src/treg/web/dashboard', import.meta.url)),
emptyOutDir: true,
rolldownOptions: { output: { codeSplitting: { groups: [{ name: 'vue', test: /node_modules\/(?:@vue|vue)\// }] } } },
},
server: {
host: '127.0.0.1',
port: 5173,
strictPort: true,
proxy: {
'^/(?!app/ui/)': { target: 'http://127.0.0.1:18790', changeOrigin: false },
},
},
})
+2
View File
@@ -0,0 +1,2 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({ test: { include: ['tests/**/*.test.ts'] } })

Some files were not shown because too many files have changed in this diff Show More