mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
Restore addressed Google service choices during saved setup
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7eca024e1e
commit
a523d97df4
4 files changed
+38
-5
No files matched your search
@@ -137,3 +137,5 @@ The implementation was rebased onto `a1f77821b9cd5c9c846fdf7a0fa5cc620ad2b6bb`.
|
||||
|
||||
- Master subsequently advanced to `125ddcabe5`. Its published Copilot v61 identity is preserved; the combined prompt is now v62. Generated profile checks and **59 Copilot installation tests passed**. Project bindings now enumerate every company agent that could run project work; the focused retained-target case passed with that restriction.
|
||||
- The previously failing login-cleanup and free-workspace-export scheduling cases both passed in isolated local runs against dedicated PostgreSQL test databases. This does not erase their CI failures or establish their cause.
|
||||
- Later review found that policy-audience expansion included generic resource selectors and denied otherwise permitted narrow writes. Actual-auth regressions now cover narrow create/update/reorder/delete and denial of broader targets. Deferred Google authorization also persists the addressed user's selected scopes with the saved connection. The browser restores that request only until newer consent is saved. All **six service-picker tests passed**; the new draft and newer-pending regressions failed before the UI fix. An initial stale-consent fixture used incomplete Drive scopes and was corrected to use the shared canonical selection.
|
||||
- The complete acceptance suite now passes **38 scenarios** against a fresh isolated PostgreSQL database, including both new review regressions. Provider setup remains fixture evidence, not live-provider qualification.
|
||||
@@ -180,7 +180,7 @@ Gaps: This individual operation has source review but no recorded HTTP verificat
|
||||
API: `present` — `POST /api/tools/oauth/{connectionId}/start`; JSON REST.
|
||||
Agent access: `available` — agent REST and default native `search_api` / `call_api`.
|
||||
Policy: `tools:manage_connections`; [shared management policy](../../doc/plans/2026-10-13-connection-api-parity.md#authorization-contract). Resolved resources and every affected target are checked; personal ownership remains the responsible user. Returns the saved setup without issuing agent-bound OAuth state; the human browser initiates provider consent.
|
||||
Evidence: `verified` — [authenticated integration fixtures](../../server/src/__tests__/connection-management.integration.test.ts) passed through production authentication and database persistence.
|
||||
Evidence: `verified` — [authenticated integration fixtures](../../server/src/__tests__/connection-management.integration.test.ts) passed through production authentication and database persistence, including saved Google service choices addressed to the responsible user. [Service-picker tests](../../ui/src/features/connections/GoogleWorkspaceServices.test.tsx) cover restoring only that user's request and retaining newer consent.
|
||||
Verification: `passed` — deterministic local fixture; no live provider qualification.
|
||||
Gaps: Provider fixtures do not qualify live provider setup or availability.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ToolConnection } from "@paperclipai/shared";
|
||||
import { googleWorkspaceSelectedScopes, type ToolConnection } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GoogleWorkspaceServicesDialog } from "./GoogleWorkspaceServices";
|
||||
|
||||
@@ -83,4 +83,27 @@ describe("Workspace service consent", () => {
|
||||
expect(onCancel).toHaveBeenCalledWith({});
|
||||
expect(onContinue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores only the addressed user's pending services on a saved draft", async () => {
|
||||
listGrants.mockResolvedValue({ currentUserId: "me", grants: [] });
|
||||
await render({ ...existing, status: "draft", config: { oauth: { pendingWorkspaceServiceRequests: {
|
||||
me: { requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"], requestedAt: "2026-10-26T10:00:00Z" },
|
||||
other: { requestedScopes: ["https://www.googleapis.com/auth/documents"], requestedAt: "2026-10-26T10:00:00Z" },
|
||||
} } } });
|
||||
await click("Continue");
|
||||
expect(listGrants).toHaveBeenCalledWith(existing.id);
|
||||
expect(onContinue).toHaveBeenCalledWith({ gmail: "read" });
|
||||
});
|
||||
|
||||
it.each([true, false])("uses pending consent only when newer than the saved grant (newer: %s)", async newer => {
|
||||
listGrants.mockResolvedValue({ currentUserId: "me", grants: [{
|
||||
kind: "user", subjectUserId: "me", updatedAt: "2026-10-26T10:00:00Z",
|
||||
providerTenant: { oauth: { requestedScopes: googleWorkspaceSelectedScopes({ drive: "write" }) } },
|
||||
}] });
|
||||
await render({ ...existing, config: { oauth: { pendingWorkspaceServiceRequests: {
|
||||
me: { requestedScopes: ["https://www.googleapis.com/auth/gmail.readonly"], requestedAt: newer ? "2026-10-26T11:00:00Z" : "2026-10-26T09:00:00Z" },
|
||||
} } } });
|
||||
await click("Continue");
|
||||
expect(onContinue).toHaveBeenCalledWith(newer ? { gmail: "read" } : { drive: "write" });
|
||||
});
|
||||
});
|
||||
@@ -44,8 +44,11 @@ type DialogProps = {
|
||||
|
||||
export function GoogleWorkspaceServicesDialog(props: DialogProps) {
|
||||
const existing = props.connection && props.connection.status !== "draft" ? props.connection : null;
|
||||
const grants = useQuery({ queryKey: ["workspace-service-consent", existing?.id], queryFn: () => toolsApi.listConnectionGrants(existing!.id), enabled: Boolean(existing) });
|
||||
if (existing && !grants.data) return <Dialog open onOpenChange={open => { if (!open) props.onCancel(); }}><DialogContent>
|
||||
const oauth = props.connection?.config?.oauth as { pendingWorkspaceServiceRequests?: Record<string, { requestedScopes?: unknown; requestedAt?: string }> } | undefined;
|
||||
const pendingRequests = oauth?.pendingWorkspaceServiceRequests;
|
||||
const loadConnection = existing ?? (pendingRequests && Object.keys(pendingRequests).length ? props.connection : null);
|
||||
const grants = useQuery({ queryKey: ["workspace-service-consent", loadConnection?.id], queryFn: () => toolsApi.listConnectionGrants(loadConnection!.id), enabled: Boolean(loadConnection) });
|
||||
if (loadConnection && !grants.data) return <Dialog open onOpenChange={open => { if (!open) props.onCancel(); }}><DialogContent>
|
||||
<DialogTitle>Connect Google Workspace</DialogTitle><DialogDescription>{grants.isError ? "Could not load your saved service access. Your connection has not changed." : "Loading your saved service access…"}</DialogDescription>
|
||||
<footer className="flex items-center justify-between gap-3"><Button variant="ghost" onClick={() => props.onCancel()}>Cancel</Button>{grants.isError && <Button onClick={() => void grants.refetch()}>Try again</Button>}</footer>
|
||||
</DialogContent></Dialog>;
|
||||
@@ -55,7 +58,12 @@ export function GoogleWorkspaceServicesDialog(props: DialogProps) {
|
||||
? grant.kind === "agent" && grant.subjectAgentId === (existing.config?.oauth as { connectorSubjectAgentId?: string } | undefined)?.connectorSubjectAgentId
|
||||
: grant.kind === "organization");
|
||||
const savedScopes = grant?.providerTenant?.oauth?.requestedScopes ?? grant?.providerTenant?.oauth?.scopes ?? [];
|
||||
return <ServicePicker {...props} initial={props.initial ?? (existing ? googleWorkspaceSelectionForScopes(savedScopes) : undefined)} />;
|
||||
const pending = grants.data?.currentUserId ? pendingRequests?.[grants.data.currentUserId] : undefined;
|
||||
const pendingTime = Date.parse(pending?.requestedAt ?? "");
|
||||
const grantTime = grant ? new Date(grant.updatedAt).getTime() : -Infinity;
|
||||
const pendingScopes = Number.isFinite(pendingTime) && pendingTime > grantTime && Array.isArray(pending?.requestedScopes) && pending.requestedScopes.every(scope => typeof scope === "string")
|
||||
? pending.requestedScopes as string[] : undefined;
|
||||
return <ServicePicker {...props} initial={props.initial ?? (pendingScopes ? googleWorkspaceSelectionForScopes(pendingScopes) : existing ? googleWorkspaceSelectionForScopes(savedScopes) : undefined)} />;
|
||||
}
|
||||
|
||||
function ServicePicker({ onContinue, onCancel, initial = GOOGLE_WORKSPACE_DEFAULT_SELECTION }: DialogProps) {
|
||||
|
||||
Reference in new issue
Block a user