From b4f0f83a24808cf30030a41a723f859fe4e102e1 Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 15:32:38 +0200 Subject: [PATCH 01/14] docs: add design spec for automation connect UX redesign Removes the manual "Connect automation" step in favor of silent connect-on-activation, with a one-time consent toast, silent proactive renewal, and a quieter status/manage affordance. Signed-off-by: Lukas Hirt --- ...2026-07-24-automation-connect-ux-design.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-24-automation-connect-ux-design.md diff --git a/docs/superpowers/specs/2026-07-24-automation-connect-ux-design.md b/docs/superpowers/specs/2026-07-24-automation-connect-ux-design.md new file mode 100644 index 0000000..78a1eaf --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-automation-connect-ux-design.md @@ -0,0 +1,134 @@ +# Automation connect UX redesign + +Date: 2026-07-24 +Status: Approved (pending spec review) + +## Context + +Scheduled/event-triggered workflows only run in the background if the sidecar +holds a stored oCIS app-password for the workflow owner (see +`backend/pkg/automation`). Today, obtaining that credential is a manual, +separate step: `WorkflowList.vue` shows an "Automation not connected" pill and +a "Connect automation" button in the page header, independent of any specific +workflow. Users find this confusing — the button's purpose isn't self-evident, +and it's an extra required click before scheduled workflows actually work. + +The credential itself is a real oCIS app-password, minted via +`POST /auth-app/tokens` (`backend/pkg/ocisclient/authapp.go`) with the label +`"workflows"`, expiring after 90 days (`automation.defaultExpiry`) — oCIS's +auth-app has no non-expiring option. Minting requires the caller's live bearer +token, which the frontend already holds on every authenticated request +(`useWorkflowsApi.ts` — `authStore.accessToken`). There is no technical +requirement for a dedicated user click; the token is available whenever the +user is using the app at all. + +## Goals + +- Remove the standalone "Connect automation" step from the normal user flow. +- Preserve a real, visible moment of awareness when a background credential is + created — silent connect, not invisible connect. +- Keep background workflows running continuously for an active user without + ever requiring them to notice or act on credential expiry. +- Keep a discoverable way to see status and revoke the credential. + +## Non-goals + +- Changing the underlying oCIS auth-app mechanism or credential lifetime. +- Building our own app-password management UI beyond what's described below + (we rely on oCIS's own account security settings as the authoritative place + to inspect/revoke all app-passwords, ours included). +- Handling the case where a user is offline/never opens the app for >90 days + gracefully beyond "it silently reconnects next time they do." + +## Design + +### 1. Trigger point + +`WorkflowList.vue` drops the header pill and "Connect automation" button. +Instead: + +- Whenever a workflow is saved/activated with a `schedule` or `event` trigger, + the frontend checks automation status; if `connected: false`, it calls + `connectAutomation()` silently as part of completing the activation. +- On `WorkflowList` mount, after loading the workflow list, if any workflow is + `enabled` with a `schedule`/`event` trigger and automation is not connected, + the same silent connect runs. This self-heals installs where automation was + previously disconnected (manually, or via credential expiry beyond the + renewal window) while such workflows remain active. +- Users who never create a schedule/event-triggered workflow never trigger a + connect and never see any automation UI at all. + +### 2. Consent / notification + +Immediately after a successful silent connect, show a one-time toast: + +> "Background execution enabled for your account — this workflow will keep +> running even when you're signed out." + +This is the real consent moment: it's tied to the specific action the user +took (activating a scheduled/event workflow), not a separate abstract toggle. +It is not shown again on subsequent silent renewals (see below). + +### 3. Renewal + +`automation.Service.Status` (called by the frontend's existing +`getAutomationStatus()` on every `WorkflowList` mount) is extended: if the +stored credential is within 14 days of `ExpiresAt`, it mints a fresh +app-password and upserts it (same operation as `Connect`) before returning the +status. This reuses an existing, already-frequent call site — no new +endpoint, no background job/middleware needed. As long as the user opens the +Workflows app at least once every ~76 days, the credential never lapses. +Renewal is silent — no toast — since it isn't a new consent event, just +maintenance of a previously granted one. + +If the user does not return within the renewal window, the credential expires +naturally; background workflows stop firing until the user's next visit +triggers the mount-time reconciliation in (1). + +### 4. Visibility / manage + +The header gets a quiet, non-interactive status line replacing the pill: + +- `Background execution active · manage` when connected. +- `Background execution off` only if the account has at least one + schedule/event-triggered workflow but automation isn't connected (should be + transient/error state given (1), but shown rather than hidden so it isn't + silently broken). +- Nothing at all if the account has no schedule/event-triggered workflows. + +"manage" opens a small panel showing: connection status, expiry date, and a +"Disconnect" button. Disconnecting while schedule/event workflows are active +shows a confirmation warning ("N workflows will stop running in the +background"). + +### 5. Failure handling + +If the silent connect during activation fails (e.g. `MintAppPassword` +errors), the activation itself is blocked and the existing inline error +pattern (`automationError`) is shown. An "Active" workflow that silently isn't +actually running in the background would be a worse failure mode than an +upfront error. + +### 6. Testing + +Rewrite `frontend/tests/e2e/automation.spec.ts`: + +- Create a schedule-triggered workflow, activate it, assert the status line + appears with no button click involved. +- Assert the one-time toast appears on first connect only. +- Assert "manage" → "Disconnect" with an active schedule workflow shows the + warning, and disconnecting flips the status line off. +- (Backend) unit test for `Service.Status` renewal-threshold behavior: a + credential expiring within 14 days gets replaced with a new `ExpiresAt`; + one expiring later is left untouched. + +## Risks / open questions + +- Renewal piggybacking on `Status()` means renewal only happens on page load, + not on a fixed schedule — acceptable per Goals (only needs to cover an + active user), but worth confirming no other code path relies on `Status()` + being a pure read. +- We're relying on oCIS's own account security UI as the place a + security-conscious user would go to fully audit/revoke the "workflows" + app-password outside this app; we have not verified that UI's exact + location/wording and should confirm during implementation. From 7f480190c065c9152d83355dd268ccaf51dc56a1 Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 15:38:10 +0200 Subject: [PATCH 02/14] docs: switch automation renewal to a backend self-renewal job Page-load-piggybacked renewal only covered users who revisit the Workflows app; a periodic job using the scheduler's existing Basic-auth-with-app-password pattern renews indefinitely with no live session required. Also corrects a wrong assumption that oCIS core ships a native app-password management UI. Signed-off-by: Lukas Hirt --- ...2026-07-24-automation-connect-ux-design.md | 98 +++++++++++++------ 1 file changed, 67 insertions(+), 31 deletions(-) diff --git a/docs/superpowers/specs/2026-07-24-automation-connect-ux-design.md b/docs/superpowers/specs/2026-07-24-automation-connect-ux-design.md index 78a1eaf..ce618a5 100644 --- a/docs/superpowers/specs/2026-07-24-automation-connect-ux-design.md +++ b/docs/superpowers/specs/2026-07-24-automation-connect-ux-design.md @@ -27,18 +27,25 @@ user is using the app at all. - Remove the standalone "Connect automation" step from the normal user flow. - Preserve a real, visible moment of awareness when a background credential is created — silent connect, not invisible connect. -- Keep background workflows running continuously for an active user without - ever requiring them to notice or act on credential expiry. +- Keep background workflows running continuously without ever requiring the + user to notice or act on credential expiry — regardless of whether they + revisit the Workflows app itself. - Keep a discoverable way to see status and revoke the credential. ## Non-goals - Changing the underlying oCIS auth-app mechanism or credential lifetime. -- Building our own app-password management UI beyond what's described below - (we rely on oCIS's own account security settings as the authoritative place - to inspect/revoke all app-passwords, ours included). -- Handling the case where a user is offline/never opens the app for >90 days - gracefully beyond "it silently reconnects next time they do." +- Building a general-purpose app-password management UI. oCIS core has no + built-in "app passwords" settings page of its own — that exists only as a + separate community-maintained app, which we don't assume is installed. Our + own "manage" panel (section 4) is the sole supported way to inspect/revoke + the `"workflows"` credential from within this app, and is self-sufficient + for that purpose. +- Auto-disconnecting automation when a user's last schedule/event workflow is + disabled. The self-renewal job (section 3) will keep renewing a connected- + but-idle automation indefinitely until the user explicitly disconnects; this + is a pre-existing gap the current manual-connect flow has too, not something + this redesign needs to newly solve. ## Design @@ -71,19 +78,29 @@ It is not shown again on subsequent silent renewals (see below). ### 3. Renewal -`automation.Service.Status` (called by the frontend's existing -`getAutomationStatus()` on every `WorkflowList` mount) is extended: if the -stored credential is within 14 days of `ExpiresAt`, it mints a fresh -app-password and upserts it (same operation as `Connect`) before returning the -status. This reuses an existing, already-frequent call site — no new -endpoint, no background job/middleware needed. As long as the user opens the -Workflows app at least once every ~76 days, the credential never lapses. -Renewal is silent — no toast — since it isn't a new consent event, just -maintenance of a previously granted one. - -If the user does not return within the renewal window, the credential expires -naturally; background workflows stop firing until the user's next visit -triggers the mount-time reconciliation in (1). +Renewal must not depend on the user opening the Workflows app — that would +silently reintroduce the "must visit within N days" gap this redesign is +meant to remove. Instead it's a backend self-renewal job, run the same way +the existing scheduler operates (`backend/pkg/scheduler`): a periodic sweep +(e.g. daily) over all stored `localdb.Automation` rows, authenticating as +each row's owner via `Basic base64(username:appPassword)` — exactly the auth +header the scheduler already builds at `scheduler.go:136` to run the user's +workflows — with no live user session or bearer token involved at all. + +For any automation within 14 days of `ExpiresAt`, the job calls +`graph.MintAppPassword` using that Basic-auth header (the endpoint only cares +that the caller is authenticated as themselves, per +`ocisclient.MintAppPassword`'s doc comment — it doesn't require a live OIDC +bearer token specifically) to mint a replacement, `UpsertAutomation`s it, and +revokes the old token. This means once connected, a `"workflows"` automation +renews itself indefinitely without the user ever needing to revisit the app — +it only stops if explicitly disconnected, or if renewal itself starts failing +(e.g. the stored app-password was revoked out-of-band). + +`automation.Service.Status` no longer performs any renewal side effect — it +stays a pure read. Renewal failures are logged; if a renewal attempt fails +enough times to leave a credential within its final day of validity, treat it +the same as a lapsed credential (see failure handling below). ### 4. Visibility / manage @@ -109,6 +126,11 @@ pattern (`automationError`) is shown. An "Active" workflow that silently isn't actually running in the background would be a worse failure mode than an upfront error. +If the background self-renewal job fails repeatedly and a credential actually +expires, this is indistinguishable from a manual disconnect: background +workflows stop firing, and the mount-time reconciliation in (1) transparently +reconnects the next time the user opens the Workflows app. + ### 6. Testing Rewrite `frontend/tests/e2e/automation.spec.ts`: @@ -118,17 +140,31 @@ Rewrite `frontend/tests/e2e/automation.spec.ts`: - Assert the one-time toast appears on first connect only. - Assert "manage" → "Disconnect" with an active schedule workflow shows the warning, and disconnecting flips the status line off. -- (Backend) unit test for `Service.Status` renewal-threshold behavior: a - credential expiring within 14 days gets replaced with a new `ExpiresAt`; - one expiring later is left untouched. + +Backend unit tests for the new renewal job: + +- An automation within 14 days of `ExpiresAt` gets a new token minted and + upserted (old token revoked), authenticated via Basic auth built from its + own stored username/app-password — no live/bearer credential involved. +- An automation with more than 14 days left is left untouched. +- A renewal call that fails (e.g. the stored app-password was already revoked + out-of-band) is logged and does not crash the sweep for other users. ## Risks / open questions -- Renewal piggybacking on `Status()` means renewal only happens on page load, - not on a fixed schedule — acceptable per Goals (only needs to cover an - active user), but worth confirming no other code path relies on `Status()` - being a pure read. -- We're relying on oCIS's own account security UI as the place a - security-conscious user would go to fully audit/revoke the "workflows" - app-password outside this app; we have not verified that UI's exact - location/wording and should confirm during implementation. +- This assumes oCIS's `/auth-app/tokens` endpoint accepts Basic auth with an + existing app-password as authorization to mint a new one for that same + user — the scheduler already relies on app-password Basic auth being valid + for Graph/WebDAV calls (`scheduler.go:136`), but we have not specifically + confirmed the auth-app endpoint itself accepts it rather than requiring an + OIDC bearer token. Needs verification against the target oCIS version early + in implementation; if unsupported, renewal would have to fall back to + something session-dependent again. +- The renewal job needs a place to run periodically — likely the same process + hosting the existing scheduler, on its own ticker, rather than new + infrastructure. Implementation should confirm this fits the current process + model before adding a second background loop. +- A connected-but-idle automation (no active schedule/event workflows) will + still renew forever until manually disconnected (see Non-goals) — not a + regression, but worth surfacing to users somehow in a future iteration if it + turns out to matter (e.g. a stale-automation nudge). From 195394fa1ab7d0691944cae98c84cb88a29abfce Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 15:44:58 +0200 Subject: [PATCH 03/14] docs: confirm self-renewal viability via live verification Empirically confirmed against owncloud/ocis-rolling:latest that an app-password can mint its own replacement via Basic auth, and traced why through the reva/auth-app source. Also notes a DELETE /auth-app/tokens 500 quirk observed during testing, unrelated to this design. Signed-off-by: Lukas Hirt --- ...2026-07-24-automation-connect-ux-design.md | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-07-24-automation-connect-ux-design.md b/docs/superpowers/specs/2026-07-24-automation-connect-ux-design.md index ce618a5..06c8489 100644 --- a/docs/superpowers/specs/2026-07-24-automation-connect-ux-design.md +++ b/docs/superpowers/specs/2026-07-24-automation-connect-ux-design.md @@ -152,14 +152,24 @@ Backend unit tests for the new renewal job: ## Risks / open questions -- This assumes oCIS's `/auth-app/tokens` endpoint accepts Basic auth with an - existing app-password as authorization to mint a new one for that same - user — the scheduler already relies on app-password Basic auth being valid - for Graph/WebDAV calls (`scheduler.go:136`), but we have not specifically - confirmed the auth-app endpoint itself accepts it rather than requiring an - OIDC bearer token. Needs verification against the target oCIS version early - in implementation; if unsupported, renewal would have to fall back to - something session-dependent again. +- **Verified** (2026-07-24, against `owncloud/ocis-rolling:latest`, the image + this project targets): an app-password used via Basic auth can successfully + mint a new app-password at `POST /auth-app/tokens` — confirmed both by + reading the source (`services/auth-app/pkg/service/service.go` only checks + for an authenticated user, never how; `pkg/auth/manager/appauth/appauth.go` + in reva carries forward the original app-password's `TokenScope`, which is + `owner` scope since our `Connect` mints with `scope.AddOwnerScope`) and by + empirically minting a token with real credentials, then using it over Basic + auth to mint a second one (`200 OK` both times). Self-renewal as designed + in section 3 is viable. +- Also observed during verification: `DELETE /auth-app/tokens` returned `500` + in this environment regardless of auth method used (real password or + app-password) — appears to be a pre-existing bug/quirk in this oCIS build, + not something this design introduces. The existing `Disconnect` code + already treats revoke failure as non-fatal and forgets the credential + locally anyway (`automation.go:112`); the self-renewal job's "revoke the + old token after minting a new one" step should follow the same + log-and-continue pattern rather than treating revoke failure as fatal. - The renewal job needs a place to run periodically — likely the same process hosting the existing scheduler, on its own ticker, rather than new infrastructure. Implementation should confirm this fits the current process From 1be72c8bfbbe08b46e29cde9f7e36957d043201f Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 15:57:42 +0200 Subject: [PATCH 04/14] docs: add implementation plan for automation connect UX redesign Seven-task plan covering the backend self-renewal job and server wiring, then the frontend composable, panel, view rewrites, and e2e test rewrite, per the approved design spec. Signed-off-by: Lukas Hirt --- .../plans/2026-07-24-automation-connect-ux.md | 1191 +++++++++++++++++ 1 file changed, 1191 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-automation-connect-ux.md diff --git a/docs/superpowers/plans/2026-07-24-automation-connect-ux.md b/docs/superpowers/plans/2026-07-24-automation-connect-ux.md new file mode 100644 index 0000000..27aa5fb --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-automation-connect-ux.md @@ -0,0 +1,1191 @@ +# Automation Connect UX Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the manual "Connect automation" button from the Workflows app and replace it with silent connect-on-activation, a backend self-renewal job that never depends on a live user session, and a quieter status/manage affordance. + +**Architecture:** Backend gets a new periodic self-renewal loop on `automation.Service` that mints a replacement app-password using Basic auth built from the stored credential itself (no live bearer token), wired into the same errgroup that already runs the scheduler and SSE manager in `cmd/workflows`'s server command. Frontend gets a small `useAutomationConnect` composable (silent connect + one-time toast) shared by `WorkflowBuilder.vue` (connect on activating a schedule/event workflow) and `WorkflowList.vue` (self-heal on mount + a status line replacing the old pill/button), plus a new `AutomationPanel.vue` side panel for status/expiry/disconnect. + +**Tech Stack:** Go 1.25 backend (chi router, `modernc.org/sqlite`), Vue 3 + ` + + +``` + +- [ ] **Step 2: Verify types and lint are clean** + +Run: `cd frontend && pnpm check:types && pnpm lint` +Expected: no errors (a pre-existing warning count unrelated to this file is fine; there must be zero errors/warnings pointing at `AutomationPanel.vue`). + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/AutomationPanel.vue +git commit -m "feat(frontend): add AutomationPanel status/disconnect component" +``` + +--- + +## Task 5: Rewrite `WorkflowList.vue` + +**Files:** +- Modify: `frontend/src/views/WorkflowList.vue` + +**Interfaces:** +- Consumes: `useAutomationConnect` (Task 3), `AutomationPanel.vue` (Task 4), existing `useWorkflowsApi` (`getAutomationStatus`, `connectAutomation`, `listWorkflows`, `deleteWorkflow`). +- Produces: the status line text (`"Background execution active"`, `"Background execution off"`) and the `"manage"` button — consumed by Task 7's e2e test. + +- [ ] **Step 1: Replace the whole file** + +Replace the full contents of `frontend/src/views/WorkflowList.vue` with: + +```vue + + + + + +``` + +- [ ] **Step 2: Verify types and lint are clean** + +Run: `cd frontend && pnpm check:types && pnpm lint` +Expected: no errors pointing at `WorkflowList.vue` (unused `$gettext` import would be a lint error — confirm it's still used by the template's `$gettext(...)` calls, which it is). + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/views/WorkflowList.vue +git commit -m "feat(frontend): replace automation pill/button with status line + manage panel" +``` + +--- + +## Task 6: Wire silent connect into `WorkflowBuilder.vue` + +**Files:** +- Modify: `frontend/src/views/WorkflowBuilder.vue` + +**Interfaces:** +- Consumes: `useAutomationConnect` (Task 3), existing `api.getAutomationStatus()`. +- Produces: the "connect on activation, block save on failure" behavior consumed by Task 7's e2e test. + +- [ ] **Step 1: Import the composable and instantiate it** + +In `frontend/src/views/WorkflowBuilder.vue`, change: + +```ts +import { useWorkflowsApi } from '../composables/useWorkflowsApi' +import { useAppConfig } from '../composables/useAppConfig' +``` + +to: + +```ts +import { useWorkflowsApi } from '../composables/useWorkflowsApi' +import { useAppConfig } from '../composables/useAppConfig' +import { useAutomationConnect } from '../composables/useAutomationConnect' +``` + +Then change: + +```ts +const appConfig = useAppConfig() +const api = useWorkflowsApi(appConfig.backendUrl) +const { addNodes, addEdges, fitView } = useVueFlow() +``` + +to: + +```ts +const appConfig = useAppConfig() +const api = useWorkflowsApi(appConfig.backendUrl) +const { connectWithNotice } = useAutomationConnect(api) +const { addNodes, addEdges, fitView } = useVueFlow() +``` + +- [ ] **Step 2: Extract the current trigger type and a "needs automation" check** + +Change: + +```ts +const triggerPayload = () => { + const triggerNode = nodes.value.find((n) => n.type === 'trigger') + const triggerType: TriggerType = triggerNode?.data.triggerType ?? 'manual' + return { + type: triggerType, + schedule: triggerType === 'schedule' ? triggerNode?.data.schedule : undefined, + event: triggerType === 'event' ? triggerNode?.data.event : undefined + } +} +``` + +to: + +```ts +const currentTriggerType = (): TriggerType => nodes.value.find((n) => n.type === 'trigger')?.data.triggerType ?? 'manual' + +const needsAutomation = () => { + const triggerType = currentTriggerType() + return enabled.value && (triggerType === 'schedule' || triggerType === 'event') +} + +const triggerPayload = () => { + const triggerNode = nodes.value.find((n) => n.type === 'trigger') + const triggerType = currentTriggerType() + return { + type: triggerType, + schedule: triggerType === 'schedule' ? triggerNode?.data.schedule : undefined, + event: triggerType === 'event' ? triggerNode?.data.event : undefined + } +} +``` + +- [ ] **Step 3: Connect (if needed) before saving, blocking the save on failure** + +Change: + +```ts +const save = async () => { + saving.value = true + saveError.value = '' + try { + const payload = { + name: name.value, + enabled: enabled.value, + trigger: triggerPayload(), + graph: { nodes: nodes.value, edges: edges.value } + } + if (isNew()) { +``` + +to: + +```ts +const save = async () => { + saving.value = true + saveError.value = '' + try { + if (needsAutomation()) { + const status = await api.getAutomationStatus() + if (!status.connected) { + await connectWithNotice() + } + } + + const payload = { + name: name.value, + enabled: enabled.value, + trigger: triggerPayload(), + graph: { nodes: nodes.value, edges: edges.value } + } + if (isNew()) { +``` + +(The rest of `save()` — the `if (isNew()) { ... } else { ... }` block, `catch`, and `finally` — is unchanged.) + +- [ ] **Step 4: Verify types and lint are clean** + +Run: `cd frontend && pnpm check:types && pnpm lint` +Expected: no errors pointing at `WorkflowBuilder.vue`. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/views/WorkflowBuilder.vue +git commit -m "feat(frontend): silently connect automation when activating a schedule/event workflow" +``` + +--- + +## Task 7: Rewrite the automation e2e test + +**Files:** +- Modify: `frontend/tests/e2e/automation.spec.ts` + +**Interfaces:** +- Consumes: UI text/roles produced by Task 5 (`"Background execution active"`, `"Background execution off"`, `"manage"` button) and Task 6 (silent connect on save, `"Background execution enabled for your account"` toast text from Task 3's composable). + +- [ ] **Step 1: Replace the whole file** + +Replace the full contents of `frontend/tests/e2e/automation.spec.ts` with: + +```ts +import { test, expect } from '@playwright/test' +import { login } from './support/auth' + +test('background execution connects automatically and can be disconnected', async ({ page }) => { + await login(page) + await page.goto('/workflows/workflows') + await expect(page.getByRole('heading', { name: 'Workflows' })).toBeVisible() + + // Start from a known state regardless of what earlier runs left behind: delete any + // leftover workflows from this test, then disconnect automation if it's still connected + // (safe to do unconditionally once those workflows are gone — nothing left to warn about). + for (const row of await page.getByRole('row').filter({ hasText: 'e2e automation workflow' }).all()) { + await row.getByRole('button', { name: 'Delete' }).click() + } + if (await page.getByRole('button', { name: 'manage' }).isVisible()) { + await page.getByRole('button', { name: 'manage' }).click() + await page.getByRole('button', { name: 'Disconnect' }).click() + await expect(page.getByText('Background execution active')).toBeHidden() + } + + // Build a workflow with a manual trigger first and save it — this exercises the "existing + // workflow" update path (no hard navigation), which is where we can reliably observe the + // one-time connect toast. Creating a workflow with a schedule trigger from scratch instead + // hard-navigates to the new workflow's URL immediately after save, before a toast could be + // observed. + await page.getByRole('button', { name: 'Add workflow' }).click() + await page.waitForURL(/\/workflows\/workflows\/new$/) + + await page.getByRole('button', { name: 'Add trigger' }).click() + await page.getByRole('button', { name: 'Manual Trigger', exact: true }).click() + await expect(page.locator('.workflows-node-trigger')).toBeVisible() + + const workflowName = `e2e automation workflow ${Date.now()}` + await page.getByRole('button', { name: 'Untitled workflow' }).click() + await page.getByLabel('Workflow name').fill(workflowName) + await page.getByLabel('Workflow name').press('Enter') + + await page.getByRole('button', { name: 'Save' }).click() + await page.waitForURL(/\/workflows\/workflows\/(?!new$)[\w-]+$/) + + // Still a manual trigger — no automation involved yet. + await expect(page.getByText('Background execution enabled for your account')).toBeHidden() + + // Switch to a schedule trigger and save again — the "existing workflow" path, where + // silent connect + the one-time toast fire with no button click involved. + await page.locator('.workflows-node-trigger').click() + await page.getByLabel('Trigger type').selectOption('schedule') + await page.getByRole('button', { name: 'Close' }).click() + await page.getByRole('button', { name: 'Save' }).click() + + await expect(page.getByText('Background execution enabled for your account')).toBeVisible() + + await page.goto('/workflows/workflows') + await expect(page.getByText('Background execution active')).toBeVisible() + await expect(page.getByRole('button', { name: 'Connect automation' })).toHaveCount(0) + + // Disconnecting while the workflow is still active shows the warning. + await page.getByRole('button', { name: 'manage' }).click() + await page.getByRole('button', { name: 'Disconnect' }).click() + await expect(page.getByText('will stop running in the background', { exact: false })).toBeVisible() + await page.getByRole('button', { name: 'Yes, disconnect' }).click() + await expect(page.getByText('Background execution active')).toBeHidden() + await expect(page.getByText('Background execution off')).toBeVisible() + + // Clean up via the UI's own delete flow. + const row = page.getByRole('row').filter({ hasText: workflowName }) + await row.getByRole('button', { name: 'Delete' }).click() + await expect(row).toBeHidden() +}) +``` + +- [ ] **Step 2: Run the e2e test** + +Run: `cd frontend && pnpm test:e2e automation.spec.ts` +Expected: 1 passed. + +- [ ] **Step 3: Run the full test suite as a regression check** + +Run: `cd frontend && pnpm test:unit && pnpm test:e2e && pnpm check:types && pnpm lint` +Run: `cd backend && go build ./... && go vet ./... && go test ./...` +Expected: everything green — this confirms Tasks 1-7 compose correctly end to end. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/tests/e2e/automation.spec.ts +git commit -m "test(e2e): rewrite automation.spec.ts for the silent-connect flow" +``` From 8a81bd5c926d92d92cfb4d4871d8794c39251e7f Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 16:04:32 +0200 Subject: [PATCH 05/14] chore: ignore .superpowers/ scratch state used by subagent-driven-development Signed-off-by: Lukas Hirt --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b9cbea6..0d57a27 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ backend/*.db # dev setup /dev/docker/traefik/certificates docker-compose.override.yml + +# subagent-driven-development scratch state (progress ledger, briefs, reports) +/.superpowers/ From 4bb4c50c3bd685503bc38a8ef983746bb038d092 Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 16:06:59 +0200 Subject: [PATCH 06/14] feat(backend): add backend self-renewal job for app-passwords Signed-off-by: Lukas Hirt --- backend/pkg/automation/renew.go | 81 ++++++++++++ backend/pkg/automation/renew_test.go | 187 +++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 backend/pkg/automation/renew.go create mode 100644 backend/pkg/automation/renew_test.go diff --git a/backend/pkg/automation/renew.go b/backend/pkg/automation/renew.go new file mode 100644 index 0000000..262421e --- /dev/null +++ b/backend/pkg/automation/renew.go @@ -0,0 +1,81 @@ +package automation + +import ( + "context" + "encoding/base64" + "fmt" + "time" + + "github.com/owncloud/ocis-workflows/pkg/localdb" +) + +// renewalWindow is how close to expiry a stored automation must be before StartRenewalLoop +// mints a replacement. 14 days gives plenty of margin against a daily sweep interval, well +// within the 90-day defaultExpiry. +const renewalWindow = 14 * 24 * time.Hour + +// StartRenewalLoop blocks, checking for automations nearing expiry every interval, until ctx +// is done. Renewal happens entirely server-side — no live user session is involved, only the +// stored app-password itself (see renewOne) — so background execution keeps working +// indefinitely without the user ever needing to revisit the app. +func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.renewDue(ctx) + } + } +} + +func (s *Service) renewDue(ctx context.Context) { + automations, err := s.db.ListAutomations(ctx) + if err != nil { + s.log.Error("automation: list automations for renewal", "error", err) + return + } + + now := time.Now() + for _, a := range automations { + if a.ExpiresAt.Sub(now) > renewalWindow { + continue + } + s.renewOne(ctx, a) + } +} + +// renewOne mints a replacement app-password for a, authenticating with a's own stored +// app-password over Basic auth — the same auth header the scheduler builds to run workflows +// (see scheduler.runOne) — rather than any live bearer token. +func (s *Service) renewOne(ctx context.Context, a localdb.Automation) { + authHeader := "Basic " + base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", a.Username, a.AppPassword)) + + token, expiresAt, err := s.graph.MintAppPassword(ctx, authHeader, defaultExpiry, tokenLabel) + if err != nil { + s.log.Error("automation: renew app password", "userID", a.UserID, "error", err) + return + } + + renewed := localdb.Automation{ + UserID: a.UserID, + Username: a.Username, + AppPassword: token, + ExpiresAt: expiresAt, + ConnectedAt: a.ConnectedAt, + } + if err := s.db.UpsertAutomation(ctx, renewed); err != nil { + s.log.Error("automation: store renewed app password", "userID", a.UserID, "error", err) + return + } + + // Best-effort — the old token being unrevokable (already expired/invalidated + // out-of-band) shouldn't undo the renewal we just successfully stored. + if err := s.graph.RevokeAppPassword(ctx, authHeader, a.AppPassword); err != nil { + s.log.Warn("automation: revoke old app password after renewal, ignoring", "userID", a.UserID, "error", err) + } + + s.log.Info("automation: renewed app password", "userID", a.UserID, "expiresAt", expiresAt) +} diff --git a/backend/pkg/automation/renew_test.go b/backend/pkg/automation/renew_test.go new file mode 100644 index 0000000..57744e5 --- /dev/null +++ b/backend/pkg/automation/renew_test.go @@ -0,0 +1,187 @@ +package automation + +import ( + "context" + "encoding/base64" + "errors" + "log/slog" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/owncloud/ocis-workflows/pkg/localdb" +) + +func testDB(t *testing.T) *localdb.DB { + t.Helper() + db, err := localdb.Open(filepath.Join(t.TempDir(), "test.db"), make([]byte, 32)) + if err != nil { + t.Fatalf("localdb.Open: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(discardWriter{}, nil)) +} + +type discardWriter struct{} + +func (discardWriter) Write(p []byte) (int, error) { return len(p), nil } + +type fakeGraphClient struct { + mintCalls []string // authHeader values MintAppPassword was called with + mintToken string + mintExpiry time.Time + mintErr error + + revokeCalls []string // old-password values RevokeAppPassword was called with +} + +func (f *fakeGraphClient) Me(context.Context, string) (string, error) { return "", nil } +func (f *fakeGraphClient) Username(context.Context, string) (string, error) { return "", nil } + +func (f *fakeGraphClient) MintAppPassword(_ context.Context, authHeader string, _ time.Duration, _ string) (string, time.Time, error) { + f.mintCalls = append(f.mintCalls, authHeader) + if f.mintErr != nil { + return "", time.Time{}, f.mintErr + } + return f.mintToken, f.mintExpiry, nil +} + +func (f *fakeGraphClient) RevokeAppPassword(_ context.Context, _, token string) error { + f.revokeCalls = append(f.revokeCalls, token) + return nil +} + +func TestRenewDueRenewsAutomationNearingExpiry(t *testing.T) { + db := testDB(t) + ctx := t.Context() + + if err := db.UpsertAutomation(ctx, localdb.Automation{ + UserID: "user-1", + Username: "admin", + AppPassword: "old-password", + ExpiresAt: time.Now().Add(10 * 24 * time.Hour), // within the 14-day renewal window + ConnectedAt: time.Now().Add(-80 * 24 * time.Hour), + }); err != nil { + t.Fatalf("UpsertAutomation: %v", err) + } + + newExpiry := time.Now().Add(defaultExpiry).Truncate(time.Second) + graph := &fakeGraphClient{mintToken: "new-password", mintExpiry: newExpiry} + svc := New(graph, db, discardLogger()) + + svc.renewDue(ctx) + + if len(graph.mintCalls) != 1 { + t.Fatalf("expected 1 MintAppPassword call, got %d", len(graph.mintCalls)) + } + wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("admin:old-password")) + if graph.mintCalls[0] != wantAuth { + t.Fatalf("MintAppPassword authHeader = %q, want %q", graph.mintCalls[0], wantAuth) + } + + got, err := db.GetAutomation(ctx, "user-1") + if err != nil { + t.Fatalf("GetAutomation: %v", err) + } + if got.AppPassword != "new-password" { + t.Fatalf("AppPassword after renewal = %q, want %q", got.AppPassword, "new-password") + } + if !got.ExpiresAt.Equal(newExpiry) { + t.Fatalf("ExpiresAt after renewal = %v, want %v", got.ExpiresAt, newExpiry) + } + if len(graph.revokeCalls) != 1 || graph.revokeCalls[0] != "old-password" { + t.Fatalf("expected RevokeAppPassword to be called with the old password, got %v", graph.revokeCalls) + } +} + +func TestRenewDueSkipsAutomationNotNearingExpiry(t *testing.T) { + db := testDB(t) + ctx := t.Context() + + if err := db.UpsertAutomation(ctx, localdb.Automation{ + UserID: "user-1", + Username: "admin", + AppPassword: "still-fresh", + ExpiresAt: time.Now().Add(60 * 24 * time.Hour), // well outside the 14-day window + ConnectedAt: time.Now(), + }); err != nil { + t.Fatalf("UpsertAutomation: %v", err) + } + + graph := &fakeGraphClient{} + svc := New(graph, db, discardLogger()) + + svc.renewDue(ctx) + + if len(graph.mintCalls) != 0 { + t.Fatalf("expected 0 MintAppPassword calls, got %d", len(graph.mintCalls)) + } + got, err := db.GetAutomation(ctx, "user-1") + if err != nil { + t.Fatalf("GetAutomation: %v", err) + } + if got.AppPassword != "still-fresh" { + t.Fatalf("AppPassword changed unexpectedly: %q", got.AppPassword) + } +} + +type selectiveFailGraphClient struct { + failForUsername string + mintToken string + mintExpiry time.Time +} + +func (f *selectiveFailGraphClient) Me(context.Context, string) (string, error) { return "", nil } +func (f *selectiveFailGraphClient) Username(context.Context, string) (string, error) { return "", nil } + +func (f *selectiveFailGraphClient) MintAppPassword(_ context.Context, authHeader string, _ time.Duration, _ string) (string, time.Time, error) { + decoded, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic ")) + username := strings.SplitN(string(decoded), ":", 2)[0] + if username == f.failForUsername { + return "", time.Time{}, errors.New("simulated mint failure") + } + return f.mintToken, f.mintExpiry, nil +} + +func (f *selectiveFailGraphClient) RevokeAppPassword(context.Context, string, string) error { return nil } + +func TestRenewDueContinuesPastAFailedRenewal(t *testing.T) { + db := testDB(t) + ctx := t.Context() + + for _, a := range []localdb.Automation{ + {UserID: "user-fails", Username: "admin", AppPassword: "will-fail", ExpiresAt: time.Now().Add(time.Hour), ConnectedAt: time.Now()}, + {UserID: "user-ok", Username: "marie", AppPassword: "will-succeed", ExpiresAt: time.Now().Add(time.Hour), ConnectedAt: time.Now()}, + } { + if err := db.UpsertAutomation(ctx, a); err != nil { + t.Fatalf("UpsertAutomation(%s): %v", a.UserID, err) + } + } + + newExpiry := time.Now().Add(defaultExpiry).Truncate(time.Second) + graph := &selectiveFailGraphClient{failForUsername: "admin", mintToken: "renewed", mintExpiry: newExpiry} + svc := New(graph, db, discardLogger()) + + svc.renewDue(ctx) // must not panic or stop early + + failed, err := db.GetAutomation(ctx, "user-fails") + if err != nil { + t.Fatalf("GetAutomation(user-fails): %v", err) + } + if failed.AppPassword != "will-fail" { + t.Fatalf("expected user-fails' password to be left untouched, got %q", failed.AppPassword) + } + + ok, err := db.GetAutomation(ctx, "user-ok") + if err != nil { + t.Fatalf("GetAutomation(user-ok): %v", err) + } + if ok.AppPassword != "renewed" { + t.Fatalf("expected user-ok to be renewed, got %q", ok.AppPassword) + } +} From efcea1cd6234f3d95f07517423f6575972e98c25 Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 16:10:40 +0200 Subject: [PATCH 07/14] feat(backend): start the self-renewal loop from the server command Co-Authored-By: Claude Sonnet 5 Signed-off-by: Lukas Hirt --- backend/pkg/command/server.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/pkg/command/server.go b/backend/pkg/command/server.go index 0ca9094..55d4a9d 100644 --- a/backend/pkg/command/server.go +++ b/backend/pkg/command/server.go @@ -35,6 +35,11 @@ const scheduleTickInterval = 10 * time.Second // active event-trigger consumer. const sseReconcileInterval = 30 * time.Second +// renewalTickInterval controls how often the automation service checks for app-passwords +// nearing expiry. Daily is frequent enough given the 14-day renewal window and 90-day +// credential lifetime. +const renewalTickInterval = 24 * time.Hour + // RunServer starts the public API server, the debug server, and the background schedule // evaluator, and blocks until any of them exits or the process receives an interrupt/ // termination signal. @@ -109,6 +114,12 @@ func RunServer(cfg config.Config) error { return nil }) + g.Go(func() error { + log.Info("starting automation renewal loop", "interval", renewalTickInterval) + automationService.StartRenewalLoop(gCtx, renewalTickInterval) + return nil + }) + g.Go(func() error { <-gCtx.Done() log.Info("shutting down") From 4ec921e6d0a6207df1c13bd21634c45ede5239cd Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 16:13:08 +0200 Subject: [PATCH 08/14] feat(frontend): add useAutomationConnect composable Signed-off-by: Lukas Hirt --- .../src/composables/useAutomationConnect.ts | 27 +++++++++++++ .../tests/unit/useAutomationConnect.spec.ts | 38 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 frontend/src/composables/useAutomationConnect.ts create mode 100644 frontend/tests/unit/useAutomationConnect.spec.ts diff --git a/frontend/src/composables/useAutomationConnect.ts b/frontend/src/composables/useAutomationConnect.ts new file mode 100644 index 0000000..ec9143c --- /dev/null +++ b/frontend/src/composables/useAutomationConnect.ts @@ -0,0 +1,27 @@ +import { useGettext } from 'vue3-gettext' +import { useMessages } from '@ownclouders/web-pkg' +import type { AutomationStatus } from '../types/workflow' + +interface AutomationApi { + connectAutomation: () => Promise +} + +/** Silently connects background automation and shows a one-time toast for the transition. + * Callers are responsible for checking whether automation is already connected before + * calling this — it always connects unconditionally. */ +export function useAutomationConnect(api: AutomationApi) { + const { $gettext } = useGettext() + const { showMessage } = useMessages() + + const connectWithNotice = async (): Promise => { + const status = await api.connectAutomation() + showMessage({ + title: $gettext('Background execution enabled for your account'), + desc: $gettext('This workflow will keep running even when you are signed out.'), + status: 'success' + }) + return status + } + + return { connectWithNotice } +} diff --git a/frontend/tests/unit/useAutomationConnect.spec.ts b/frontend/tests/unit/useAutomationConnect.spec.ts new file mode 100644 index 0000000..f789755 --- /dev/null +++ b/frontend/tests/unit/useAutomationConnect.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' + +const showMessage = vi.fn() +vi.mock('@ownclouders/web-pkg', () => ({ + useMessages: () => ({ showMessage }) +})) +vi.mock('vue3-gettext', () => ({ + useGettext: () => ({ $gettext: (msg: string) => msg }) +})) + +import { useAutomationConnect } from '../../src/composables/useAutomationConnect' + +describe('useAutomationConnect', () => { + it('connects and shows a one-time toast', async () => { + const connectAutomation = vi.fn().mockResolvedValue({ connected: true, expirationDateTime: '2026-10-01T00:00:00Z' }) + const { connectWithNotice } = useAutomationConnect({ connectAutomation }) + + const status = await connectWithNotice() + + expect(status).toEqual({ connected: true, expirationDateTime: '2026-10-01T00:00:00Z' }) + expect(connectAutomation).toHaveBeenCalledOnce() + expect(showMessage).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Background execution enabled for your account', + status: 'success' + }) + ) + }) + + it('propagates a failed connect without showing a toast', async () => { + showMessage.mockClear() + const connectAutomation = vi.fn().mockRejectedValue(new Error('boom')) + const { connectWithNotice } = useAutomationConnect({ connectAutomation }) + + await expect(connectWithNotice()).rejects.toThrow('boom') + expect(showMessage).not.toHaveBeenCalled() + }) +}) From f0236e01d5146c0648e31ca58b6fca4740454697 Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 16:16:25 +0200 Subject: [PATCH 09/14] feat(frontend): add AutomationPanel status/disconnect component Signed-off-by: Lukas Hirt --- frontend/src/components/AutomationPanel.vue | 122 ++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 frontend/src/components/AutomationPanel.vue diff --git a/frontend/src/components/AutomationPanel.vue b/frontend/src/components/AutomationPanel.vue new file mode 100644 index 0000000..e7f9993 --- /dev/null +++ b/frontend/src/components/AutomationPanel.vue @@ -0,0 +1,122 @@ + + + + + From 283f2aa7b72f52cebdfb0285081001e2744ffa75 Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 16:20:05 +0200 Subject: [PATCH 10/14] feat(frontend): replace automation pill/button with status line + manage panel Signed-off-by: Lukas Hirt --- frontend/src/views/WorkflowList.vue | 94 ++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 28 deletions(-) diff --git a/frontend/src/views/WorkflowList.vue b/frontend/src/views/WorkflowList.vue index b79d9be..9938fd8 100644 --- a/frontend/src/views/WorkflowList.vue +++ b/frontend/src/views/WorkflowList.vue @@ -3,16 +3,14 @@

{{ $gettext('Workflows') }}

- - - {{ automationConnected ? $gettext('Automation connected') : $gettext('Automation not connected') }} - - - {{ automationConnected ? $gettext('Disconnect automation') : $gettext('Connect automation') }} - + + {{ $gettext('Background execution active') }} + + + + {{ $gettext('Background execution off') }} {{ $gettext('Add workflow') }} @@ -57,27 +55,47 @@ + + @@ -150,7 +173,22 @@ onMounted(() => { .workflows-automation-status { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.4rem; + font-size: 0.85rem; + opacity: 0.8; +} +.workflows-automation-status.is-inactive { + color: #b3261e; + opacity: 1; +} +.workflows-automation-manage-link { + border: none; + background: transparent; + color: var(--oc-color-swatch-brand-default, #1a5fb4); + text-decoration: underline; + cursor: pointer; + padding: 0; + font-size: inherit; } .workflows-list-empty { opacity: 0.7; From 91fc66ebaf1a931c59b8eb8e5926367caffaae8c Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 16:23:28 +0200 Subject: [PATCH 11/14] feat(frontend): silently connect automation when activating a schedule/event workflow Signed-off-by: Lukas Hirt --- frontend/src/views/WorkflowBuilder.vue | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/WorkflowBuilder.vue b/frontend/src/views/WorkflowBuilder.vue index 92a9842..6599684 100644 --- a/frontend/src/views/WorkflowBuilder.vue +++ b/frontend/src/views/WorkflowBuilder.vue @@ -117,6 +117,7 @@ import NodeDetailsPanel from '../components/NodeDetailsPanel.vue' import ExecutionsPanel from '../components/ExecutionsPanel.vue' import { useWorkflowsApi } from '../composables/useWorkflowsApi' import { useAppConfig } from '../composables/useAppConfig' +import { useAutomationConnect } from '../composables/useAutomationConnect' import { builderPath, listPath } from '../router' import { findNodeType, TRIGGER_CATEGORY, AI_CATEGORY, ACTION_CATEGORY } from '../nodeTypes' import type { TriggerType, WorkflowEdge, WorkflowNode, WorkflowNodeData } from '../types/workflow' @@ -127,6 +128,7 @@ const { $gettext } = useGettext() const route = useRoute() const appConfig = useAppConfig() const api = useWorkflowsApi(appConfig.backendUrl) +const { connectWithNotice } = useAutomationConnect(api) const { addNodes, addEdges, fitView } = useVueFlow() const listPathHref = listPath() @@ -225,9 +227,16 @@ const updateNodeData = (nodeId: string, data: WorkflowNodeData) => { } } +const currentTriggerType = (): TriggerType => nodes.value.find((n) => n.type === 'trigger')?.data.triggerType ?? 'manual' + +const needsAutomation = () => { + const triggerType = currentTriggerType() + return enabled.value && (triggerType === 'schedule' || triggerType === 'event') +} + const triggerPayload = () => { const triggerNode = nodes.value.find((n) => n.type === 'trigger') - const triggerType: TriggerType = triggerNode?.data.triggerType ?? 'manual' + const triggerType = currentTriggerType() return { type: triggerType, schedule: triggerType === 'schedule' ? triggerNode?.data.schedule : undefined, @@ -239,6 +248,13 @@ const save = async () => { saving.value = true saveError.value = '' try { + if (needsAutomation()) { + const status = await api.getAutomationStatus() + if (!status.connected) { + await connectWithNotice() + } + } + const payload = { name: name.value, enabled: enabled.value, From 3bfe470a52e0ebb2ca5224dfc0fdfa026b44390b Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 16:52:20 +0200 Subject: [PATCH 12/14] fix(frontend): defer the connect toast until after the workflow persists Signed-off-by: Lukas Hirt --- .../src/composables/useAutomationConnect.ts | 23 +++++++++++---- frontend/src/views/WorkflowBuilder.vue | 12 ++++++-- .../tests/unit/useAutomationConnect.spec.ts | 28 +++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/frontend/src/composables/useAutomationConnect.ts b/frontend/src/composables/useAutomationConnect.ts index ec9143c..0db3d57 100644 --- a/frontend/src/composables/useAutomationConnect.ts +++ b/frontend/src/composables/useAutomationConnect.ts @@ -6,22 +6,33 @@ interface AutomationApi { connectAutomation: () => Promise } -/** Silently connects background automation and shows a one-time toast for the transition. - * Callers are responsible for checking whether automation is already connected before - * calling this — it always connects unconditionally. */ +/** Connects background automation, and separately, notifying the user of that transition + * via a toast. `connect` and `notifyConnected` are split so a caller that has more work to + * do after connecting (e.g. persisting a workflow) can defer the toast until that work has + * actually completed — showing "success" before the real action is durable would let a user + * navigate away and lose unsaved work. `connectWithNotice` combines both steps for callers + * with nothing further to wait on (e.g. a mount-time self-heal with no separate persist + * step). Callers are responsible for checking whether automation is already connected + * before calling any of these — they always connect unconditionally. */ export function useAutomationConnect(api: AutomationApi) { const { $gettext } = useGettext() const { showMessage } = useMessages() - const connectWithNotice = async (): Promise => { - const status = await api.connectAutomation() + const notifyConnected = (): void => { showMessage({ title: $gettext('Background execution enabled for your account'), desc: $gettext('This workflow will keep running even when you are signed out.'), status: 'success' }) + } + + const connect = (): Promise => api.connectAutomation() + + const connectWithNotice = async (): Promise => { + const status = await connect() + notifyConnected() return status } - return { connectWithNotice } + return { connect, notifyConnected, connectWithNotice } } diff --git a/frontend/src/views/WorkflowBuilder.vue b/frontend/src/views/WorkflowBuilder.vue index 6599684..1ca06b3 100644 --- a/frontend/src/views/WorkflowBuilder.vue +++ b/frontend/src/views/WorkflowBuilder.vue @@ -128,7 +128,7 @@ const { $gettext } = useGettext() const route = useRoute() const appConfig = useAppConfig() const api = useWorkflowsApi(appConfig.backendUrl) -const { connectWithNotice } = useAutomationConnect(api) +const { connect, notifyConnected } = useAutomationConnect(api) const { addNodes, addEdges, fitView } = useVueFlow() const listPathHref = listPath() @@ -248,10 +248,12 @@ const save = async () => { saving.value = true saveError.value = '' try { + let justConnected = false if (needsAutomation()) { const status = await api.getAutomationStatus() if (!status.connected) { - await connectWithNotice() + await connect() + justConnected = true } } @@ -263,9 +265,15 @@ const save = async () => { } if (isNew()) { const created = await api.createWorkflow(payload) + if (justConnected) { + notifyConnected() + } window.location.assign(builderPath(created.id)) } else { await api.updateWorkflow(currentId(), payload) + if (justConnected) { + notifyConnected() + } } } catch (e) { saveError.value = e instanceof Error ? e.message : String(e) diff --git a/frontend/tests/unit/useAutomationConnect.spec.ts b/frontend/tests/unit/useAutomationConnect.spec.ts index f789755..e762f8c 100644 --- a/frontend/tests/unit/useAutomationConnect.spec.ts +++ b/frontend/tests/unit/useAutomationConnect.spec.ts @@ -35,4 +35,32 @@ describe('useAutomationConnect', () => { await expect(connectWithNotice()).rejects.toThrow('boom') expect(showMessage).not.toHaveBeenCalled() }) + + it('connect() connects without showing a toast', async () => { + showMessage.mockClear() + const connectAutomation = vi.fn().mockResolvedValue({ connected: true, expirationDateTime: '2026-10-01T00:00:00Z' }) + const { connect } = useAutomationConnect({ connectAutomation }) + + const status = await connect() + + expect(status).toEqual({ connected: true, expirationDateTime: '2026-10-01T00:00:00Z' }) + expect(connectAutomation).toHaveBeenCalledOnce() + expect(showMessage).not.toHaveBeenCalled() + }) + + it('notifyConnected() shows the toast without calling the API', () => { + showMessage.mockClear() + const connectAutomation = vi.fn() + const { notifyConnected } = useAutomationConnect({ connectAutomation }) + + notifyConnected() + + expect(connectAutomation).not.toHaveBeenCalled() + expect(showMessage).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Background execution enabled for your account', + status: 'success' + }) + ) + }) }) From b89ba122c7924d4a521fea65956b2d9bbb480888 Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 17:06:29 +0200 Subject: [PATCH 13/14] test(frontend): rewrite automation.spec.ts for the silent-connect flow Signed-off-by: Lukas Hirt --- frontend/tests/e2e/automation.spec.ts | 85 ++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 15 deletions(-) diff --git a/frontend/tests/e2e/automation.spec.ts b/frontend/tests/e2e/automation.spec.ts index 8670f27..ec0a0cb 100644 --- a/frontend/tests/e2e/automation.spec.ts +++ b/frontend/tests/e2e/automation.spec.ts @@ -1,25 +1,80 @@ import { test, expect } from '@playwright/test' import { login } from './support/auth' -test('connect and disconnect background automation', async ({ page }) => { - await login(page) +test('background execution connects automatically and can be disconnected', async ({ page }) => { + // Automation connect/disconnect state is per-account, and every other e2e spec logs in as + // the default `admin` user and runs concurrently with this one (`fullyParallel` in + // playwright.config.ts). event-trigger.spec.ts in particular also silently connects + // automation (its event-triggered workflow needs it too), which races with this test's own + // connect/disconnect assertions if both run against the same account at once. Logging in as + // a distinct demo user isolates this test's automation state from the rest of the suite. + await login(page, 'einstein', 'relativity') await page.goto('/workflows/workflows') + await expect(page.getByRole('heading', { name: 'Workflows' })).toBeVisible() + // The workflow list (and automation status) loads asynchronously after mount — wait for + // that to settle before inspecting rows below, otherwise `.all()` (which doesn't retry) + // can run before the table exists and silently find nothing. + await expect(page.getByText('Loading workflows...')).toBeHidden() - // Start from a known state regardless of what earlier runs left behind. - const statusPill = page.locator('.workflows-automation-status .workflows-status-pill') - if (await page.getByRole('button', { name: 'Disconnect automation' }).isVisible()) { - await page.getByRole('button', { name: 'Disconnect automation' }).click() - await expect(statusPill).toHaveText('Automation not connected') + // Start from a known state regardless of what earlier runs left behind: delete any + // leftover workflows from this test, then disconnect automation if it's still connected + // (safe to do unconditionally once those workflows are gone — nothing left to warn about). + for (const row of await page.getByRole('row').filter({ hasText: 'e2e automation workflow' }).all()) { + await row.getByRole('button', { name: 'Delete' }).click() + await expect(row).toBeHidden() } + if (await page.getByRole('button', { name: 'manage' }).isVisible()) { + await page.getByRole('button', { name: 'manage' }).click() + await page.getByRole('button', { name: 'Disconnect' }).click() + await expect(page.getByText('Background execution active')).toBeHidden() + } + + // Build a workflow with a manual trigger first and save it — this exercises the "existing + // workflow" update path (no hard navigation), which is where we can reliably observe the + // one-time connect toast. Creating a workflow with a schedule trigger from scratch instead + // hard-navigates to the new workflow's URL immediately after save, before a toast could be + // observed. + await page.getByRole('button', { name: 'Add workflow' }).click() + await page.waitForURL(/\/workflows\/workflows\/new$/) + + await page.getByRole('button', { name: 'Add trigger' }).click() + await page.getByRole('button', { name: 'Manual Trigger', exact: true }).click() + await expect(page.locator('.workflows-node-trigger')).toBeVisible() + + const workflowName = `e2e automation workflow ${Date.now()}` + await page.getByRole('button', { name: 'Untitled workflow' }).click() + await page.getByLabel('Workflow name').fill(workflowName) + await page.getByLabel('Workflow name').press('Enter') + + await page.getByRole('button', { name: 'Save' }).click() + await page.waitForURL(/\/workflows\/workflows\/(?!new$)[\w-]+$/) - await page.getByRole('button', { name: 'Connect automation' }).click() - await expect(statusPill).toHaveText('Automation connected') - await expect(page.getByRole('button', { name: 'Disconnect automation' })).toBeVisible() + // Still a manual trigger — no automation involved yet. + await expect(page.getByText('Background execution enabled for your account', { exact: true })).toBeHidden() + + // Switch to a schedule trigger and save again — the "existing workflow" path, where + // silent connect + the one-time toast fire with no button click involved. + await page.locator('.workflows-node-trigger').click() + await page.getByLabel('Trigger type').selectOption('schedule') + await page.getByRole('button', { name: 'Close' }).click() + await page.getByRole('button', { name: 'Save' }).click() + + await expect(page.getByText('Background execution enabled for your account', { exact: true })).toBeVisible() + + await page.goto('/workflows/workflows') + await expect(page.getByText('Background execution active')).toBeVisible() + await expect(page.getByRole('button', { name: 'Connect automation' })).toHaveCount(0) - await page.reload() - await expect(statusPill).toHaveText('Automation connected') + // Disconnecting while the workflow is still active shows the warning. + await page.getByRole('button', { name: 'manage' }).click() + await page.getByRole('button', { name: 'Disconnect' }).click() + await expect(page.getByText('will stop running in the background', { exact: false })).toBeVisible() + await page.getByRole('button', { name: 'Yes, disconnect' }).click() + await expect(page.getByText('Background execution active')).toBeHidden() + await expect(page.getByText('Background execution off')).toBeVisible() - await page.getByRole('button', { name: 'Disconnect automation' }).click() - await expect(statusPill).toHaveText('Automation not connected') - await expect(page.getByRole('button', { name: 'Connect automation' })).toBeVisible() + // Clean up via the UI's own delete flow. + const row = page.getByRole('row').filter({ hasText: workflowName }) + await row.getByRole('button', { name: 'Delete' }).click() + await expect(row).toBeHidden() }) From 5510ccacc3adf01895b9ac71451891aa7c43959f Mon Sep 17 00:00:00 2001 From: Lukas Hirt Date: Fri, 24 Jul 2026 17:21:24 +0200 Subject: [PATCH 14/14] fix(backend): treat an expired-in-place credential as disconnected Signed-off-by: Lukas Hirt --- backend/pkg/automation/automation.go | 2 +- backend/pkg/automation/automation_test.go | 84 +++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 backend/pkg/automation/automation_test.go diff --git a/backend/pkg/automation/automation.go b/backend/pkg/automation/automation.go index 1c3541e..c78c2ed 100644 --- a/backend/pkg/automation/automation.go +++ b/backend/pkg/automation/automation.go @@ -41,7 +41,7 @@ func New(graph GraphClient, db *localdb.DB, log *slog.Logger) *Service { } func toStatus(a *localdb.Automation) *model.AutomationStatus { - if a == nil { + if a == nil || a.ExpiresAt.Before(time.Now()) { return &model.AutomationStatus{Connected: false} } return &model.AutomationStatus{ diff --git a/backend/pkg/automation/automation_test.go b/backend/pkg/automation/automation_test.go new file mode 100644 index 0000000..f9aa4fc --- /dev/null +++ b/backend/pkg/automation/automation_test.go @@ -0,0 +1,84 @@ +package automation + +import ( + "testing" + "time" + + "github.com/owncloud/ocis-workflows/pkg/localdb" +) + +func TestStatusReportsConnectedForFutureExpiry(t *testing.T) { + db := testDB(t) + ctx := t.Context() + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + if err := db.UpsertAutomation(ctx, localdb.Automation{ + UserID: "user-1", + Username: "admin", + AppPassword: "still-fresh", + ExpiresAt: expiresAt, + ConnectedAt: time.Now(), + }); err != nil { + t.Fatalf("UpsertAutomation: %v", err) + } + + svc := New(&fakeGraphClient{}, db, discardLogger()) + + status, err := svc.Status(ctx, "user-1") + if err != nil { + t.Fatalf("Status: %v", err) + } + if !status.Connected { + t.Fatalf("Connected = false, want true") + } + want := expiresAt.UTC().Format(time.RFC3339) + if status.ExpirationDateTime != want { + t.Fatalf("ExpirationDateTime = %q, want %q", status.ExpirationDateTime, want) + } +} + +func TestStatusReportsDisconnectedForPastExpiry(t *testing.T) { + db := testDB(t) + ctx := t.Context() + + if err := db.UpsertAutomation(ctx, localdb.Automation{ + UserID: "user-1", + Username: "admin", + AppPassword: "expired-in-place", + ExpiresAt: time.Now().Add(-time.Hour), // already expired, but the row was never deleted + ConnectedAt: time.Now().Add(-100 * 24 * time.Hour), + }); err != nil { + t.Fatalf("UpsertAutomation: %v", err) + } + + svc := New(&fakeGraphClient{}, db, discardLogger()) + + status, err := svc.Status(ctx, "user-1") + if err != nil { + t.Fatalf("Status: %v", err) + } + if status.Connected { + t.Fatalf("Connected = true, want false for a past-expiry credential") + } + if status.ExpirationDateTime != "" { + t.Fatalf("ExpirationDateTime = %q, want empty", status.ExpirationDateTime) + } +} + +func TestStatusReportsDisconnectedWhenNeverConnected(t *testing.T) { + db := testDB(t) + ctx := t.Context() + + svc := New(&fakeGraphClient{}, db, discardLogger()) + + status, err := svc.Status(ctx, "never-connected-user") + if err != nil { + t.Fatalf("Status: %v", err) + } + if status.Connected { + t.Fatalf("Connected = true, want false for a user with no stored automation") + } + if status.ExpirationDateTime != "" { + t.Fatalf("ExpirationDateTime = %q, want empty", status.ExpirationDateTime) + } +}