@jini-ai/agent-plugins 0.6.16 → 0.6.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -1
- package/NOTICE +14 -0
- package/README.md +35 -14
- package/charts-echarts/plugin.json +39 -0
- package/charts-echarts/skills/charts-echarts/SKILL.md +110 -0
- package/charts-recharts/plugin.json +32 -0
- package/charts-recharts/skills/charts-recharts/SKILL.md +108 -0
- package/cloudflare/NOTES.md +16 -0
- package/cloudflare/form-destinations/cloudflare-d1.mjs +9 -0
- package/cloudflare/lib/api.mjs +96 -0
- package/cloudflare/lib/assets.mjs +65 -0
- package/cloudflare/lib/dns.mjs +32 -0
- package/cloudflare/lib/domain-inventory.mjs +61 -0
- package/cloudflare/lib/domain-status.mjs +48 -0
- package/cloudflare/lib/domains.mjs +95 -0
- package/cloudflare/lib/forms.mjs +254 -0
- package/cloudflare/lib/pages-upload.mjs +51 -0
- package/cloudflare/lib/target.mjs +165 -0
- package/cloudflare/lib/workers-upload.mjs +48 -0
- package/cloudflare/lib/zones.mjs +25 -0
- package/cloudflare/mcp.json +4 -0
- package/cloudflare/plugin.json +96 -0
- package/cloudflare/skills/cloudflare/SKILL.md +57 -0
- package/cloudflare/skills/cloudflare/references/domains.md +31 -0
- package/cloudflare/skills/cloudflare/references/pages.md +18 -0
- package/cloudflare/skills/cloudflare/references/permissions.md +29 -0
- package/cloudflare/skills/cloudflare/references/troubleshooting.md +20 -0
- package/cloudflare/skills/cloudflare/references/workers.md +42 -0
- package/cloudflare/targets/cloudflare-pages.mjs +12 -0
- package/cloudflare/targets/cloudflare-workers.mjs +17 -0
- package/cloudflare/tovu-consent.json +6 -0
- package/cloudflare/tovu-deploy-targets.json +630 -0
- package/cloudflare/tovu-form-destinations.json +12 -0
- package/cloudflare/worker/forms-receiver.mjs +249 -0
- package/composio/mcp.json +10 -0
- package/composio/plugin.json +68 -0
- package/composio/skills/composio/SKILL.md +85 -0
- package/dist/core/identity-namespace.d.ts +12 -0
- package/dist/core/identity-namespace.js +20 -0
- package/dist/folder-zip-node.js +29 -7
- package/dist/lifecycle/bundled-digests.js +4 -3
- package/dist/lifecycle/install.d.ts +7 -0
- package/dist/lifecycle/install.js +45 -3
- package/dist/lifecycle/manifest.d.ts +2 -2
- package/dist/lifecycle/manifest.js +5 -3
- package/dist/lifecycle/node.js +2 -0
- package/dist/lifecycle/persistent-state.js +1 -1
- package/dist/lifecycle/ports.d.ts +8 -0
- package/dist/lifecycle/seed-bundled.js +4 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +3 -2
- package/fly/deploy-configs/fly.mjs +115 -0
- package/fly/deploy-ops/fly.mjs +102 -0
- package/fly/mcp.json +4 -0
- package/fly/plugin.json +81 -0
- package/fly/scripts/render-deploy-workflow.mjs +150 -0
- package/fly/skills/fly/SKILL.md +43 -0
- package/fly/skills/fly/references/fly-deploy.template.yml +117 -0
- package/fly/skills/fly/references/fly.template.toml +81 -0
- package/fly/tovu-credential-schemes.json +11 -0
- package/fly/tovu-deploy-configs.json +21 -0
- package/fly/tovu-deploy-ops.json +14 -0
- package/github/deploy-ops/github-actions.mjs +164 -0
- package/github/deployments/github.mjs +415 -0
- package/github/mcp.json +4 -0
- package/github/plugin.json +105 -0
- package/github/skills/github/SKILL.md +249 -0
- package/github/skills/github/references/actions.md +224 -0
- package/github/skills/github/references/auth-and-tokens.md +140 -0
- package/github/skills/github/references/repo-files.md +135 -0
- package/github/skills/github/references/site-backup.md +159 -0
- package/github/skills/github/references/source-control.md +123 -0
- package/github/source-control/backup-push.mjs +242 -0
- package/github/source-control/commit-site.mjs +459 -0
- package/github/source-control/github.mjs +93 -0
- package/github/source-control/write-files.mjs +468 -0
- package/github/tovu-deploy-ops.json +13 -0
- package/github/tovu-source-control.json +117 -0
- package/github-pages/NOTES.md +19 -0
- package/github-pages/mcp.json +4 -0
- package/github-pages/plugin.json +48 -0
- package/github-pages/skills/github-pages/SKILL.md +44 -0
- package/github-pages/targets/github-pages.mjs +543 -0
- package/github-pages/tovu-deploy-targets.json +327 -0
- package/google-login/exports/google-g.svg +1 -0
- package/google-login/exports/sign-in-button.css +68 -0
- package/google-login/exports/sign-in-button.html +4 -0
- package/google-login/mcp.json +4 -0
- package/google-login/plugin.json +59 -0
- package/google-login/skills/google-login/SKILL.md +96 -0
- package/google-login/tovu-login-providers.json +21 -0
- package/google-workspace/README.md +132 -0
- package/google-workspace/form-destinations/README.md +87 -0
- package/google-workspace/form-destinations/dedupe.mjs +43 -0
- package/google-workspace/form-destinations/delivery-result.mjs +69 -0
- package/google-workspace/form-destinations/mapping.mjs +152 -0
- package/google-workspace/form-destinations/sheets.mjs +202 -0
- package/google-workspace/lib/auth.mjs +81 -0
- package/google-workspace/lib/drive-files.mjs +54 -0
- package/google-workspace/lib/form-destination-settings.mjs +72 -0
- package/google-workspace/lib/http.mjs +168 -0
- package/google-workspace/lib/picker-flow.mjs +171 -0
- package/google-workspace/lib/picker-selection.mjs +107 -0
- package/google-workspace/lib/product-state.mjs +93 -0
- package/google-workspace/lib/request-policy.mjs +163 -0
- package/google-workspace/lib/sheets-a1.mjs +79 -0
- package/google-workspace/lib/sheets-admin-csv.mjs +82 -0
- package/google-workspace/lib/sheets-admin-request-kinds.mjs +183 -0
- package/google-workspace/lib/sheets-client.mjs +286 -0
- package/google-workspace/lib/surface-actions.mjs +107 -0
- package/google-workspace/mcp.json +4 -0
- package/google-workspace/plugin.json +77 -0
- package/google-workspace/products.json +12 -0
- package/google-workspace/skills/google-workspace/SKILL.md +82 -0
- package/google-workspace/skills/google-workspace/references/connection.md +102 -0
- package/google-workspace/skills/google-workspace/references/form-delivery.md +50 -0
- package/google-workspace/skills/google-workspace/references/sheets.md +149 -0
- package/google-workspace/skills/google-workspace/references/troubleshooting.md +31 -0
- package/google-workspace/tools/account-info.mjs +18 -0
- package/google-workspace/tools/sheets/README.md +54 -0
- package/google-workspace/tools/sheets/copy-tab.mjs +23 -0
- package/google-workspace/tools/sheets/copy.mjs +23 -0
- package/google-workspace/tools/sheets/create.mjs +35 -0
- package/google-workspace/tools/sheets/find-replace.mjs +38 -0
- package/google-workspace/tools/sheets/find.mjs +52 -0
- package/google-workspace/tools/sheets/get-by-data-filter.mjs +23 -0
- package/google-workspace/tools/sheets/get.mjs +22 -0
- package/google-workspace/tools/sheets/values-append.mjs +27 -0
- package/google-workspace/tools/sheets/values-batch-clear.mjs +20 -0
- package/google-workspace/tools/sheets/values-batch-get.mjs +20 -0
- package/google-workspace/tools/sheets/values-batch-update.mjs +29 -0
- package/google-workspace/tools/sheets/values-by-data-filter.mjs +62 -0
- package/google-workspace/tools/sheets/values-clear.mjs +20 -0
- package/google-workspace/tools/sheets/values-get.mjs +21 -0
- package/google-workspace/tools/sheets/values-update.mjs +26 -0
- package/google-workspace/tools/sheets-admin/README.md +45 -0
- package/google-workspace/tools/sheets-admin/basic-filter.mjs +32 -0
- package/google-workspace/tools/sheets-admin/batch-update.mjs +25 -0
- package/google-workspace/tools/sheets-admin/developer-metadata.mjs +28 -0
- package/google-workspace/tools/sheets-admin/export-csv.mjs +67 -0
- package/google-workspace/tools/sheets-admin/filter-views.mjs +38 -0
- package/google-workspace/tools/sheets-admin/format.mjs +70 -0
- package/google-workspace/tools/sheets-admin/named-ranges.mjs +42 -0
- package/google-workspace/tools/sheets-admin/open-existing.mjs +34 -0
- package/google-workspace/tools/sheets-admin/protected-ranges-get.mjs +18 -0
- package/google-workspace/tools/sheets-admin/sort.mjs +18 -0
- package/google-workspace/tools/sheets-admin/tab-modify.mjs +36 -0
- package/google-workspace/tovu-admin-surfaces.json +63 -0
- package/google-workspace/tovu-agent-tools.json +1178 -0
- package/google-workspace/tovu-connections.json +35 -0
- package/google-workspace/tovu-form-destinations.json +10 -0
- package/google-workspace/ui/README.md +73 -0
- package/google-workspace/ui/connection-panel.handler.mjs +167 -0
- package/google-workspace/ui/connection-panel.html +17 -0
- package/google-workspace/ui/connection-panel.mjs +189 -0
- package/google-workspace/ui/connection-view.mjs +120 -0
- package/google-workspace/ui/destination-panel.handler.mjs +106 -0
- package/google-workspace/ui/destination-panel.html +17 -0
- package/google-workspace/ui/destination-panel.mjs +150 -0
- package/google-workspace/ui/dom.mjs +140 -0
- package/google-workspace/ui/panel.css +170 -0
- package/google-workspace/ui/picker-flow.mjs +87 -0
- package/google-workspace/ui/picker-view.mjs +44 -0
- package/google-workspace/ui/picker.handler.mjs +56 -0
- package/google-workspace/ui/picker.html +17 -0
- package/google-workspace/ui/picker.mjs +88 -0
- package/google-workspace/ui/surface-client.mjs +227 -0
- package/higgsfield-media/mcp.json +9 -0
- package/higgsfield-media/plugin.json +109 -0
- package/higgsfield-media/skills/higgsfield-media/SKILL.md +325 -0
- package/higgsfield-media/skills/higgsfield-media/references/failure-modes.md +116 -0
- package/higgsfield-media/skills/higgsfield-media/references/models-and-plan-gates.md +142 -0
- package/jev/mcp.json +4 -0
- package/jev/plugin.json +44 -0
- package/jev/skills/jev/SKILL.md +308 -0
- package/jev/skills/jev/references/cookbooks.md +197 -0
- package/jev/skills/jev/references/jaggedness.md +135 -0
- package/jev/skills/jev/references/javascript-sdk.md +146 -0
- package/jev/skills/jev/references/patterns.md +123 -0
- package/jev/skills/jev/references/primitives-reference.md +187 -0
- package/langfuse/plugin.json +22 -0
- package/meilisearch/mcp.json +4 -0
- package/meilisearch/plugin.json +54 -0
- package/meilisearch/providers/meilisearch.mjs +651 -0
- package/meilisearch/skills/meilisearch/SKILL.md +43 -0
- package/meilisearch/tovu-search-providers.json +10 -0
- package/netlify/NOTES.md +13 -0
- package/netlify/mcp.json +4 -0
- package/netlify/plugin.json +47 -0
- package/netlify/skills/netlify/SKILL.md +39 -0
- package/netlify/targets/netlify.mjs +384 -0
- package/netlify/tovu-consent.json +6 -0
- package/netlify/tovu-deploy-targets.json +163 -0
- package/opentelemetry/mcp.json +4 -0
- package/opentelemetry/plugin.json +52 -0
- package/opentelemetry/skills/opentelemetry/SKILL.md +39 -0
- package/package.json +161 -6
- package/posthog/mcp.json +4 -0
- package/posthog/plugin.json +46 -0
- package/posthog/skills/posthog/SKILL.md +36 -0
- package/railway/deploy-configs/railway.mjs +135 -0
- package/railway/deploy-ops/railway.mjs +167 -0
- package/railway/mcp.json +4 -0
- package/railway/plugin.json +45 -0
- package/railway/skills/railway/SKILL.md +66 -0
- package/railway/tovu-deploy-configs.json +16 -0
- package/railway/tovu-deploy-ops.json +11 -0
- package/render/deploy-configs/render.mjs +141 -0
- package/render/deploy-ops/render.mjs +152 -0
- package/render/mcp.json +4 -0
- package/render/plugin.json +47 -0
- package/render/skills/render/SKILL.md +63 -0
- package/render/tovu-deploy-configs.json +16 -0
- package/render/tovu-deploy-ops.json +11 -0
- package/resend/NOTES.md +52 -0
- package/resend/adapters/resend.mjs +161 -0
- package/resend/lib/domains.mjs +137 -0
- package/resend/lib/errors.mjs +25 -0
- package/resend/lib/feedback.mjs +118 -0
- package/resend/mcp.json +13 -0
- package/resend/plugin.json +192 -0
- package/resend/skills/resend/SKILL.md +107 -0
- package/resend/tovu-mail-adapters.json +22 -0
- package/s3-compatible/NOTES.md +11 -0
- package/s3-compatible/mcp.json +4 -0
- package/s3-compatible/object-stores/s3-compatible.mjs +287 -0
- package/s3-compatible/plugin.json +56 -0
- package/s3-compatible/skills/s3-compatible/SKILL.md +39 -0
- package/s3-compatible/targets/s3-compatible.mjs +1054 -0
- package/s3-compatible/tovu-deploy-targets.json +432 -0
- package/s3-compatible/tovu-object-stores.json +29 -0
- package/sentry/mcp.json +4 -0
- package/sentry/plugin.json +49 -0
- package/sentry/skills/sentry/SKILL.md +34 -0
- package/shopify/mcp.json +4 -0
- package/shopify/plugin.json +22 -0
- package/shopify/skills/shopify/SKILL.md +103 -0
- package/shopify/skills/shopify/references/catalog-sync.md +74 -0
- package/shopify/skills/shopify/references/domains.md +26 -0
- package/shopify/skills/shopify/references/live-storefront.md +34 -0
- package/shopify/skills/shopify/references/storefront-snippets.md +21 -0
- package/site-compliance/mcp.json +4 -0
- package/site-compliance/plugin.json +34 -0
- package/site-compliance/skills/site-compliance/SKILL.md +220 -0
- package/site-compliance/skills/site-compliance/references/ccpa-cpra.md +115 -0
- package/site-compliance/skills/site-compliance/references/gdpr-eu-uk.md +143 -0
- package/site-compliance/skills/site-compliance/references/wcag-2-2.md +129 -0
- package/site-import/mcp.json +4 -0
- package/site-import/plugin.json +41 -0
- package/site-import/skills/site-import/SKILL.md +232 -0
- package/site-import/skills/site-import/references/content-mapping.md +166 -0
- package/site-import/skills/site-import/references/discovery-and-classification.md +117 -0
- package/site-import/skills/site-import/references/theme-extraction.md +34 -0
- package/slide-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
- package/slide-design/mcp.json +4 -0
- package/slide-design/plugin.json +26 -0
- package/slide-design/skills/slide-design/SKILL.md +103 -0
- package/slide-design/skills/slide-design/references/charts-and-exhibits.md +100 -0
- package/slide-design/skills/slide-design/references/html-deck.md +175 -0
- package/slide-design/skills/slide-design/references/layout-and-style.md +131 -0
- package/slide-design/skills/slide-design/references/review.md +93 -0
- package/slide-design/skills/slide-design/references/storyline.md +118 -0
- package/stripe/plugin.json +22 -0
- package/supabase/mcp.json +26 -0
- package/supabase/plugin.json +78 -0
- package/supabase/skills/supabase/SKILL.md +169 -0
- package/supabase/skills/supabase/references/failure-modes.md +101 -0
- package/ui-ux-design/CHANGELOG.md +28 -0
- package/ui-ux-design/LICENSE +201 -0
- package/ui-ux-design/NOTICE +14 -0
- package/ui-ux-design/README.md +62 -0
- package/ui-ux-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
- package/ui-ux-design/mcp.json +4 -0
- package/ui-ux-design/plugin.json +26 -0
- package/ui-ux-design/skills/frontend-accessibility/SKILL.md +60 -0
- package/ui-ux-design/skills/gstack-design/SKILL.md +53 -0
- package/ui-ux-design/skills/gstack-design/references/consultation.md +30 -0
- package/ui-ux-design/skills/gstack-design/references/html.md +29 -0
- package/ui-ux-design/skills/gstack-design/references/review.md +28 -0
- package/ui-ux-design/skills/gstack-design/references/shotgun.md +33 -0
- package/ui-ux-design/skills/gstack-design/references/upstream-notes.md +23 -0
- package/ui-ux-design/skills/interface-design/ORIGINAL.md +16 -0
- package/ui-ux-design/skills/interface-design/SKILL.md +41 -0
- package/ui-ux-design/skills/interface-design/references/commands/audit.md +40 -0
- package/ui-ux-design/skills/interface-design/references/commands/critique.md +21 -0
- package/ui-ux-design/skills/interface-design/references/commands/extract.md +34 -0
- package/ui-ux-design/skills/interface-design/references/commands/init.md +31 -0
- package/ui-ux-design/skills/interface-design/references/commands/status.md +22 -0
- package/ui-ux-design/skills/interface-design/references/examples/system-precision.md +29 -0
- package/ui-ux-design/skills/interface-design/references/examples/system-warmth.md +29 -0
- package/ui-ux-design/skills/interface-design/references/system-template.md +57 -0
- package/ui-ux-design/skills/shadcn-ui/README.md +244 -0
- package/ui-ux-design/skills/shadcn-ui/SKILL.md +333 -0
- package/ui-ux-design/skills/shadcn-ui/examples/auth-layout.tsx +177 -0
- package/ui-ux-design/skills/shadcn-ui/examples/data-table.tsx +313 -0
- package/ui-ux-design/skills/shadcn-ui/examples/form-pattern.tsx +177 -0
- package/ui-ux-design/skills/shadcn-ui/resources/component-catalog.md +481 -0
- package/ui-ux-design/skills/shadcn-ui/resources/customization-guide.md +516 -0
- package/ui-ux-design/skills/shadcn-ui/resources/migration-guide.md +463 -0
- package/ui-ux-design/skills/shadcn-ui/resources/setup-guide.md +412 -0
- package/ui-ux-design/skills/shadcn-ui/scripts/verify-setup.sh +134 -0
- package/ui-ux-design/skills/ui-ux-design/SKILL.md +171 -0
- package/ui-ux-design/skills/ui-ux-design/references/brand-and-voice.md +78 -0
- package/ui-ux-design/skills/ui-ux-design/references/chrome-mobile-test-matrix.md +137 -0
- package/ui-ux-design/skills/ui-ux-design/references/components-and-states.md +112 -0
- package/ui-ux-design/skills/ui-ux-design/references/delight-and-motion.md +54 -0
- package/ui-ux-design/skills/ui-ux-design/references/foundations.md +145 -0
- package/ui-ux-design/skills/ui-ux-design/references/inclusive-ai-imagery.md +57 -0
- package/ui-ux-design/skills/ui-ux-design/references/kole-jain-uiux-concepts.md +104 -0
- package/ui-ux-design/skills/ui-ux-design/references/kuinetic-reference.md +184 -0
- package/ui-ux-design/skills/ui-ux-design/references/openai-frontend-skill.md +139 -0
- package/ui-ux-design/skills/ui-ux-design/references/premium-ui.md +55 -0
- package/ui-ux-design/skills/ui-ux-design/references/research-and-validation.md +88 -0
- package/ui-ux-design/skills/ui-ux-design/references/sam-crawford-premium-websites.md +103 -0
- package/ui-ux-design/skills/ui-ux-design/references/self-made-web-designer-core-skills.md +119 -0
- package/ui-ux-design/skills/ui-ux-design/references/ui-animation.md +173 -0
- package/ui-ux-design/skills/ui-ux-design/references/ui-loop.md +234 -0
- package/ui-ux-design/skills/ui-ux-design/references/visual-storytelling.md +54 -0
- package/ui-ux-design/skills/vercel-web-design-guidelines/SKILL.md +39 -0
- package/ui-ux-design/skills/web-compliance/SKILL.md +134 -0
- package/ui-ux-design/skills/web-compliance/references/audit-evidence.md +59 -0
- package/vercel/NOTES.md +23 -0
- package/vercel/mcp.json +4 -0
- package/vercel/object-stores/vercel-blob.mjs +147 -0
- package/vercel/plugin.json +47 -0
- package/vercel/skills/vercel/SKILL.md +48 -0
- package/vercel/targets/vercel.mjs +586 -0
- package/vercel/tovu-consent.json +6 -0
- package/vercel/tovu-deploy-targets.json +219 -0
- package/vercel/tovu-object-stores.json +10 -0
- package/wordpress/NOTES.md +33 -0
- package/wordpress/plugin.json +30 -0
- package/wordpress/skills/wordpress/SKILL.md +43 -0
- package/wordpress/skills/wordpress/references/free-site-redirect.md +46 -0
- package/wordpress/skills/wordpress/references/subdomain.md +66 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"connections": [
|
|
4
|
+
{
|
|
5
|
+
"id": "account",
|
|
6
|
+
"module": "lib/auth.mjs",
|
|
7
|
+
"label": "Google Workspace",
|
|
8
|
+
"authorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth",
|
|
9
|
+
"tokenEndpoint": "https://oauth2.googleapis.com/token",
|
|
10
|
+
"authorizationParams": {
|
|
11
|
+
"public": { "access_type": "offline", "prompt": "consent" },
|
|
12
|
+
"confidential": { "access_type": "offline", "include_granted_scopes": "true" }
|
|
13
|
+
},
|
|
14
|
+
"forceConsentParams": { "prompt": "consent" },
|
|
15
|
+
"startParams": [
|
|
16
|
+
{ "name": "trigger_onepick", "maxLength": 8 },
|
|
17
|
+
{ "name": "mimetypes", "maxLength": 256 }
|
|
18
|
+
],
|
|
19
|
+
"callbackFields": [
|
|
20
|
+
{ "name": "picked_file_ids", "maxLength": 4096 }
|
|
21
|
+
],
|
|
22
|
+
"budgets": [
|
|
23
|
+
{ "scope": "connection", "perMinute": 45 },
|
|
24
|
+
{ "scope": "instance", "perMinute": 240 }
|
|
25
|
+
],
|
|
26
|
+
"scopeSets": ["sheets"],
|
|
27
|
+
"apiOrigins": ["https://sheets.googleapis.com", "https://www.googleapis.com"],
|
|
28
|
+
"byoClient": true,
|
|
29
|
+
"clients": {
|
|
30
|
+
"web": { "clientIdEnv": "TOVU_GOOGLE_WORKSPACE_OAUTH_WEB_CLIENT_ID", "clientSecretEnv": "TOVU_GOOGLE_WORKSPACE_OAUTH_WEB_CLIENT_SECRET" },
|
|
31
|
+
"desktop": { "clientIdEnv": "TOVU_GOOGLE_WORKSPACE_OAUTH_DESKTOP_CLIENT_ID", "clientSecretEnv": "TOVU_GOOGLE_WORKSPACE_OAUTH_DESKTOP_CLIENT_SECRET" }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# `ui/` - the Google Workspace admin panels
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The plugin's own admin screens, declared in `../tovu-admin-surfaces.json` and mounted by the host as
|
|
6
|
+
sandboxed frames (Jini `@jini-ai/agent-plugins/admin-surfaces`; in Tovu,
|
|
7
|
+
`apps/website/src/features/agent-plugins/admin-surfaces/`). Three surfaces:
|
|
8
|
+
|
|
9
|
+
| surface id | entry | what the person does there |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `connection` | `connection-panel.html` | Connect, reconnect, switch account (staged, confirm/cancel; confirming moves only the form destinations the person ticked, each after the new account opened its spreadsheet), disconnect and retry a pending revocation, turn Sheets on/off, grant a product's permission. |
|
|
12
|
+
| `picker` | `picker.html` | Choose a spreadsheet with Google's Picker; see the spreadsheets chosen so far. |
|
|
13
|
+
| `form-destinations` | `destination-panel.html` | Per form: spreadsheet (chosen one or the Picker) + tab (numeric sheet id); save or remove. |
|
|
14
|
+
|
|
15
|
+
## How it fits
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
frame (static files, served without credentials, CSP-locked) server (handlerModule, never served)
|
|
19
|
+
<name>.mjs ── dom.mjs ── surface-client.mjs ── action-request ──▶ host dispatcher ──▶ <name>.handler.mjs
|
|
20
|
+
◀── action-response ── │
|
|
21
|
+
lib/surface-actions.mjs, lib/picker-flow.mjs,
|
|
22
|
+
lib/form-destination-settings.mjs, lib/picker-selection.mjs
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- Frame files: `*.html` entries, `*.mjs` frame scripts, `panel.css`. Text is always set with
|
|
26
|
+
`textContent` (`dom.mjs`); no inline script or style (the CSP forbids both).
|
|
27
|
+
- `surface-client.mjs` is a verbatim copy of Jini's self-contained protocol client (see its header);
|
|
28
|
+
refresh it when the protocol changes.
|
|
29
|
+
- `connection-view.mjs` and `picker-view.mjs` are pure wording modules (no DOM): the honesty rules
|
|
30
|
+
("Disconnected locally; Google revocation pending", "cancel changes nothing") live there and are
|
|
31
|
+
tested there.
|
|
32
|
+
- Sign-in links: the frame has no `allow-popups`, so a handler's `begin` link is opened by the host
|
|
33
|
+
page ("Continue to sign-in"); no handler returns the link.
|
|
34
|
+
|
|
35
|
+
## Public API (handler modules)
|
|
36
|
+
|
|
37
|
+
Form destinations declares the read-only `forms: ["list"]` capability. Its list/save/remove
|
|
38
|
+
results optionally include `forms: [{slug,title}]` (at most 200), so the panel offers site forms by
|
|
39
|
+
title and saves the chosen slug. An empty list keeps an empty selector; a failed initial read shows
|
|
40
|
+
the error without an editor. Only hosts without the capability use the typed short-name fallback.
|
|
41
|
+
The server's existing binding writer still checks that the selected form belongs to this workspace.
|
|
42
|
+
|
|
43
|
+
Each `*.handler.mjs` default-exports `{ actions, bootstrap? }` for its surface and exports its factory
|
|
44
|
+
(`createConnectionPanelHandlers()`, `createPickerHandlers({}, { now, randomId })`,
|
|
45
|
+
`createDestinationPanelHandlers({}, { now, randomId })`) for tests.
|
|
46
|
+
|
|
47
|
+
| surface | actions |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `connection` | `status`, `sign-in { action }`, `confirm-switch { reassign: formSlug[] }` (result adds `reassignment: { moved, failed[{ formSlug, code, message }] }`), `cancel-switch`, `disconnect` (result adds `revocation: confirmed \| pending \| not-needed`), `set-product { productId, enabled }` |
|
|
50
|
+
| `picker` | bootstrap; `list-picked`, `verify-picked { sessionId, fileId }`, `start-system-picker`, `finish-system-picker` |
|
|
51
|
+
| `form-destinations` | the Picker's bootstrap and actions, plus `list-destinations`, `list-tabs { spreadsheetId }`, `save-destination { formSlug, spreadsheetId, sheetId, confirmExternalRows }`, `remove-destination { formSlug }` |
|
|
52
|
+
|
|
53
|
+
Plugin settings keys written here: `products` (`{ sheets: true }`), `picked-spreadsheets` (newest
|
|
54
|
+
first, at most 10, metadata + account), `form-destinations` (`{ <formSlug>: { spreadsheetId, title,
|
|
55
|
+
url, sheetId, tabTitle, accountId } }`, at most 50), and the transient `picker-session:<surface>` /
|
|
56
|
+
`picker-attempt:<surface>`. None holds a token.
|
|
57
|
+
|
|
58
|
+
## Hosted vs desktop Picker
|
|
59
|
+
|
|
60
|
+
The host decides (the handler call's `context.hostKind`), and the bootstrap's `mode` tells the frame:
|
|
61
|
+
`system-browser` on a desktop host (Google's native Picker as a code + PKCE sign-in with
|
|
62
|
+
`trigger_onepick` and the spreadsheet MIME filter; `picked_file_ids` comes back through the host's
|
|
63
|
+
callback inbox), `hosted` on a hosted site (the JS Picker inside the frame, with the bootstrap's
|
|
64
|
+
short-lived access token and the public `TOVU_GOOGLE_PICKER_BROWSER_KEY` / `TOVU_GOOGLE_PICKER_APP_ID`).
|
|
65
|
+
`unavailable` names why (not connected, permission missing, Sheets off, or a hosted site without both
|
|
66
|
+
Picker values: `picker-not-configured`). Every selection is verified by `lib/picker-selection.mjs` before it is saved.
|
|
67
|
+
|
|
68
|
+
## Extension points
|
|
69
|
+
|
|
70
|
+
A new product adds its switch automatically (the connection panel lists `products.json`); a new
|
|
71
|
+
action needs a declaration in `tovu-admin-surfaces.json` (closed schemas) and a handler in the
|
|
72
|
+
surface's module. Another provider plugin builds its own panels on the same host seam; nothing here
|
|
73
|
+
is imported by a host or Jini.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Server-side handlers of the Google Workspace connection panel (`ui/connection-panel.html`),
|
|
3
|
+
* loaded by the host as this surface's `handlerModule` (never served to the frame).
|
|
4
|
+
*
|
|
5
|
+
* Exists so the account lifecycle has one honest screen: connect, reconnect (same account, same
|
|
6
|
+
* connection), switch account (staged: the new account is shown and must be confirmed), turn a
|
|
7
|
+
* product on or off (a settings write, never a change to what Google granted), grant a product's
|
|
8
|
+
* permission (a separate sign-in), and revoke. Confirming a switch also moves the form destinations
|
|
9
|
+
* the person ticked to the new account, each only after the new account opened its spreadsheet; the
|
|
10
|
+
* rest stay frozen to the old account (a switch never silently changes where rows go). Revocation is reported exactly as the host reports it:
|
|
11
|
+
* `confirmed` only when Google confirmed; `pending` means the site stopped using the sign-in but Google
|
|
12
|
+
* has not confirmed, so the panel says so and offers a retry. No result carries a token, a
|
|
13
|
+
* connection ref or a raw Google scope; scope sets are the plugin's own product ids.
|
|
14
|
+
*/
|
|
15
|
+
import { GOOGLE_ACCOUNT_ACCESS_PAGE } from '../lib/auth.mjs';
|
|
16
|
+
import { verifySpreadsheetAccess } from '../lib/picker-selection.mjs';
|
|
17
|
+
import { PRODUCTS, PRODUCT_STATE_KEY } from '../lib/product-state.mjs';
|
|
18
|
+
import { GoogleWorkspaceError } from '../lib/request-policy.mjs';
|
|
19
|
+
import { assertSpreadsheetId } from '../lib/sheets-client.mjs';
|
|
20
|
+
import { CONNECTION_ID, surfaceAccount, surfaceSheetsClient, withSurfaceFailures } from '../lib/surface-actions.mjs';
|
|
21
|
+
import { readFormDestinations, saveFormDestination } from '../lib/form-destination-settings.mjs';
|
|
22
|
+
|
|
23
|
+
/** The connection status the frame sees; empty labels are left out (the schemas have no null). @complexity O(1). */
|
|
24
|
+
function connectionView(status) {
|
|
25
|
+
return {
|
|
26
|
+
status: status.status,
|
|
27
|
+
grantedProducts: [...status.grantedScopeSetIds],
|
|
28
|
+
...(status.accountLabel ? { accountLabel: status.accountLabel } : {}),
|
|
29
|
+
...(status.pendingSwitchAccountLabel ? { pendingSwitchAccountLabel: status.pendingSwitchAccountLabel } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The stored product switches, as `{ <productId>: boolean }` for catalog products only. @complexity O(p). */
|
|
34
|
+
async function productSwitches(state) {
|
|
35
|
+
const stored = await state.get({ key: PRODUCT_STATE_KEY });
|
|
36
|
+
return Object.fromEntries(PRODUCTS.products.map((product) => [product.id, stored?.[product.id] === true]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Everything the panel shows: connection, products (on/off and whether Google granted their
|
|
41
|
+
* permission), the form destinations (delivery bindings) a switch would affect, and the Google page where a
|
|
42
|
+
* person can remove access themselves.
|
|
43
|
+
* @complexity O(p + d): one status read, two state reads.
|
|
44
|
+
*/
|
|
45
|
+
async function panelView(capabilities) {
|
|
46
|
+
const status = await capabilities.connections.status({ connectionId: CONNECTION_ID });
|
|
47
|
+
const enabled = await productSwitches(capabilities.state);
|
|
48
|
+
const destinations = await readFormDestinations({ deliveryBindings: capabilities.deliveryBindings });
|
|
49
|
+
return {
|
|
50
|
+
connection: connectionView(status),
|
|
51
|
+
products: PRODUCTS.products.map((product) => ({ id: product.id, label: product.label, enabled: enabled[product.id], granted: status.grantedScopeSetIds.includes(product.id) })),
|
|
52
|
+
destinations: Object.entries(destinations).map(([formSlug, saved]) => ({ formSlug, title: saved.title, tabTitle: saved.tabTitle })),
|
|
53
|
+
accountAccessUrl: GOOGLE_ACCOUNT_ACCESS_PAGE,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The scope sets a sign-in asks for: what Google already granted plus every product that is on, in
|
|
59
|
+
* catalog order. Desktop installed apps have no incremental consent, so even "grant a permission" is
|
|
60
|
+
* a re-consent for the full intended set, never a smaller one that would drop a granted product.
|
|
61
|
+
* @complexity O(p).
|
|
62
|
+
*/
|
|
63
|
+
async function intendedScopeSets(capabilities) {
|
|
64
|
+
const status = await capabilities.connections.status({ connectionId: CONNECTION_ID });
|
|
65
|
+
const enabled = await productSwitches(capabilities.state);
|
|
66
|
+
return PRODUCTS.products.filter((product) => enabled[product.id] || status.grantedScopeSetIds.includes(product.id)).map((product) => product.id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Fixed sentences for a destination that was not moved; plugin errors keep their own fixed message. */
|
|
70
|
+
const NOT_FOUND = { code: 'destination-not-found', message: 'This form destination is no longer saved. Reload the panel and check Form destinations.' };
|
|
71
|
+
const NOT_MOVED = { code: 'reassign-failed', message: 'This site could not check this spreadsheet with the new account. Choose its spreadsheet again in Form destinations.' };
|
|
72
|
+
|
|
73
|
+
/** Why one destination was not moved, as the frame may see it (never Google's text). @complexity O(1). */
|
|
74
|
+
function notMoved(formSlug, error) {
|
|
75
|
+
return { formSlug, ...(error instanceof GoogleWorkspaceError ? { code: error.code, message: error.message } : NOT_MOVED) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Moves one destination to `accountId`, only after that account opened its spreadsheet through
|
|
80
|
+
* Drive and the Sheets API; the tab (numeric sheet id) and enabled state stay as chosen.
|
|
81
|
+
* @complexity O(1): two requests, one binding write.
|
|
82
|
+
*/
|
|
83
|
+
async function moveDestination(capabilities, accountId, formSlug, destination) {
|
|
84
|
+
const fileId = assertSpreadsheetId({ id: destination.spreadsheetId }, { code: 'invalid-selection' });
|
|
85
|
+
const verified = await verifySpreadsheetAccess({ client: surfaceSheetsClient({ capabilities }), connectionRef: CONNECTION_ID, fileId });
|
|
86
|
+
const moved = { spreadsheetId: fileId, title: verified.title, sheetId: destination.sheetId, tabTitle: destination.tabTitle, accountId };
|
|
87
|
+
await saveFormDestination({ deliveryBindings: capabilities.deliveryBindings, formSlug, destination: moved }, { bindingId: destination.bindingId, enabled: destination.enabled });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Tries each ticked destination in turn; a failure leaves that binding exactly as it was. @complexity O(t) moves. */
|
|
91
|
+
async function moveEach(capabilities, accountId, formSlugs) {
|
|
92
|
+
const destinations = await readFormDestinations({ deliveryBindings: capabilities.deliveryBindings });
|
|
93
|
+
const outcome = { moved: [], failed: [] };
|
|
94
|
+
for (const formSlug of formSlugs) {
|
|
95
|
+
if (!Object.hasOwn(destinations, formSlug)) { outcome.failed.push({ formSlug, ...NOT_FOUND }); continue; }
|
|
96
|
+
try {
|
|
97
|
+
await moveDestination(capabilities, accountId, formSlug, destinations[formSlug]);
|
|
98
|
+
outcome.moved.push(formSlug);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
outcome.failed.push(notMoved(formSlug, error));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return outcome;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* After a confirmed switch: the ticked destinations (form slugs) move to the new account; unticked
|
|
108
|
+
* ones keep the old account, so their deliveries are refused until the person re-picks them.
|
|
109
|
+
* @complexity O(t): one account read, then two requests and one write per ticked destination.
|
|
110
|
+
*/
|
|
111
|
+
async function reassignDestinations(capabilities, formSlugs) {
|
|
112
|
+
if (formSlugs.length === 0) return { moved: [], failed: [] };
|
|
113
|
+
let account;
|
|
114
|
+
try {
|
|
115
|
+
account = await surfaceAccount({ capabilities });
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return { moved: [], failed: formSlugs.map((formSlug) => notMoved(formSlug, error)) };
|
|
118
|
+
}
|
|
119
|
+
return moveEach(capabilities, account.accountId, formSlugs);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Builds the panel's handler module.
|
|
124
|
+
* @returns {{ actions: Record<string, Function> }} The `handlerModule` default export shape.
|
|
125
|
+
* @complexity O(1) to build; each action is a bounded number of host calls.
|
|
126
|
+
*/
|
|
127
|
+
export function createConnectionPanelHandlers(_required = {}, _optional = {}) {
|
|
128
|
+
const act = (fail, run) => withSurfaceFailures({ fail, run });
|
|
129
|
+
return {
|
|
130
|
+
actions: {
|
|
131
|
+
status: ({ capabilities, fail }) => act(fail, () => panelView(capabilities)),
|
|
132
|
+
|
|
133
|
+
/** Starts a sign-in; the HOST page offers it (the frame cannot open links). The URL is never returned. */
|
|
134
|
+
'sign-in': ({ capabilities, input, fail }) => act(fail, async () => {
|
|
135
|
+
const started = await capabilities.connections.begin({ connectionId: CONNECTION_ID, action: input.action }, { scopeSetIds: await intendedScopeSets(capabilities) });
|
|
136
|
+
return { expiresAt: started.expiresAt };
|
|
137
|
+
}),
|
|
138
|
+
|
|
139
|
+
/** `reassign`: the form slugs whose destinations move to the new account (none by default). */
|
|
140
|
+
'confirm-switch': ({ capabilities, input, fail }) => act(fail, async () => {
|
|
141
|
+
await capabilities.connections.confirmSwitch({ connectionId: CONNECTION_ID });
|
|
142
|
+
const reassignment = await reassignDestinations(capabilities, [...new Set(input.reassign)]);
|
|
143
|
+
return { ...(await panelView(capabilities)), reassignment };
|
|
144
|
+
}),
|
|
145
|
+
|
|
146
|
+
'cancel-switch': ({ capabilities, fail }) => act(fail, async () => {
|
|
147
|
+
await capabilities.connections.cancelSwitch({ connectionId: CONNECTION_ID });
|
|
148
|
+
return panelView(capabilities);
|
|
149
|
+
}),
|
|
150
|
+
|
|
151
|
+
/** Revoke (and retry a pending revocation): the host's outcome passes through unchanged. */
|
|
152
|
+
disconnect: ({ capabilities, fail }) => act(fail, async () => {
|
|
153
|
+
const outcome = await capabilities.connections.disconnect({ connectionId: CONNECTION_ID });
|
|
154
|
+
return { ...(await panelView(capabilities)), revocation: outcome.revocation };
|
|
155
|
+
}),
|
|
156
|
+
|
|
157
|
+
/** Turns a product on or off. Only the settings change: Google's grant stays as it is. */
|
|
158
|
+
'set-product': ({ capabilities, input, fail }) => act(fail, async () => {
|
|
159
|
+
const switches = await productSwitches(capabilities.state);
|
|
160
|
+
await capabilities.state.set({ key: PRODUCT_STATE_KEY, value: { ...switches, [input.productId]: input.enabled } });
|
|
161
|
+
return panelView(capabilities);
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export default createConnectionPanelHandlers();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Google Workspace</title>
|
|
7
|
+
<!-- Static and secret-free: served without credentials. Start data arrives in the host's init message. -->
|
|
8
|
+
<link rel="stylesheet" href="panel.css">
|
|
9
|
+
<script type="module" src="connection-panel.mjs"></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<main>
|
|
13
|
+
<h1 class="sr-only">Google Workspace connection</h1>
|
|
14
|
+
<div id="app" aria-live="polite"><p class="muted">Loading…</p></div>
|
|
15
|
+
</main>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The connection panel in the browser: renders the handler's panel view (account, products,
|
|
3
|
+
* revocation state) and sends the person's choices as declared actions. All wording comes from
|
|
4
|
+
* `connection-view.mjs`; this file only wires buttons to actions.
|
|
5
|
+
*
|
|
6
|
+
* Sign-ins: the panel is a sandboxed frame that cannot open links or popups, so `sign-in` only asks
|
|
7
|
+
* the server to start one; the admin page above the panel then offers "Continue to sign-in" (in the
|
|
8
|
+
* desktop app it opens the system browser). The panel polls the status until the sign-in finishes or
|
|
9
|
+
* its link expires. The Google Account page (to remove access by hand while a revocation is
|
|
10
|
+
* pending) is opened the same way, through the surface client's `openLink`.
|
|
11
|
+
*/
|
|
12
|
+
import { DISCONNECT_CONFIRM_TEXT, REASSIGN_HINT, connectionSummary, productRows, reassignmentMessage, revocationMessage } from './connection-view.mjs';
|
|
13
|
+
import { busy, connectToHost, failureText, h, notice as noticeLine, show, tip, wait } from './dom.mjs';
|
|
14
|
+
|
|
15
|
+
const POLL_MS = 4000;
|
|
16
|
+
let client;
|
|
17
|
+
let app;
|
|
18
|
+
let notice = null;
|
|
19
|
+
/** True while the panel asks "Disconnect and revoke?"; the revocation is sent only from that step. */
|
|
20
|
+
let confirmingDisconnect = false;
|
|
21
|
+
|
|
22
|
+
const call = (action, payload = {}) => client.call({ action, payload });
|
|
23
|
+
|
|
24
|
+
/** Polls the status after a sign-in started, until the connection changes or the link expires. */
|
|
25
|
+
async function followSignIn(before, expiresAt) {
|
|
26
|
+
while (Date.now() < Date.parse(expiresAt)) {
|
|
27
|
+
await wait(POLL_MS);
|
|
28
|
+
const view = await call('status');
|
|
29
|
+
// The sign-in finished: the "continue to sign-in" instruction no longer applies.
|
|
30
|
+
if (JSON.stringify(view.connection) !== JSON.stringify(before.connection)) { notice = null; return render(view); }
|
|
31
|
+
}
|
|
32
|
+
notice = { text: 'The sign-in link expired. Start it again if you still want to change the connection.', tone: 'warn' };
|
|
33
|
+
return render(await call('status'));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Runs one action from a button; failures become the notice (with "Try again" when the host says
|
|
37
|
+
* a retry can help), never a thrown page error. */
|
|
38
|
+
async function run(work) {
|
|
39
|
+
try {
|
|
40
|
+
await busy(app, work);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
notice = { text: failureText(error), retry: error?.retryable === true ? () => run(work) : undefined };
|
|
43
|
+
render(lastView);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SIGN_IN_ACTIONS = { connect: 'connect', reconnect: 'reconnect', switch: 'switch', grant: 'add-scopes' };
|
|
48
|
+
|
|
49
|
+
/** What each summary button does. */
|
|
50
|
+
function onAction(action, view) {
|
|
51
|
+
if (action in SIGN_IN_ACTIONS) return run(async () => {
|
|
52
|
+
const started = await call('sign-in', { action: SIGN_IN_ACTIONS[action] });
|
|
53
|
+
notice = { text: 'Continue with the "Continue to sign-in" button the admin page shows above this panel. This panel updates when you are done.', tone: 'info' };
|
|
54
|
+
render(view);
|
|
55
|
+
await followSignIn(view, started.expiresAt);
|
|
56
|
+
});
|
|
57
|
+
if (action === 'disconnect') { confirmingDisconnect = true; notice = null; return render(view); }
|
|
58
|
+
if (action === 'confirm-disconnect' || action === 'retry-revocation') return run(async () => {
|
|
59
|
+
confirmingDisconnect = false;
|
|
60
|
+
const result = await call('disconnect');
|
|
61
|
+
notice = { text: revocationMessage({ revocation: result.revocation }), tone: result.revocation === 'confirmed' ? 'ok' : 'warn' };
|
|
62
|
+
render(result);
|
|
63
|
+
});
|
|
64
|
+
if (action === 'confirm-switch') return run(async () => {
|
|
65
|
+
const result = await call('confirm-switch', { reassign: tickedDestinations() });
|
|
66
|
+
notice = { text: reassignmentMessage({ reassignment: result.reassignment, destinations: view.destinations }), tone: result.reassignment.failed.length === 0 ? 'ok' : 'warn' };
|
|
67
|
+
render(result);
|
|
68
|
+
});
|
|
69
|
+
return run(async () => { notice = null; render(await call(action)); });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The form slugs whose "move to the new account" box is ticked right now. */
|
|
73
|
+
function tickedDestinations() {
|
|
74
|
+
return [...app.querySelectorAll('input[name="reassign"]')].filter((box) => box.checked).map((box) => box.value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One unticked checkbox per destination while a switch is staged: moving one is always a choice. */
|
|
78
|
+
function reassignList(destinations) {
|
|
79
|
+
return h('fieldset', { class: 'list reassign' }, [
|
|
80
|
+
h('legend', {}, [REASSIGN_HINT]),
|
|
81
|
+
...destinations.map((entry) => h('label', { class: 'check' }, [
|
|
82
|
+
h('input', { type: 'checkbox', name: 'reassign', value: entry.formSlug }),
|
|
83
|
+
`${entry.formSlug}: ${entry.title} / ${entry.tabTitle}`,
|
|
84
|
+
])),
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The Google Account page, opened by the admin page (the frame cannot open links): only this
|
|
90
|
+
* surface's declared `linkOrigins` open. The address stays visible in case the host refuses.
|
|
91
|
+
*/
|
|
92
|
+
function accountAccessLink(url) {
|
|
93
|
+
const open = () => client.openLink({ url }).catch((error) => { notice = { text: failureText(error) }; render(lastView); });
|
|
94
|
+
return h('p', { class: 'account-link' }, [
|
|
95
|
+
'Or remove access yourself:', ' ',
|
|
96
|
+
h('button', { type: 'button', class: 'small', onClick: open }, ['Open Google Account page']),
|
|
97
|
+
' ', h('span', { class: 'url' }, [url]),
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The second step of "Disconnect and revoke": nothing is sent until the person confirms here. */
|
|
102
|
+
function disconnectConfirmation(view) {
|
|
103
|
+
const cancel = () => { confirmingDisconnect = false; render(view); };
|
|
104
|
+
return h('div', { class: 'confirm', role: 'group', 'aria-label': 'Confirm disconnect' }, [
|
|
105
|
+
h('p', {}, [DISCONNECT_CONFIRM_TEXT]),
|
|
106
|
+
h('div', { class: 'actions' }, [
|
|
107
|
+
h('button', { type: 'button', class: 'danger', onClick: () => onAction('confirm-disconnect', view) }, ['Yes, disconnect and revoke']),
|
|
108
|
+
h('button', { type: 'button', onClick: cancel }, ['Cancel']),
|
|
109
|
+
]),
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function connectionCard(view) {
|
|
114
|
+
const summary = connectionSummary({ connection: view.connection });
|
|
115
|
+
const switching = Boolean(view.connection.pendingSwitchAccountLabel);
|
|
116
|
+
// At most one primary action, and none while all is well (connected); the irreversible one reads
|
|
117
|
+
// as dangerous and sits apart at the end.
|
|
118
|
+
const buttonClass = (entry, index) => {
|
|
119
|
+
if (entry.action === 'disconnect') return 'danger-quiet';
|
|
120
|
+
return index === 0 && summary.tone !== 'ok' ? 'primary' : undefined;
|
|
121
|
+
};
|
|
122
|
+
return h('section', { class: 'card', 'aria-labelledby': 'account-heading' }, [
|
|
123
|
+
h('div', { class: 'head' }, [
|
|
124
|
+
h('span', { class: `dot ${summary.tone}`, 'aria-hidden': 'true' }),
|
|
125
|
+
h('h2', { id: 'account-heading' }, [summary.heading]),
|
|
126
|
+
tip('About the Google connection', summary.detail),
|
|
127
|
+
]),
|
|
128
|
+
switching && view.destinations.length > 0 ? reassignList(view.destinations) : null,
|
|
129
|
+
h('div', { class: 'actions' }, summary.buttons.map((entry, index) => h('button', { type: 'button', class: buttonClass(entry, index), onClick: () => onAction(entry.action, view) }, [entry.label]))),
|
|
130
|
+
confirmingDisconnect ? disconnectConfirmation(view) : null,
|
|
131
|
+
view.connection.status === 'revocation-pending' ? accountAccessLink(view.accountAccessUrl) : null,
|
|
132
|
+
]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function productsCard(view) {
|
|
136
|
+
const rows = productRows({ products: view.products, connection: view.connection });
|
|
137
|
+
return h('section', { class: 'card', 'aria-labelledby': 'products-heading' }, [
|
|
138
|
+
h('div', { class: 'head' }, [
|
|
139
|
+
h('h2', { id: 'products-heading' }, ['Products']),
|
|
140
|
+
tip('About products', 'Turning a product on or off changes only what this site does; it never changes what Google granted.'),
|
|
141
|
+
]),
|
|
142
|
+
h('ul', { class: 'rows' }, rows.map((row) => h('li', { class: 'product' }, [
|
|
143
|
+
h('div', { class: 'product-name' }, [
|
|
144
|
+
h('span', { class: 'name-line' }, [h('strong', {}, [row.label]), tip(`About ${row.label}`, row.note)]),
|
|
145
|
+
h('span', { class: row.grant ? 'state warn' : 'state' }, [row.state]),
|
|
146
|
+
]),
|
|
147
|
+
row.grant ? h('button', { type: 'button', class: 'primary small', onClick: () => onAction('grant', view) }, ['Grant permission']) : null,
|
|
148
|
+
// A switch drawn from the toggle button: the visible track is decoration; its text is the action.
|
|
149
|
+
h('button', { type: 'button', class: 'switch', 'aria-pressed': String(row.enabled), onClick: () => run(async () => { notice = null; render(await call('set-product', { productId: row.id, enabled: !row.enabled })); }) }, [
|
|
150
|
+
h('span', { class: 'sr-only' }, [row.toggleLabel]),
|
|
151
|
+
]),
|
|
152
|
+
]))),
|
|
153
|
+
]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let lastView = null;
|
|
157
|
+
|
|
158
|
+
function render(view) {
|
|
159
|
+
lastView = view;
|
|
160
|
+
// A null view only comes from a failed first `status`, which always sets the notice first (and
|
|
161
|
+
// with no view there are no buttons, so no later action can render null again).
|
|
162
|
+
if (view === null) return show(app, noticeLine(notice));
|
|
163
|
+
return show(app,
|
|
164
|
+
notice ? noticeLine(notice) : null,
|
|
165
|
+
connectionCard(view),
|
|
166
|
+
productsCard(view));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Starts the panel in a frame: connects to the host and renders the status once the host's init
|
|
171
|
+
* arrives. Exported so a test can start a fresh panel (fresh state) in its own jsdom window; the
|
|
172
|
+
* last line starts the real one.
|
|
173
|
+
* @param {{ window: Window, document: Document }} required
|
|
174
|
+
*/
|
|
175
|
+
export function initConnectionPanel(required) {
|
|
176
|
+
client = connectToHost({ window: required.window });
|
|
177
|
+
app = required.document.getElementById('app');
|
|
178
|
+
notice = null;
|
|
179
|
+
confirmingDisconnect = false;
|
|
180
|
+
lastView = null;
|
|
181
|
+
client.ready.then(load);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The first `status`; a failure the host calls retryable offers "Try again" (another `status`). */
|
|
185
|
+
function load() {
|
|
186
|
+
return call('status').then(render, (error) => { notice = { text: failureText(error), retry: error?.retryable === true ? load : undefined }; render(null); });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
initConnectionPanel({ window, document });
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What the connection panel says and offers for each connection state: pure functions from
|
|
3
|
+
* the handler's panel view to text and buttons, with no DOM, so the honesty rules are testable.
|
|
4
|
+
*
|
|
5
|
+
* Exists because the wording IS the security property here: a revocation Google has not confirmed
|
|
6
|
+
* must read "Disconnected locally; Google revocation pending" (never "revoked"), turning a product
|
|
7
|
+
* off must not read like removing Google's permission, and a staged account switch must name the
|
|
8
|
+
* new account before anything changes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const REVOCATION_PENDING_TEXT = 'Disconnected locally; Google revocation pending';
|
|
12
|
+
|
|
13
|
+
const REVOCATION_TEXT = Object.freeze({
|
|
14
|
+
confirmed: 'Disconnected. Google confirmed that this site\'s access to the account was removed.',
|
|
15
|
+
pending: `${REVOCATION_PENDING_TEXT}. This site no longer uses this sign-in, but Google has not confirmed removing its access. Retry below, or remove this site's access yourself on your Google Account page.`,
|
|
16
|
+
'not-needed': 'Disconnected. There was no Google access left to remove.',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shown while a switch is staged. The panel lists this plugin's form destinations (its delivery
|
|
21
|
+
* bindings), and each stays with the account its spreadsheet was chosen with unless the person ticks it.
|
|
22
|
+
*/
|
|
23
|
+
export const SWITCH_NOTE = 'After the switch, the Google Workspace tools act as the new account. Form destinations stay with the account their spreadsheet was chosen with: tick the ones below that should deliver as the new account, and this site first checks that the new account can open each spreadsheet. A destination you leave unticked, or that the new account cannot open, keeps the old account: its deliveries fail with "Reconnect required" until you choose its spreadsheet again in Form destinations. Deliveries already queued keep the old account and are not sent again.';
|
|
24
|
+
|
|
25
|
+
/** What the panel says under the destination checkboxes: nothing is ticked until the person ticks it. */
|
|
26
|
+
export const REASSIGN_HINT = 'Move to the new account (nothing is moved unless you tick it):';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The panel's own second step before a revocation (the sandboxed frame has no `confirm()`): it is
|
|
30
|
+
* irreversible at Google, so one click must never do it.
|
|
31
|
+
*/
|
|
32
|
+
export const DISCONNECT_CONFIRM_TEXT = 'Disconnect and revoke? This site stops using this Google account at once and asks Google to remove its access. This cannot be undone: using Google Workspace again needs a new sign-in, and form deliveries fail until then.';
|
|
33
|
+
|
|
34
|
+
const button = (action, label) => ({ action, label });
|
|
35
|
+
|
|
36
|
+
const STATES = Object.freeze({
|
|
37
|
+
disconnected: () => ({ heading: 'Not connected', tone: 'neutral', detail: 'Connect a Google account to let this site work with the spreadsheets you choose or create. It asks only for access to files you pick or it creates.', buttons: [button('connect', 'Connect Google account')] }),
|
|
38
|
+
connected: ({ accountLabel }) => ({ heading: `Connected as ${accountLabel ?? 'a Google account'}`, tone: 'ok', detail: 'Reconnect keeps this account and its settings. Switch account asks you to confirm the new account before anything changes.', buttons: [button('reconnect', 'Reconnect'), button('switch', 'Switch account'), button('disconnect', 'Disconnect and revoke')] }),
|
|
39
|
+
'reconnect-required': () => ({ heading: 'Reconnect required', tone: 'warn', detail: 'Google no longer accepts the saved sign-in. Form deliveries fail while it is like this and stay listed as failed; they are not sent again after you reconnect. Reconnect the same account so new submissions are delivered.', buttons: [button('reconnect', 'Reconnect'), button('disconnect', 'Disconnect and revoke')] }),
|
|
40
|
+
'revocation-pending': () => ({ heading: REVOCATION_PENDING_TEXT, tone: 'warn', detail: REVOCATION_TEXT.pending, buttons: [button('retry-revocation', 'Retry Google revocation'), button('connect', 'Connect again')] }),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Heading, status tone (the dot beside the heading), explanation and buttons for the connection; a
|
|
45
|
+
* staged switch takes precedence.
|
|
46
|
+
* @param {{ connection: { status: string, accountLabel?: string, pendingSwitchAccountLabel?: string } }} required
|
|
47
|
+
* @returns {{ heading: string, tone: 'ok' | 'warn' | 'info' | 'neutral', detail: string, buttons: Array<{ action: string, label: string }> }}
|
|
48
|
+
* @complexity O(1).
|
|
49
|
+
*/
|
|
50
|
+
export function connectionSummary(required, _optional = {}) {
|
|
51
|
+
const { connection } = required;
|
|
52
|
+
if (connection.pendingSwitchAccountLabel) {
|
|
53
|
+
return {
|
|
54
|
+
heading: `Switch to ${connection.pendingSwitchAccountLabel}?`,
|
|
55
|
+
tone: 'info',
|
|
56
|
+
detail: `Signed in now as ${connection.accountLabel ?? 'the current account'}. Nothing changes until you confirm. ${SWITCH_NOTE}`,
|
|
57
|
+
buttons: [button('confirm-switch', `Use ${connection.pendingSwitchAccountLabel}`), button('cancel-switch', 'Keep the current account')],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return (STATES[connection.status] ?? STATES.disconnected)(connection);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The sentence after a disconnect, exactly as the host reported the remote outcome.
|
|
65
|
+
* @param {{ revocation: 'confirmed' | 'pending' | 'not-needed' }} required
|
|
66
|
+
* @returns {string} An unknown value reads as pending: never claim a revocation nobody confirmed.
|
|
67
|
+
* @complexity O(1).
|
|
68
|
+
*/
|
|
69
|
+
export function revocationMessage(required, _optional = {}) {
|
|
70
|
+
return REVOCATION_TEXT[required.revocation] ?? REVOCATION_TEXT.pending;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The sentence after a confirmed switch: which destinations moved, which did not and why (the
|
|
75
|
+
* plugin's fixed messages only), and that the rest still use the old account.
|
|
76
|
+
* @param {{ reassignment: { moved: string[], failed: Array<{ formSlug: string, message: string }> }, destinations: Array<{ formSlug: string }> }} required
|
|
77
|
+
* @returns {string}
|
|
78
|
+
* @complexity O(d).
|
|
79
|
+
*/
|
|
80
|
+
export function reassignmentMessage(required, _optional = {}) {
|
|
81
|
+
const { moved, failed } = required.reassignment;
|
|
82
|
+
const kept = required.destinations.filter((entry) => !moved.includes(entry.formSlug)).map((entry) => entry.formSlug);
|
|
83
|
+
return [
|
|
84
|
+
'Switched account.',
|
|
85
|
+
moved.length > 0 ? `Now delivering as the new account: ${moved.join(', ')}.` : null,
|
|
86
|
+
...failed.map((entry) => `Not moved, ${entry.formSlug}: ${entry.message}`),
|
|
87
|
+
kept.length > 0 ? `Still using the old account (deliveries fail until you choose the spreadsheet again in Form destinations): ${kept.join(', ')}.` : null,
|
|
88
|
+
].filter(Boolean).join(' ');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A product's state line: on/off is a setting; Google's permission is a separate fact. @complexity O(1). */
|
|
92
|
+
function productNote(product, connected) {
|
|
93
|
+
// Neutral wording: on a fresh install "off" is the normal starting point, not a failure.
|
|
94
|
+
if (!product.enabled) return `Not in use. Turn it on to use ${product.label} in assistant tools and form deliveries. Turning it off later keeps the Google sign-in; deliveries already queued retry for about a day.`;
|
|
95
|
+
if (!connected) return 'On. Connect a Google account to use it.';
|
|
96
|
+
return product.granted ? 'On. Google has granted the permission it needs.' : 'On, but Google has not granted its permission yet. Grant it to use this product.';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The few words under a product's name; the full sentence (`productNote`) is its explanation. @complexity O(1). */
|
|
100
|
+
function productState(product, connected) {
|
|
101
|
+
if (!product.enabled) return 'Not in use';
|
|
102
|
+
if (!connected) return 'Needs a Google account';
|
|
103
|
+
return product.granted ? 'Ready' : 'Needs Google permission';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* One row per product: label, short state, note, the on/off toggle and, when on but not granted,
|
|
108
|
+
* the separate "Grant permission" sign-in.
|
|
109
|
+
* @param {{ products: Array<{ id: string, label: string, enabled: boolean, granted: boolean }>, connection: { status: string } }} required
|
|
110
|
+
* @returns {Array<{ id: string, label: string, state: string, note: string, enabled: boolean, toggleLabel: string, grant: boolean }>}
|
|
111
|
+
* @complexity O(p).
|
|
112
|
+
*/
|
|
113
|
+
export function productRows(required, _optional = {}) {
|
|
114
|
+
const connected = required.connection.status === 'connected';
|
|
115
|
+
return required.products.map((product) => ({
|
|
116
|
+
id: product.id, label: product.label, enabled: product.enabled, state: productState(product, connected), note: productNote(product, connected),
|
|
117
|
+
toggleLabel: product.enabled ? `Turn ${product.label} off` : `Turn ${product.label} on`,
|
|
118
|
+
grant: connected && product.enabled && !product.granted,
|
|
119
|
+
}));
|
|
120
|
+
}
|