Unified · Split
| @@ -0,0 +1,5 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +Show policy and OAuth app removal failures in the UI, and keep success-only state unchanged when those writes fail. | |
| @@ -158,6 +158,7 @@ scenario( · 157 unmodified lines | ||
| 158 | 158 | .getByRole("button") |
| 159 | 159 | .filter({ hasText: leaf }) |
| 160 | 160 | .getByLabel(label, { exact: true }); |
| 161 | + const internalError = JSON.stringify({ _tag: "InternalError", traceId: "policy-write" }); | |
| 161 | 162 | |
| 162 | 163 | await step("Open the integration's Tools tab", async () => { |
| 163 | 164 | await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" }); |
| @@ -172,6 +173,30 @@ scenario( · 8 unmodified lines | ||
| 172 | 173 | await policyMenuFor(alpha, `${integration}.records.create`).waitFor(); |
| 173 | 174 | }); |
| 174 | 175 | |
| 176 | + await step("A rejected policy create reports the failure and writes nothing", async () => { | |
| 177 | + await page.route("**/api/policies", async (route) => { | |
| 178 | + if (route.request().method() !== "POST") { | |
| 179 | + await route.continue(); | |
| 180 | + return; | |
| 181 | + } | |
| 182 | + await route.fulfill({ | |
| 183 | + status: 500, | |
| 184 | + contentType: "application/json", | |
| 185 | + body: internalError, | |
| 186 | + }); | |
| 187 | + }); | |
| 188 | + await policyMenuFor(alpha, `${integration}.records.create`).click(); | |
| 189 | + await page.getByText(leafPattern, { exact: true }).waitFor(); | |
| 190 | + await page.getByRole("menuitem", { name: "Block" }).click(); | |
| 191 | + await page.getByText("Failed to create policy", { exact: true }).waitFor(); | |
| 192 | + const afterFailure = await Effect.runPromise(client.policies.list()); | |
| 193 | + expect( | |
| 194 | + afterFailure.map((policy) => policy.pattern), | |
| 195 | + "a rejected create does not persist the optimistic policy", | |
| 196 | + ).not.toContain(leafPattern); | |
| 197 | + await page.unroute("**/api/policies"); | |
| 198 | + }); | |
| 199 | + | |
| 175 | 200 | await step("Block records.create from the per-tool menu", async () => { |
| 176 | 201 | await policyMenuFor(alpha, `${integration}.records.create`).click(); |
| 177 | 202 | // The menu is headed by the exact pattern it will store. |
| @@ -180,6 +205,30 @@ scenario( · 2 unmodified lines | ||
| 180 | 205 | await leafIndicator(alpha, "create", `Blocked (matched ${leafPattern})`).waitFor(); |
| 181 | 206 | }); |
| 182 | 207 | |
| 208 | + await step("A rejected clear reports the failure and keeps the policy active", async () => { | |
| 209 | + await page.route("**/api/policies/*", async (route) => { | |
| 210 | + if (route.request().method() !== "DELETE") { | |
| 211 | + await route.continue(); | |
| 212 | + return; | |
| 213 | + } | |
| 214 | + await route.fulfill({ | |
| 215 | + status: 500, | |
| 216 | + contentType: "application/json", | |
| 217 | + body: internalError, | |
| 218 | + }); | |
| 219 | + }); | |
| 220 | + await policyMenuFor(alpha, `${integration}.records.create`).click(); | |
| 221 | + await page.getByRole("menuitem", { name: "Clear" }).click(); | |
| 222 | + await page.getByText("Failed to clear policy", { exact: true }).waitFor(); | |
| 223 | + await leafIndicator(alpha, "create", `Blocked (matched ${leafPattern})`).waitFor(); | |
| 224 | + const afterFailure = await Effect.runPromise(client.policies.list()); | |
| 225 | + expect( | |
| 226 | + afterFailure.map((policy) => policy.pattern), | |
| 227 | + "a rejected clear leaves the stored policy intact", | |
| 228 | + ).toContain(leafPattern); | |
| 229 | + await page.unroute("**/api/policies/*"); | |
| 230 | + }); | |
| 231 | + | |
| 183 | 232 | await step("Require approval for the whole records category", async () => { |
| 184 | 233 | await policyMenuFor(alpha, `${integration}.records.*`).click(); |
| 185 | 234 | await page.getByText(categoryPattern, { exact: true }).waitFor(); |
| @@ -129,6 +129,33 @@ scenario( · 128 unmodified lines | ||
| 129 | 129 | await actions.waitFor(); |
| 130 | 130 | }); |
| 131 | 131 | |
| 132 | + await step("A rejected removal reports the failure and keeps the app", async () => { | |
| 133 | + await page.route("**/api/oauth/clients/**", async (route) => { | |
| 134 | + if (route.request().method() !== "DELETE") { | |
| 135 | + await route.continue(); | |
| 136 | + return; | |
| 137 | + } | |
| 138 | + await route.fulfill({ | |
| 139 | + status: 500, | |
| 140 | + contentType: "application/json", | |
| 141 | + body: JSON.stringify({ _tag: "InternalError", traceId: "oauth-client-remove" }), | |
| 142 | + }); | |
| 143 | + }); | |
| 144 | + await actions.click(); | |
| 145 | + await page.getByRole("menuitem", { name: "Remove" }).click(); | |
| 146 | + await page.getByRole("button", { name: "Remove app" }).click(); | |
| 147 | + await page.getByText(`Failed to remove ${appName}`, { exact: true }).waitFor(); | |
| 148 | + await page.getByRole("heading", { name: `Remove ${appName}?` }).waitFor(); | |
| 149 | + const clients = await Effect.runPromise(client.oauth.listClients()); | |
| 150 | + expect( | |
| 151 | + clients.map((candidate) => String(candidate.slug)), | |
| 152 | + "a rejected removal leaves the registered app in the catalog", | |
| 153 | + ).toContain(appName); | |
| 154 | + await page.getByRole("button", { name: "Cancel" }).click(); | |
| 155 | + await actions.waitFor(); | |
| 156 | + await page.unroute("**/api/oauth/clients/**"); | |
| 157 | + }); | |
| 158 | + | |
| 132 | 159 | await step("Remove the app and confirm", async () => { |
| 133 | 160 | await actions.click(); |
| 134 | 161 | await page.getByRole("menuitem", { name: "Remove" }).click(); |
| @@ -1230,7 +1230,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1229 unmodified lines | ||
| 1230 | 1230 | const doRegisterDynamic = useAtomSet(registerDynamicOAuthClient, { |
| 1231 | 1231 | mode: "promiseExit", |
| 1232 | 1232 | }); |
| 1233 | − const doRemoveOAuthClient = useAtomSet(removeOAuthClientOptimistic, { mode: "promise" }); | |
| 1233 | + const doRemoveOAuthClient = useAtomSet(removeOAuthClientOptimistic, { mode: "promiseExit" }); | |
| 1234 | 1234 | const doValidate = useAtomSet(validateConnection, { mode: "promiseExit" }); |
| 1235 | 1235 | const doCheckConnectionHealth = useAtomSet(checkConnectionHealth, { mode: "promiseExit" }); |
| 1236 | 1236 | const doUpdateConnection = useAtomSet(updateConnection, { mode: "promiseExit" }); |
| @@ -1610,12 +1610,16 @@ function AddAccountModalView(props: AddAccountModalProps) { · 373 unmodified lines | ||
| 1610 | 1610 | // reconnect at their next refresh); clear the picked app if it was the one |
| 1611 | 1611 | // removed so the connect button doesn't point at a gone slug. |
| 1612 | 1612 | const handleRemoveApp = async (client: OAuthClientSummary): Promise<void> => { |
| 1613 | − setRemovingClient(null); | |
| 1614 | − await doRemoveOAuthClient({ | |
| 1613 | + const exit = await doRemoveOAuthClient({ | |
| 1615 | 1614 | params: { slug: client.slug }, |
| 1616 | 1615 | payload: { owner: client.owner }, |
| 1617 | 1616 | reactivityKeys: oauthClientWriteKeys, |
| 1618 | 1617 | }); |
| 1618 | + if (Exit.isFailure(exit)) { | |
| 1619 | + toast.error(messageFromExit(exit, `Failed to remove ${String(client.slug)}`)); | |
| 1620 | + return; | |
| 1621 | + } | |
| 1622 | + setRemovingClient(null); | |
| 1619 | 1623 | trackEvent("oauth_client_removed", { owner: client.owner }); |
| 1620 | 1624 | toast.success(`Removed ${String(client.slug)}`); |
| 1621 | 1625 | if (pickedApp === String(client.slug)) setPickedApp(null); |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { useCallback, useMemo, useRef } from "react"; |
| 2 | 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 3 | +import * as Exit from "effect/Exit"; | |
| 3 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | +import { toast } from "sonner"; | |
| 4 | 6 | import { |
| 5 | 7 | PolicyId, |
| 6 | 8 | positionForNewPattern, |
| @@ -16,6 +18,7 @@ import { · 9 unmodified lines | ||
| 16 | 18 | } from "../api/atoms"; |
| 17 | 19 | import { policyWriteKeys } from "../api/reactivity-keys"; |
| 18 | 20 | import { trackEvent } from "../api/analytics"; |
| 21 | +import { messageFromExit } from "../api/error-reporting"; | |
| 19 | 22 | |
| 20 | 23 | export interface PolicyAction { |
| 21 | 24 | /** Set the action on a pattern. If a user rule with this exact pattern |
| @@ -44,9 +47,9 @@ export interface PolicyAction { · 22 unmodified lines | ||
| 44 | 47 | */ |
| 45 | 48 | export const usePolicyActions = (owner: Owner = "org"): PolicyAction => { |
| 46 | 49 | const policies = useAtomValue(policiesOptimisticAtom); |
| 47 | − const doCreate = useAtomSet(createPolicyOptimistic, { mode: "promise" }); | |
| 48 | − const doUpdate = useAtomSet(updatePolicyOptimistic, { mode: "promise" }); | |
| 49 | − const doRemove = useAtomSet(removePolicyOptimistic, { mode: "promise" }); | |
| 50 | + const doCreate = useAtomSet(createPolicyOptimistic, { mode: "promiseExit" }); | |
| 51 | + const doUpdate = useAtomSet(updatePolicyOptimistic, { mode: "promiseExit" }); | |
| 52 | + const doRemove = useAtomSet(removePolicyOptimistic, { mode: "promiseExit" }); | |
| 50 | 53 | |
| 51 | 54 | // Sorted by position ASC (lowest position = highest precedence first), |
| 52 | 55 | // matching server evaluation order. Optimistic placeholder rows carry |
| @@ -105,23 +108,31 @@ export const usePolicyActions = (owner: Owner = "org"): PolicyAction => { · 52 unmodified lines | ||
| 105 | 108 | const existing = findExact(pattern); |
| 106 | 109 | if (existing) { |
| 107 | 110 | if (existing.action === action) return; |
| 108 | − await doUpdate({ | |
| 111 | + const exit = await doUpdate({ | |
| 109 | 112 | params: { policyId: PolicyId.make(existing.id) }, |
| 110 | 113 | payload: { owner, action }, |
| 111 | 114 | reactivityKeys: policyWriteKeys, |
| 112 | 115 | }); |
| 116 | + if (Exit.isFailure(exit)) { | |
| 117 | + toast.error(messageFromExit(exit, "Failed to update policy")); | |
| 118 | + return; | |
| 119 | + } | |
| 113 | 120 | trackEvent("tool_policy_set", { action, pattern_kind: patternKind, owner }); |
| 114 | 121 | return; |
| 115 | 122 | } |
| 116 | 123 | const position = computePosition(pattern); |
| 117 | − const created = await doCreate({ | |
| 124 | + const exit = await doCreate({ | |
| 118 | 125 | payload: |
| 119 | 126 | position === undefined |
| 120 | 127 | ? { owner, pattern, action } |
| 121 | 128 | : { owner, pattern, action, position }, |
| 122 | 129 | reactivityKeys: policyWriteKeys, |
| 123 | 130 | }); |
| 124 | − createdIdByPattern.current.set(pattern, String(created.id)); | |
| 131 | + if (Exit.isFailure(exit)) { | |
| 132 | + toast.error(messageFromExit(exit, "Failed to create policy")); | |
| 133 | + return; | |
| 134 | + } | |
| 135 | + createdIdByPattern.current.set(pattern, String(exit.value.id)); | |
| 125 | 136 | trackEvent("tool_policy_set", { action, pattern_kind: patternKind, owner }); |
| 126 | 137 | }, |
| 127 | 138 | [owner, doCreate, doUpdate, findExact, computePosition], |
| @@ -138,12 +149,16 @@ export const usePolicyActions = (owner: Owner = "org"): PolicyAction => { · 10 unmodified lines | ||
| 138 | 149 | const isReal = (id: string | undefined): id is string => !!id && !id.startsWith("pending-"); |
| 139 | 150 | const id = [existing?.id, createdIdByPattern.current.get(pattern), policyId].find(isReal); |
| 140 | 151 | if (!id) return; |
| 141 | − createdIdByPattern.current.delete(pattern); | |
| 142 | − await doRemove({ | |
| 152 | + const exit = await doRemove({ | |
| 143 | 153 | params: { policyId: PolicyId.make(id) }, |
| 144 | 154 | payload: { owner }, |
| 145 | 155 | reactivityKeys: policyWriteKeys, |
| 146 | 156 | }); |
| 157 | + if (Exit.isFailure(exit)) { | |
| 158 | + toast.error(messageFromExit(exit, "Failed to clear policy")); | |
| 159 | + return; | |
| 160 | + } | |
| 161 | + createdIdByPattern.current.delete(pattern); | |
| 147 | 162 | trackEvent("tool_policy_cleared", { |
| 148 | 163 | pattern_kind: pattern.endsWith(".*") ? "group" : "exact", |
| 149 | 164 | owner, |