mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
refactor(dashboard): extract Vue app and integrate production builds
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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>
|
||||
Generated
+1497
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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||'<cli>'}} -- <args></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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}} -- <args></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>
|
||||
@@ -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 & 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 & 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 "backlinks for a domain"\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 & 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 & 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 & 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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & skills</h1>
|
||||
<div class="tabs" style="margin:8px 0 4px">
|
||||
<button @click="go('tools')">Skills & 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 & 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 & 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>
|
||||
@@ -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 & 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 & 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>
|
||||
@@ -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 & skills</h1>
|
||||
<div v-if="canRegister" class="tabs" style="margin:8px 0 4px">
|
||||
<button class="active">Skills & 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 & 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>
|
||||
@@ -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.'; } }
|
||||
}
|
||||
@@ -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'); }
|
||||
}
|
||||
@@ -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); } }
|
||||
}
|
||||
@@ -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'; }
|
||||
}
|
||||
@@ -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){} }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'; }
|
||||
}
|
||||
@@ -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}`; }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const LS = 'treg-dash'
|
||||
@@ -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>)
|
||||
}
|
||||
@@ -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?.() },
|
||||
}
|
||||
@@ -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},
|
||||
};
|
||||
}
|
||||
@@ -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; } }
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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'; }
|
||||
}
|
||||
@@ -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'); }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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'; }
|
||||
}
|
||||
@@ -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); } }
|
||||
}
|
||||
@@ -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; } }
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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=>({'&':'&','<':'<','>':'>'}[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; } }
|
||||
}
|
||||
@@ -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=>({'&':'&','<':'<','>':'>'}[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); }
|
||||
}
|
||||
@@ -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); } }
|
||||
}
|
||||
@@ -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); } }
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user