From c22d9ecf5801a75c84da927788a5bce2092c56d9 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:22:05 +0000 Subject: [PATCH 01/31] feat(editor): move the admin directory onto TanStack Query (#7726) # Description of Changes Step 5 of the TanStack Query rollout, covering the admin People, Teams and Team details screens. Follows #7264, #7283, #7285. ## The problem Two separate ones, in the same three files. **Reads.** Each section fetched and held its own copy of the same resources: People read the roster and the team list, Teams read the team list plus the roster again when its add-member modal opened, Team details read all three. Cost scaled with how many screens you visited rather than with how much data exists. **Writes.** Thirteen handlers each did the same five things by hand: set a processing flag, call the service, toast the outcome, dig a message out of an axios error, and reload their own slice. Refreshing was a convention, not a mechanism, and one handler had already forgotten it. ## The fix Three shared query keys (`adminUsers`, `teams`, `teamDetails`), and one `useAdminMutation` helper that every write is declared against: ```ts const createTeam = useAdminMutation({ write: (name: string) => teamService.createTeam(name), invalidates: ["teams"], success: t("workspace.teams.createTeam.success"), errorFallback: t("workspace.teams.createTeam.error"), onDone: () => { setNewTeamName(""); setCreateModalOpened(false); }, }); ``` Each write names the slices it disturbs, which is the part that only works when reads and writes are designed together: `createTeam` invalidates the team list, while a membership move invalidates the list, both teams' detail rows and the roster, because it genuinely changes all three. Invalidation refetches only mounted queries, so this costs nothing extra. The blanket "invalidate everything" helper survives in exactly one role: child components (invite, password change, seat update) that write through their own services, where the affected scopes are not visible from the call site. ## Why it is better, measured Request counts come from one harness driving `teams -> team details -> back -> people`, run against the branch point and against this branch. The assertion is committed, so it cannot silently regress. | | Before | After | |---|---|---| | Requests | 7 | **3** | | `getTeams` | 4 | **1** | | `getUsers` | 2 | **1** | | `getTeamDetails` | 1 | 1 | | Committed renders | 17 | **15** | Three is one per distinct resource, the floor for that sequence. The four `getTeams` were the Teams table, Team details fetching the same list for its "move to team" dropdown, the explicit refresh on the back button, and People. Renders barely move, which is expected: this changes where data lives, not how often React draws. It is reported because a caching change can quietly cost renders, and this one does not. On the code itself, across the three sections: | | | |---|---| | Net lines | **-216** | | `useState`/`useEffect` removed | **11**, none added | | Duplicated `isAxiosError` blocks | 13 to **1** | | `setProcessing` calls | 19 to **0** | `isAxiosError` is no longer imported by any of the three files. ## Bug fixed `disableMfaByAdmin` showed a success toast and never refreshed. The menu item renders only when `user.mfaEnabled` is true, so an admin disabled MFA, was told it worked, and watched the option stay on screen until a manual reload. It is covered by a test that fails if the invalidation is removed. ## Behaviour worth checking in review - A write no longer blocks its handler before closing the modal. The dialog closes when the write succeeds and the table updates when the refetch lands, rather than the button spinning through both. - Modal submit buttons now track their own mutation rather than one shared flag. Team details still derives a single busy flag, now from its five mutations rather than a `useState`, so its row actions disable together as before. - The per-handler `console.error` is kept, once, in the shared error path. ## Testing Five tests, each verified by breaking the implementation and confirming that one test, and only that one, fails: | Mutation | Caught by | |---|---| | Drop the shared stale window (`staleTime: 0`) | request-count test | | Make invalidation a no-op | write-visibility test | | Ignore the login-enabled gate | login-disabled test | | Stop invalidating after the MFA write | MFA-refresh test | | Fall back to the generic error message | server-message test | The write tests drive the real flows through their modals and menus rather than calling hooks directly. `task frontend:check` passes typecheck, lint and oxfmt, and 2383 of 2385 editor tests. The two failures, `workbenchSession.test.ts` and `notificationActions.test.tsx`, are untouched here and fail identically with this branch's changes reverted. ## Scope The three services keep their current shape; nothing outside these three sections and the new hook module changes. Child modals that write through their own services still refresh via the blanket helper, and converting those is separate work. --- frontend/editor/src/core/query/keys.ts | 4 + .../config/configSections/PeopleSection.tsx | 477 +++++++----------- .../configSections/TeamDetailsSection.tsx | 393 ++++++--------- .../config/configSections/TeamsSection.tsx | 216 +++----- .../config/configSections/adminReads.test.tsx | 295 +++++++++++ .../proprietary/hooks/useAdminDirectory.ts | 151 ++++++ 6 files changed, 885 insertions(+), 651 deletions(-) create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/adminReads.test.tsx create mode 100644 frontend/editor/src/proprietary/hooks/useAdminDirectory.ts diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index 5354b56b63..d95f674011 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -1,5 +1,7 @@ /** Editor query keys: ["editor", , ...params]. */ export const qk = { + /** The admin directory payload: a different endpoint and shape to qk.users(). */ + adminUsers: () => ["editor", "adminUsers"] as const, appConfig: () => ["editor", "appConfig"] as const, endpointsAvailability: () => ["editor", "endpointsAvailability"] as const, endpointEnabled: (endpoint: string) => @@ -9,5 +11,7 @@ export const qk = { /** Keyed on the asking identity: two users must never share one answer. */ portalAccess: (userId: string | null) => ["editor", "portalAccess", userId] as const, + teamDetails: (teamId: number) => ["editor", "teamDetails", teamId] as const, + teams: () => ["editor", "teams"] as const, users: () => ["editor", "users"] as const, } as const; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index d955cb341b..6b14d2b421 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -1,5 +1,4 @@ -import { useState, useEffect } from "react"; -import { isAxiosError } from "axios"; +import { useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Stack, @@ -20,14 +19,12 @@ import { import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { alert } from "@app/components/toast"; import { userManagementService, User, } from "@app/services/userManagementService"; -import { teamService, Team } from "@app/services/teamService"; +import { type Team } from "@app/services/teamService"; import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import { useAppConfig } from "@app/contexts/AppConfigContext"; import InviteMembersModal from "@app/components/shared/InviteMembersModal"; import { useLoginRequired } from "@app/hooks/useLoginRequired"; import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; @@ -36,17 +33,109 @@ import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton"; import { useLicense } from "@app/contexts/LicenseContext"; import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal"; import { useAuth } from "@app/auth/UseSession"; +import { + useAdminUsers, + useTeams, + useAdminMutation, + useInvalidateAdminDirectory, +} from "@app/hooks/useAdminDirectory"; + +const EXAMPLE_USERS: User[] = [ + { + id: 1, + username: "admin", + email: "admin@example.com", + enabled: true, + roleName: "ROLE_ADMIN", + rolesAsString: "ROLE_ADMIN", + authenticationType: "password", + isActive: true, + lastRequest: Date.now(), + team: { id: 1, name: "Engineering" }, + }, + { + id: 2, + username: "john.doe", + email: "john.doe@example.com", + enabled: true, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "password", + isActive: false, + lastRequest: Date.now() - 86400000, + team: { id: 1, name: "Engineering" }, + }, + { + id: 3, + username: "jane.smith", + email: "jane.smith@example.com", + enabled: true, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "oauth", + isActive: true, + lastRequest: Date.now(), + team: { id: 2, name: "Marketing" }, + }, + { + id: 4, + username: "bob.wilson", + email: "bob.wilson@example.com", + enabled: false, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "password", + isActive: false, + lastRequest: Date.now() - 604800000, + team: undefined, + }, +]; + +const EXAMPLE_TEAMS: Team[] = [ + { id: 1, name: "Engineering", userCount: 3 }, + { id: 2, name: "Marketing", userCount: 2 }, +]; + +const EXAMPLE_LICENSE = { + maxAllowedUsers: 10, + availableSlots: 6, + grandfatheredUserCount: 0, + licenseMaxUsers: 5, + premiumEnabled: true, + totalUsers: 4, +}; export default function PeopleSection() { const { t } = useTranslation(); - const { config } = useAppConfig(); const { loginEnabled } = useLoginRequired(); const { user: currentUser } = useAuth(); const navigate = useNavigate(); const { licenseInfo: globalLicenseInfo } = useLicense(); - const [users, setUsers] = useState([]); - const [teams, setTeams] = useState([]); - const [loading, setLoading] = useState(true); + const admin = useAdminUsers(loginEnabled); + const { data: fetchedTeams } = useTeams(loginEnabled); + const refreshDirectory = useInvalidateAdminDirectory(); + + // Session and MFA state arrive alongside the roster, keyed by username. + const fetchedUsers = useMemo(() => { + if (!admin.data) return []; + return admin.data.users.map((user) => ({ + ...user, + isActive: admin.data.userSessions[user.username] || false, + lastRequest: admin.data.userLastRequest[user.username] || undefined, + mfaEnabled: + ( + admin.data.userSettings?.[user.username] as + | Record + | undefined + )?.mfaEnabled === "true", + })); + }, [admin.data]); + + // Login off means the endpoints are not callable, so the table shows a + // worked example instead of an empty state. + const users = loginEnabled ? fetchedUsers : EXAMPLE_USERS; + const teams = loginEnabled ? (fetchedTeams ?? []) : EXAMPLE_TEAMS; + const loading = loginEnabled && admin.isPending; const [searchQuery, setSearchQuery] = useState(""); const [inviteModalOpened, setInviteModalOpened] = useState(false); const [editUserModalOpened, setEditUserModalOpened] = useState(false); @@ -54,19 +143,20 @@ export default function PeopleSection() { useState(false); const [passwordUser, setPasswordUser] = useState(null); const [selectedUser, setSelectedUser] = useState(null); - const [processing, setProcessing] = useState(false); - const [mailEnabled, setMailEnabled] = useState(false); - const [lockedUsers, setLockedUsers] = useState([]); - - // License information - const [licenseInfo, setLicenseInfo] = useState<{ - maxAllowedUsers: number; - availableSlots: number; - grandfatheredUserCount: number; - licenseMaxUsers: number; - premiumEnabled: boolean; - totalUsers: number; - } | null>(null); + const mailEnabled = loginEnabled ? (admin.data?.mailEnabled ?? false) : false; + const lockedUsers = loginEnabled ? (admin.data?.lockedUsers ?? []) : []; + const licenseInfo = loginEnabled + ? admin.data + ? { + maxAllowedUsers: admin.data.maxAllowedUsers, + availableSlots: admin.data.availableSlots, + grandfatheredUserCount: admin.data.grandfatheredUserCount, + licenseMaxUsers: admin.data.licenseMaxUsers, + premiumEnabled: admin.data.premiumEnabled, + totalUsers: admin.data.totalUsers, + } + : null + : EXAMPLE_LICENSE; const hasNoSlots = licenseInfo ? licenseInfo.availableSlots === 0 : false; const handleAddMembersClick = () => { if (!loginEnabled) { @@ -115,253 +205,103 @@ export default function PeopleSection() { teamId: undefined as number | undefined, }); - useEffect(() => { - fetchData(); - }, []); + const updateUserRole = useAdminMutation({ + write: (payload: { username: string; role: string; teamId?: number }) => + userManagementService.updateUserRole(payload), + // A role edit can also move the user, which changes both teams' counts. + invalidates: ["users", "teams"], + success: t("workspace.people.editMember.success"), + errorFallback: t("workspace.people.editMember.error"), + onDone: () => closeEditModal(), + }); - useEffect(() => { - if (config) { - console.log( - "[PeopleSection] Email invites enabled:", - config.enableEmailInvites, - ); - } - }, [config]); + const toggleEnabled = useAdminMutation({ + write: (user: User) => + userManagementService.toggleUserEnabled(user.username, !user.enabled), + invalidates: ["users"], + success: t("workspace.people.toggleEnabled.success"), + errorFallback: t("workspace.people.toggleEnabled.error"), + }); - const fetchData = async () => { - try { - setLoading(true); + const deleteUser = useAdminMutation({ + write: (username: string) => userManagementService.deleteUser(username), + invalidates: ["users", "teams"], + success: t( + "workspace.people.deleteUserSuccess", + "User deleted successfully", + ), + errorFallback: t( + "workspace.people.deleteUserError", + "Failed to delete user", + ), + }); - if (loginEnabled) { - const [adminData, teamsData] = await Promise.all([ - userManagementService.getUsers(), - teamService.getTeams(), - ]); + const unlockUser = useAdminMutation({ + write: (username: string) => userManagementService.unlockUser(username), + invalidates: ["users"], + success: t( + "workspace.people.unlockUserSuccess", + "User account unlocked successfully", + ), + errorFallback: t( + "workspace.people.unlockUserError", + "Failed to unlock user account", + ), + }); - // Enrich users with session data - const enrichedUsers = adminData.users.map((user) => ({ - ...user, - isActive: adminData.userSessions[user.username] || false, - lastRequest: adminData.userLastRequest[user.username] || undefined, - mfaEnabled: - ( - adminData.userSettings?.[user.username] as - | Record - | undefined - )?.mfaEnabled === "true", - })); + const disableMfa = useAdminMutation({ + write: (username: string) => + userManagementService.disableMfaByAdmin(username), + invalidates: ["users"], + success: t( + "workspace.people.mfa.adminDisableSuccess", + "MFA disabled successfully for user", + ), + errorFallback: t( + "workspace.people.mfa.adminDisableError", + "Failed to disable MFA for user", + ), + }); - setUsers(enrichedUsers); - setTeams(teamsData); - - // Store license information - setLicenseInfo({ - maxAllowedUsers: adminData.maxAllowedUsers, - availableSlots: adminData.availableSlots, - grandfatheredUserCount: adminData.grandfatheredUserCount, - licenseMaxUsers: adminData.licenseMaxUsers, - premiumEnabled: adminData.premiumEnabled, - totalUsers: adminData.totalUsers, - }); - setMailEnabled(adminData.mailEnabled); - setLockedUsers(adminData.lockedUsers || []); - } else { - // Provide example data when login is disabled - const exampleUsers: User[] = [ - { - id: 1, - username: "admin", - email: "admin@example.com", - enabled: true, - roleName: "ROLE_ADMIN", - rolesAsString: "ROLE_ADMIN", - authenticationType: "password", - isActive: true, - lastRequest: Date.now(), - team: { id: 1, name: "Engineering" }, - }, - { - id: 2, - username: "john.doe", - email: "john.doe@example.com", - enabled: true, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "password", - isActive: false, - lastRequest: Date.now() - 86400000, - team: { id: 1, name: "Engineering" }, - }, - { - id: 3, - username: "jane.smith", - email: "jane.smith@example.com", - enabled: true, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "oauth", - isActive: true, - lastRequest: Date.now(), - team: { id: 2, name: "Marketing" }, - }, - { - id: 4, - username: "bob.wilson", - email: "bob.wilson@example.com", - enabled: false, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "password", - isActive: false, - lastRequest: Date.now() - 604800000, - team: undefined, - }, - ]; - - const exampleTeams: Team[] = [ - { id: 1, name: "Engineering", userCount: 3 }, - { id: 2, name: "Marketing", userCount: 2 }, - ]; - - setUsers(exampleUsers); - setTeams(exampleTeams); - setMailEnabled(false); - setLockedUsers([]); - - // Example license information - setLicenseInfo({ - maxAllowedUsers: 10, - availableSlots: 6, - grandfatheredUserCount: 0, - licenseMaxUsers: 5, - premiumEnabled: true, - totalUsers: 4, - }); - } - } catch (error) { - console.error("[PeopleSection] Failed to fetch people data:", error); - alert({ alertType: "error", title: "Failed to load people data" }); - } finally { - setLoading(false); - } - }; - - const handleUpdateUserRole = async () => { + const handleUpdateUserRole = () => { if (!selectedUser) return; - - try { - setProcessing(true); - await userManagementService.updateUserRole({ - username: selectedUser.username, - role: editForm.role, - teamId: editForm.teamId, - }); - alert({ - alertType: "success", - title: t("workspace.people.editMember.success"), - }); - closeEditModal(); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to update user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.editMember.error"); - alert({ alertType: "error", title: errorMessage }); - } finally { - setProcessing(false); - } + updateUserRole.mutate({ + username: selectedUser.username, + role: editForm.role, + teamId: editForm.teamId, + }); }; - const handleToggleEnabled = async (user: User) => { - try { - await userManagementService.toggleUserEnabled( - user.username, - !user.enabled, - ); - alert({ - alertType: "success", - title: t("workspace.people.toggleEnabled.success"), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to toggle user status:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.toggleEnabled.error"); - alert({ alertType: "error", title: errorMessage }); - } + const handleToggleEnabled = (user: User) => { + toggleEnabled.mutate(user); }; - const handleDeleteUser = async (user: User) => { + const handleDeleteUser = (user: User) => { const confirmMessage = t( "workspace.people.confirmDelete", "Are you sure you want to delete this user? This action cannot be undone.", ); - if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { - return; - } + if ( + !window.confirm(`${confirmMessage} - try { - await userManagementService.deleteUser(user.username); - alert({ - alertType: "success", - title: t( - "workspace.people.deleteUserSuccess", - "User deleted successfully", - ), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to delete user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.deleteUserError", "Failed to delete user"); - alert({ alertType: "error", title: errorMessage }); - } +User: ${user.username}`) + ) + return; + deleteUser.mutate(user.username); }; - const handleUnlockUser = async (user: User) => { + const handleUnlockUser = (user: User) => { const confirmMessage = t( "workspace.people.confirmUnlock", "Are you sure you want to unlock this user account?", ); - if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { - return; - } + if ( + !window.confirm(`${confirmMessage} - try { - await userManagementService.unlockUser(user.username); - alert({ - alertType: "success", - title: t( - "workspace.people.unlockUserSuccess", - "User account unlocked successfully", - ), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to unlock user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t( - "workspace.people.unlockUserError", - "Failed to unlock user account", - ); - alert({ alertType: "error", title: errorMessage }); - } +User: ${user.username}`) + ) + return; + unlockUser.mutate(user.username); }; const openEditModal = (user: User) => { @@ -549,7 +489,7 @@ export default function PeopleSection() { - + )} @@ -891,40 +831,7 @@ export default function PeopleSection() { height="1rem" /> } - onClick={async () => { - try { - await userManagementService.disableMfaByAdmin( - user.username, - ); - alert({ - alertType: "success", - title: t( - "workspace.people.mfa.adminDisableSuccess", - "MFA disabled successfully for user", - ), - }); - } catch (error: unknown) { - console.error( - "[PeopleSection] Failed to disable MFA for user:", - error, - ); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error - ? error.message - : undefined) || - t( - "workspace.people.mfa.adminDisableError", - "Failed to disable MFA for user", - ); - alert({ - alertType: "error", - title: errorMessage, - }); - } - }} + onClick={() => disableMfa.mutate(user.username)} disabled={!loginEnabled} > {t( @@ -968,14 +875,14 @@ export default function PeopleSection() { setInviteModalOpened(false)} - onSuccess={fetchData} + onSuccess={refreshDirectory} /> @@ -1075,7 +982,7 @@ export default function PeopleSection() { /> @@ -186,7 +188,7 @@ export function FormSaveBar({ loading={saving} disabled={applying || policyEnforcing} onClick={handleDownload} - style={{ flex: 1 }} + style={{ flex: "1 1 10rem", minWidth: 0 }} > {t("viewer.formBar.download", "Download PDF")} From 8c00fffe185d027d022befac6f8d10216fab7472 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:04:06 +0200 Subject: [PATCH 09/31] refactor(api): replace com.fasterxml.jackson with tools.jackson (Jackson 2 to Jackson 3 namespace.) (#7444) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../cluster/valkey/ValkeyJobStore.java | 14 +++--- .../storage/converter/JsonMapConverter.java | 16 +++---- .../controller/SigningSessionController.java | 4 +- .../workflow/util/WorkflowMapper.java | 4 +- .../ai/controller/AiCreateController.java | 12 ++--- .../AiCreateInternalController.java | 12 ++--- .../service/StripeUsageReportingService.java | 2 +- .../saas/legal/LegalDocumentRegistry.java | 44 +++++++++---------- .../payg/entitlement/EntitlementGuard.java | 2 +- .../api/ProcurementController.java | 2 +- .../procurement/legal/AgreementAssembler.java | 2 +- .../KeygenEnterpriseLicenseService.java | 4 +- .../service/ProcurementService.java | 6 +-- .../entitlement/EntitlementGuardTest.java | 4 +- 14 files changed, 63 insertions(+), 65 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java index 8e93871859..750abea4fc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java @@ -17,16 +17,16 @@ import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.cluster.JobStore; import stirling.software.common.cluster.JobStoreEntry; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + /** * Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId. * @@ -265,7 +265,7 @@ public class ValkeyJobStore implements JobStore { } try { return MAPPER.readValue(v.toString(), MAP_STRING); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn( "JobStore {} field 'resultMeta' is not valid JSON '{}' - treating as empty", key, @@ -277,7 +277,7 @@ public class ValkeyJobStore implements JobStore { private static String writeJson(Object value) { try { return MAPPER.writeValueAsString(value); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new IllegalStateException("Failed to JSON-serialize JobStore field", e); } } @@ -286,7 +286,7 @@ public class ValkeyJobStore implements JobStore { try { List parsed = MAPPER.readValue(json, LIST_STRING); return parsed == null ? new ArrayList<>() : parsed; - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn( "JobStore {} field 'fileIds' is not valid JSON '{}' - treating as empty", key, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java index 1c9f7ab765..5576ebb181 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java @@ -3,16 +3,16 @@ package stirling.software.proprietary.storage.converter; import java.util.HashMap; import java.util.Map; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; import lombok.extern.slf4j.Slf4j; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + /** * JPA AttributeConverter for storing Map as JSON in database columns. * @@ -33,7 +33,7 @@ public class JsonMapConverter implements AttributeConverter, try { return objectMapper.writeValueAsString(attribute); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.error("Failed to convert map to JSON", e); throw new RuntimeException("Failed to convert map to JSON", e); } @@ -48,7 +48,7 @@ public class JsonMapConverter implements AttributeConverter, try { // Try normal parsing first return objectMapper.readValue(dbData, new TypeReference>() {}); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { // Fallback: try double-parsing for legacy double-encoded data // This handles data that was stored as JSON strings instead of JSON objects log.debug("Attempting double-decode fallback for legacy metadata format"); @@ -69,7 +69,7 @@ public class JsonMapConverter implements AttributeConverter, return objectMapper.readValue( node.asText(), new TypeReference>() {}); } - } catch (JsonProcessingException e2) { + } catch (JacksonException e2) { log.error("Failed to parse metadata even with double-decode fallback", e2); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index 4e95707217..73867776e9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -20,8 +20,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import com.fasterxml.jackson.databind.ObjectMapper; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -44,6 +42,8 @@ import stirling.software.proprietary.workflow.service.CertificateSubmissionValid import stirling.software.proprietary.workflow.service.SigningFinalizationService; import stirling.software.proprietary.workflow.service.WorkflowSessionService; +import tools.jackson.databind.ObjectMapper; + @Slf4j @RestController @RequestMapping("/api/v1/security") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java index b2f53c2824..8d61ae032c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java @@ -4,14 +4,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import com.fasterxml.jackson.databind.ObjectMapper; - import stirling.software.proprietary.workflow.dto.ParticipantResponse; import stirling.software.proprietary.workflow.dto.WetSignatureMetadata; import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse; import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; +import tools.jackson.databind.ObjectMapper; + /** * Utility class for mapping workflow entities to DTOs. Centralizes conversion logic for consistent * API responses. diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java index 28bbfbdea0..77a87a27bb 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java @@ -26,8 +26,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; @@ -167,7 +167,7 @@ public class AiCreateController { if (request.constraints() != null) { try { constraintsPayload = objectMapper.writeValueAsString(request.constraints()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid constraints payload", exc); } @@ -202,7 +202,7 @@ public class AiCreateController { String payload; try { payload = objectMapper.writeValueAsString(request.draftSections()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc); } @@ -392,7 +392,7 @@ public class AiCreateController { objectMapper .getTypeFactory() .constructCollectionType(List.class, DraftSection.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse draft sections payload", exc); return null; } @@ -408,7 +408,7 @@ public class AiCreateController { objectMapper .getTypeFactory() .constructMapType(Map.class, String.class, Object.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse outline constraints payload", exc); return null; } diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java index 60c8dc4615..04b8302e7e 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java @@ -14,8 +14,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; @@ -61,7 +61,7 @@ public class AiCreateInternalController { try { outlineConstraintsPayload = objectMapper.writeValueAsString(request.outlineConstraints()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid outline constraints payload", exc); } @@ -70,7 +70,7 @@ public class AiCreateInternalController { if (request.draftSections() != null) { try { draftSectionsPayload = objectMapper.writeValueAsString(request.draftSections()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc); } @@ -136,7 +136,7 @@ public class AiCreateInternalController { .getTypeFactory() .constructCollectionType( List.class, AiCreateController.DraftSection.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse draft sections payload", exc); return null; } @@ -152,7 +152,7 @@ public class AiCreateInternalController { objectMapper .getTypeFactory() .constructMapType(Map.class, String.class, Object.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse outline constraints payload", exc); return null; } diff --git a/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java b/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java index d9445483a3..ae5d3d943a 100644 --- a/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java +++ b/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java @@ -12,7 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java index ffbe030863..48dd6a1c3b 100644 --- a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java @@ -13,8 +13,8 @@ import java.util.regex.Pattern; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -56,28 +56,26 @@ public class LegalDocumentRegistry { subprocessorUrl = root.path("subprocessorUrl").asText(""); eulaUrl = root.path("eulaUrl").asText(""); JsonNode docs = root.path("documents"); - docs.fieldNames() - .forEachRemaining( - id -> { - JsonNode d = docs.get(id); - List parts = - objectMapper.convertValue( - d.path("parts"), - objectMapper - .getTypeFactory() - .constructCollectionType( - List.class, String.class)); - documents.put( + docs.forEachEntry( + (id, d) -> { + List parts = + objectMapper.convertValue( + d.path("parts"), + objectMapper + .getTypeFactory() + .constructCollectionType( + List.class, String.class)); + documents.put( + id, + new LegalDocumentMeta( id, - new LegalDocumentMeta( - id, - d.path("label").asText(id), - d.path("displayName").asText(id), - d.path("version").asText("0"), - d.path("effectiveDate").asText(""), - d.path("status").asText("draft"), - parts == null ? List.of() : parts)); - }); + d.path("label").asText(id), + d.path("displayName").asText(id), + d.path("version").asText("0"), + d.path("effectiveDate").asText(""), + d.path("status").asText("draft"), + parts == null ? List.of() : parts)); + }); log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java index ae8474fa42..441ebf1220 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java @@ -20,7 +20,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index 1c495b3afd..c08778bdbc 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java index c641f2e596..e75f3cbb32 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java @@ -10,7 +10,7 @@ import java.util.Map; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java index a11ad05755..a1c09a9cb9 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java @@ -16,8 +16,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index a739e538b2..26bfb733dd 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -10,8 +10,8 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -707,7 +707,7 @@ public class ProcurementService { private String writeLineItems(QuoteBreakdown breakdown) { try { return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems()); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn("[procurement] failed to serialise line items", e); return "[]"; } diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java index 0a77de3e7e..a9ca7642a6 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java @@ -28,8 +28,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.method.HandlerMethod; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; From 74be5bf0ad0bbf592b8944cb2a8677f0c4055ff0 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:04:35 +0200 Subject: [PATCH 10/31] fix(forms): Fix checkbox export values and wide dropdown options (#7288) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- frontend/editor/src/core/tools/formFill/FieldInput.tsx | 7 +++++-- .../editor/src/core/tools/formFill/FormFieldOverlay.tsx | 9 ++++++--- .../core/tools/formFill/providers/PdfiumFormProvider.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/editor/src/core/tools/formFill/FieldInput.tsx b/frontend/editor/src/core/tools/formFill/FieldInput.tsx index 9662298dc4..9a8943ea47 100644 --- a/frontend/editor/src/core/tools/formFill/FieldInput.tsx +++ b/frontend/editor/src/core/tools/formFill/FieldInput.tsx @@ -68,8 +68,11 @@ function FieldInputInner({ ); case "checkbox": { - const isChecked = !!value && value !== "Off"; - const onValue = (field.widgets && field.widgets[0]?.exportValue) || "Yes"; + const exportVal = field.widgets && field.widgets[0]?.exportValue; + const isChecked = exportVal + ? value === exportVal || value === "Yes" + : !!value && value !== "Off"; + const onValue = exportVal || "Yes"; return ( Date: Sun, 30 Aug 2026 00:11:41 +0200 Subject: [PATCH 11/31] chore(crop): Remove invalid crop area message and related validation logic (#7160) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../public/locales/ar-AR/translation.toml | 1 - .../public/locales/az-AZ/translation.toml | 1 - .../public/locales/bg-BG/translation.toml | 1 - .../public/locales/bo-CN/translation.toml | 1 - .../public/locales/ca-CA/translation.toml | 1 - .../public/locales/cs-CZ/translation.toml | 1 - .../public/locales/da-DK/translation.toml | 1 - .../public/locales/de-DE/translation.toml | 1 - .../public/locales/el-GR/translation.toml | 1 - .../public/locales/en-GB/translation.toml | 1 - .../public/locales/en-US/translation.toml | 1 - .../public/locales/es-ES/translation.toml | 1 - .../public/locales/eu-ES/translation.toml | 1 - .../public/locales/fa-IR/translation.toml | 1 - .../public/locales/fr-FR/translation.toml | 1 - .../public/locales/ga-IE/translation.toml | 1 - .../public/locales/hi-IN/translation.toml | 1 - .../public/locales/hr-HR/translation.toml | 1 - .../public/locales/hu-HU/translation.toml | 1 - .../public/locales/id-ID/translation.toml | 1 - .../public/locales/it-IT/translation.toml | 1 - .../public/locales/ja-JP/translation.toml | 1 - .../public/locales/ko-KR/translation.toml | 1 - .../public/locales/ml-ML/translation.toml | 1 - .../public/locales/nl-NL/translation.toml | 1 - .../public/locales/no-NB/translation.toml | 1 - .../public/locales/pl-PL/translation.toml | 1 - .../public/locales/pt-BR/translation.toml | 1 - .../public/locales/pt-PT/translation.toml | 1 - .../public/locales/ro-RO/translation.toml | 1 - .../public/locales/ru-RU/translation.toml | 1 - .../public/locales/sk-SK/translation.toml | 1 - .../public/locales/sl-SI/translation.toml | 1 - .../locales/sr-LATN-RS/translation.toml | 1 - .../public/locales/sv-SE/translation.toml | 1 - .../public/locales/th-TH/translation.toml | 1 - .../public/locales/tr-TR/translation.toml | 1 - .../public/locales/uk-UA/translation.toml | 1 - .../public/locales/vi-VN/translation.toml | 1 - .../public/locales/zh-BO/translation.toml | 1 - .../public/locales/zh-CN/translation.toml | 1 - .../public/locales/zh-TW/translation.toml | 1 - .../components/tools/crop/CropSettings.tsx | 23 +---------- .../hooks/tools/crop/useCropParameters.ts | 39 ++++++++----------- .../editor/src/core/utils/cropCoordinates.ts | 16 +++++++- 45 files changed, 32 insertions(+), 88 deletions(-) diff --git a/frontend/editor/public/locales/ar-AR/translation.toml b/frontend/editor/public/locales/ar-AR/translation.toml index 4c4d45e2b4..4180d77811 100644 --- a/frontend/editor/public/locales/ar-AR/translation.toml +++ b/frontend/editor/public/locales/ar-AR/translation.toml @@ -3526,7 +3526,6 @@ label = "إحداثي Y" [crop.error] failed = "فشل قصّ PDF" -invalidArea = "منطقة القص تتجاوز حدود PDF" [crop.preview] title = "معاينة منطقة القص" diff --git a/frontend/editor/public/locales/az-AZ/translation.toml b/frontend/editor/public/locales/az-AZ/translation.toml index f73930133e..0033be05d5 100644 --- a/frontend/editor/public/locales/az-AZ/translation.toml +++ b/frontend/editor/public/locales/az-AZ/translation.toml @@ -3526,7 +3526,6 @@ label = "Y mövqeyi" [crop.error] failed = "PDF-i kəsmək alınmadı" -invalidArea = "Kəsmə sahəsi PDF sərhədlərini aşır" [crop.preview] title = "Kəsmə sahəsinin seçimi" diff --git a/frontend/editor/public/locales/bg-BG/translation.toml b/frontend/editor/public/locales/bg-BG/translation.toml index dc6ef92e39..07cf5a73d3 100644 --- a/frontend/editor/public/locales/bg-BG/translation.toml +++ b/frontend/editor/public/locales/bg-BG/translation.toml @@ -3526,7 +3526,6 @@ label = "Y позиция" [crop.error] failed = "Неуспешно изрязване на PDF" -invalidArea = "Областта за изрязване излиза извън границите на PDF" [crop.preview] title = "Избор на област за изрязване" diff --git a/frontend/editor/public/locales/bo-CN/translation.toml b/frontend/editor/public/locales/bo-CN/translation.toml index 86d0b4f10d..21193f5f2d 100644 --- a/frontend/editor/public/locales/bo-CN/translation.toml +++ b/frontend/editor/public/locales/bo-CN/translation.toml @@ -3526,7 +3526,6 @@ label = "Yཡི་གནས་བབ།" [crop.error] failed = "སོན་བཟང་མ་འདང་བ། PDF" -invalidArea = "སོན་འདེབས་རྒྱ་ཁྱོན་དེ་PDFམཚམས་ཐིག་ལས་བརྒལ་ཡོད།" [crop.preview] title = "སོན་བཟང་ཁུལ་འདེམས་པ།" diff --git a/frontend/editor/public/locales/ca-CA/translation.toml b/frontend/editor/public/locales/ca-CA/translation.toml index 498da94436..c22244ac98 100644 --- a/frontend/editor/public/locales/ca-CA/translation.toml +++ b/frontend/editor/public/locales/ca-CA/translation.toml @@ -3526,7 +3526,6 @@ label = "Posició Y" [crop.error] failed = "No s'ha pogut retallar el PDF" -invalidArea = "L'àrea de retall s'estén més enllà dels límits del PDF" [crop.preview] title = "Selecció de l'àrea de retall" diff --git a/frontend/editor/public/locales/cs-CZ/translation.toml b/frontend/editor/public/locales/cs-CZ/translation.toml index 7b234329c7..f2161674f4 100644 --- a/frontend/editor/public/locales/cs-CZ/translation.toml +++ b/frontend/editor/public/locales/cs-CZ/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozice Y" [crop.error] failed = "Oříznutí PDF se nezdařilo" -invalidArea = "Oblast ořezu přesahuje hranice PDF" [crop.preview] title = "Výběr oblasti ořezu" diff --git a/frontend/editor/public/locales/da-DK/translation.toml b/frontend/editor/public/locales/da-DK/translation.toml index f4c068da92..a0ddd7f4d9 100644 --- a/frontend/editor/public/locales/da-DK/translation.toml +++ b/frontend/editor/public/locales/da-DK/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-position" [crop.error] failed = "Kunne ikke beskære PDF" -invalidArea = "Beskæringsområdet strækker sig ud over PDF'ens grænser" [crop.preview] title = "Valg af beskæringsområde" diff --git a/frontend/editor/public/locales/de-DE/translation.toml b/frontend/editor/public/locales/de-DE/translation.toml index 7c1bc79a8c..81c88d7599 100644 --- a/frontend/editor/public/locales/de-DE/translation.toml +++ b/frontend/editor/public/locales/de-DE/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-Position" [crop.error] failed = "PDF zuschneiden fehlgeschlagen" -invalidArea = "Zuschneidebereich überschreitet die PDF-Grenzen" [crop.preview] title = "Zuschneidebereich-Auswahl" diff --git a/frontend/editor/public/locales/el-GR/translation.toml b/frontend/editor/public/locales/el-GR/translation.toml index 5269791aec..57ebd2eb6b 100644 --- a/frontend/editor/public/locales/el-GR/translation.toml +++ b/frontend/editor/public/locales/el-GR/translation.toml @@ -3526,7 +3526,6 @@ label = "Θέση Y" [crop.error] failed = "Αποτυχία περικοπής του PDF" -invalidArea = "Η περιοχή περικοπής εκτείνεται πέρα από τα όρια του PDF" [crop.preview] title = "Επιλογή περιοχής περικοπής" diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 8fdd452928..d2b2aa38e3 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3526,7 +3526,6 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" -invalidArea = "Crop area extends beyond PDF boundaries" [crop.preview] title = "Crop Area Selection" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 378157818c..baee16cd02 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3846,7 +3846,6 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" -invalidArea = "Crop area extends beyond PDF boundaries" [crop.preview] title = "Crop Area Selection" diff --git a/frontend/editor/public/locales/es-ES/translation.toml b/frontend/editor/public/locales/es-ES/translation.toml index 73aeb53429..5fffd6eae8 100644 --- a/frontend/editor/public/locales/es-ES/translation.toml +++ b/frontend/editor/public/locales/es-ES/translation.toml @@ -3526,7 +3526,6 @@ label = "Posición Y" [crop.error] failed = "Error al recortar PDF" -invalidArea = "El área de recorte se extiende más allá de los límites del PDF" [crop.preview] title = "Selección de Área de Recorte" diff --git a/frontend/editor/public/locales/eu-ES/translation.toml b/frontend/editor/public/locales/eu-ES/translation.toml index b5a2388e67..018bfbabc5 100644 --- a/frontend/editor/public/locales/eu-ES/translation.toml +++ b/frontend/editor/public/locales/eu-ES/translation.toml @@ -3526,7 +3526,6 @@ label = "Y posizioa" [crop.error] failed = "Huts egin du PDFa mozteak" -invalidArea = "Mozketa-area PDFaren mugak baino harago doa" [crop.preview] title = "Mozketa-arearen hautapena" diff --git a/frontend/editor/public/locales/fa-IR/translation.toml b/frontend/editor/public/locales/fa-IR/translation.toml index 14a40c4694..000d039dfa 100644 --- a/frontend/editor/public/locales/fa-IR/translation.toml +++ b/frontend/editor/public/locales/fa-IR/translation.toml @@ -3526,7 +3526,6 @@ label = "موقعیت Y" [crop.error] failed = "برش PDF ناموفق بود" -invalidArea = "ناحیه برش از مرزهای PDF فراتر رفته است" [crop.preview] title = "انتخاب ناحیه برش" diff --git a/frontend/editor/public/locales/fr-FR/translation.toml b/frontend/editor/public/locales/fr-FR/translation.toml index 5550076052..e7d8a2a9ab 100644 --- a/frontend/editor/public/locales/fr-FR/translation.toml +++ b/frontend/editor/public/locales/fr-FR/translation.toml @@ -3526,7 +3526,6 @@ label = "Position Y" [crop.error] failed = "Échec du recadrage du PDF" -invalidArea = "La zone de recadrage dépasse les limites du PDF" [crop.preview] title = "Sélection de la zone de recadrage" diff --git a/frontend/editor/public/locales/ga-IE/translation.toml b/frontend/editor/public/locales/ga-IE/translation.toml index 4d3ad56168..1507ae92df 100644 --- a/frontend/editor/public/locales/ga-IE/translation.toml +++ b/frontend/editor/public/locales/ga-IE/translation.toml @@ -3526,7 +3526,6 @@ label = "Suíomh Y" [crop.error] failed = "Theip ar an PDF a bhearradh" -invalidArea = "Téann an limistéar bearrtha thar theorainneacha an PDF" [crop.preview] title = "Roghnú Limistéir Bhearrtha" diff --git a/frontend/editor/public/locales/hi-IN/translation.toml b/frontend/editor/public/locales/hi-IN/translation.toml index ae5993b387..bd769216a8 100644 --- a/frontend/editor/public/locales/hi-IN/translation.toml +++ b/frontend/editor/public/locales/hi-IN/translation.toml @@ -3526,7 +3526,6 @@ label = "Y स्थान" [crop.error] failed = "PDF क्रॉप करने में विफल" -invalidArea = "क्रॉप क्षेत्र PDF सीमाओं से बाहर जा रहा है" [crop.preview] title = "क्रॉप क्षेत्र चयन" diff --git a/frontend/editor/public/locales/hr-HR/translation.toml b/frontend/editor/public/locales/hr-HR/translation.toml index 587933825b..371c1b782c 100644 --- a/frontend/editor/public/locales/hr-HR/translation.toml +++ b/frontend/editor/public/locales/hr-HR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y položaj" [crop.error] failed = "Izrezivanje PDF-a nije uspjelo" -invalidArea = "Područje izrezivanja prelazi granice PDF-a" [crop.preview] title = "Odabir područja izrezivanja" diff --git a/frontend/editor/public/locales/hu-HU/translation.toml b/frontend/editor/public/locales/hu-HU/translation.toml index 98d393c047..c8fb681564 100644 --- a/frontend/editor/public/locales/hu-HU/translation.toml +++ b/frontend/editor/public/locales/hu-HU/translation.toml @@ -3526,7 +3526,6 @@ label = "Y pozíció" [crop.error] failed = "A PDF vágása sikertelen" -invalidArea = "A vágási terület túlnyúlik a PDF határain" [crop.preview] title = "Vágási terület kiválasztása" diff --git a/frontend/editor/public/locales/id-ID/translation.toml b/frontend/editor/public/locales/id-ID/translation.toml index 15fe756aa6..83751f1dce 100644 --- a/frontend/editor/public/locales/id-ID/translation.toml +++ b/frontend/editor/public/locales/id-ID/translation.toml @@ -3526,7 +3526,6 @@ label = "Posisi Y" [crop.error] failed = "Gagal memangkas PDF" -invalidArea = "Area pangkas melampaui batas PDF" [crop.preview] title = "Pilihan Area Pangkas" diff --git a/frontend/editor/public/locales/it-IT/translation.toml b/frontend/editor/public/locales/it-IT/translation.toml index a99f00a343..592f644216 100644 --- a/frontend/editor/public/locales/it-IT/translation.toml +++ b/frontend/editor/public/locales/it-IT/translation.toml @@ -3526,7 +3526,6 @@ label = "Posizione Y" [crop.error] failed = "Impossibile ritagliare il PDF" -invalidArea = "L’area di ritaglio supera i limiti del PDF" [crop.preview] title = "Selezione area di ritaglio" diff --git a/frontend/editor/public/locales/ja-JP/translation.toml b/frontend/editor/public/locales/ja-JP/translation.toml index dcc5735dbd..a8d6730ba4 100644 --- a/frontend/editor/public/locales/ja-JP/translation.toml +++ b/frontend/editor/public/locales/ja-JP/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "PDF の切り抜きに失敗しました" -invalidArea = "切り抜き範囲が PDF の境界を超えています" [crop.preview] title = "切り抜き範囲の選択" diff --git a/frontend/editor/public/locales/ko-KR/translation.toml b/frontend/editor/public/locales/ko-KR/translation.toml index 833f710c7f..33ca27a071 100644 --- a/frontend/editor/public/locales/ko-KR/translation.toml +++ b/frontend/editor/public/locales/ko-KR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 위치" [crop.error] failed = "PDF 자르기에 실패했습니다" -invalidArea = "자르기 영역이 PDF 경계를 벗어났습니다" [crop.preview] title = "자르기 영역 선택" diff --git a/frontend/editor/public/locales/ml-ML/translation.toml b/frontend/editor/public/locales/ml-ML/translation.toml index 53e8ef302f..10235abe24 100644 --- a/frontend/editor/public/locales/ml-ML/translation.toml +++ b/frontend/editor/public/locales/ml-ML/translation.toml @@ -3526,7 +3526,6 @@ label = "Y സ്ഥാനം" [crop.error] failed = "PDF ക്രോപ്പ് ചെയ്യാൻ കഴിഞ്ഞില്ല" -invalidArea = "ക്രോപ്പ് ഏരിയ PDF അതിരുകൾക്ക് പുറത്തേക്ക് നീളുന്നു" [crop.preview] title = "ക്രോപ്പ് ഏരിയ തിരഞ്ഞെടുപ്പ്" diff --git a/frontend/editor/public/locales/nl-NL/translation.toml b/frontend/editor/public/locales/nl-NL/translation.toml index d4ada20e32..e5841842f2 100644 --- a/frontend/editor/public/locales/nl-NL/translation.toml +++ b/frontend/editor/public/locales/nl-NL/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-positie" [crop.error] failed = "PDF bijsnijden mislukt" -invalidArea = "Bijsnijgebied valt buiten PDF-randen" [crop.preview] title = "Selectie bijsnijgebied" diff --git a/frontend/editor/public/locales/no-NB/translation.toml b/frontend/editor/public/locales/no-NB/translation.toml index 9402f04f22..1dd1167219 100644 --- a/frontend/editor/public/locales/no-NB/translation.toml +++ b/frontend/editor/public/locales/no-NB/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-posisjon" [crop.error] failed = "Kunne ikke beskjære PDF" -invalidArea = "Beskjæringsområdet går utenfor PDF-grensene" [crop.preview] title = "Valg av beskjæringsområde" diff --git a/frontend/editor/public/locales/pl-PL/translation.toml b/frontend/editor/public/locales/pl-PL/translation.toml index 566a681f9a..c327cf2483 100644 --- a/frontend/editor/public/locales/pl-PL/translation.toml +++ b/frontend/editor/public/locales/pl-PL/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozycja Y" [crop.error] failed = "Nie udało się przyciąć PDF" -invalidArea = "Obszar przycięcia wykracza poza granice PDF" [crop.preview] title = "Wybór obszaru przycięcia" diff --git a/frontend/editor/public/locales/pt-BR/translation.toml b/frontend/editor/public/locales/pt-BR/translation.toml index d89968fdfa..8455176984 100644 --- a/frontend/editor/public/locales/pt-BR/translation.toml +++ b/frontend/editor/public/locales/pt-BR/translation.toml @@ -3526,7 +3526,6 @@ label = "Posição Y" [crop.error] failed = "Falha ao recortar o PDF" -invalidArea = "A área de corte se estende além dos limites do PDF" [crop.preview] title = "Seleção da área de corte" diff --git a/frontend/editor/public/locales/pt-PT/translation.toml b/frontend/editor/public/locales/pt-PT/translation.toml index 0f9aebeff7..0b24388d75 100644 --- a/frontend/editor/public/locales/pt-PT/translation.toml +++ b/frontend/editor/public/locales/pt-PT/translation.toml @@ -3526,7 +3526,6 @@ label = "Posição Y" [crop.error] failed = "Falha ao recortar o PDF" -invalidArea = "A área de recorte excede os limites do PDF" [crop.preview] title = "Seleção da área de recorte" diff --git a/frontend/editor/public/locales/ro-RO/translation.toml b/frontend/editor/public/locales/ro-RO/translation.toml index 0f7c1555b8..2a3f4eba7d 100644 --- a/frontend/editor/public/locales/ro-RO/translation.toml +++ b/frontend/editor/public/locales/ro-RO/translation.toml @@ -3526,7 +3526,6 @@ label = "Poziția Y" [crop.error] failed = "Nu s-a putut decupa PDF-ul" -invalidArea = "Zona de decupare depășește limitele PDF-ului" [crop.preview] title = "Selecție zonă de decupare" diff --git a/frontend/editor/public/locales/ru-RU/translation.toml b/frontend/editor/public/locales/ru-RU/translation.toml index eae673f896..7d32f58a6f 100644 --- a/frontend/editor/public/locales/ru-RU/translation.toml +++ b/frontend/editor/public/locales/ru-RU/translation.toml @@ -3526,7 +3526,6 @@ label = "Положение Y" [crop.error] failed = "Не удалось обрезать PDF" -invalidArea = "Область обрезки выходит за границы PDF" [crop.preview] title = "Выбор области обрезки" diff --git a/frontend/editor/public/locales/sk-SK/translation.toml b/frontend/editor/public/locales/sk-SK/translation.toml index 1906d101d0..9383f6a866 100644 --- a/frontend/editor/public/locales/sk-SK/translation.toml +++ b/frontend/editor/public/locales/sk-SK/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozícia Y" [crop.error] failed = "Nepodarilo sa orezať PDF" -invalidArea = "Oblasť orezania presahuje hranice PDF" [crop.preview] title = "Výber oblasti orezania" diff --git a/frontend/editor/public/locales/sl-SI/translation.toml b/frontend/editor/public/locales/sl-SI/translation.toml index 120606f790..c6c9cdbd54 100644 --- a/frontend/editor/public/locales/sl-SI/translation.toml +++ b/frontend/editor/public/locales/sl-SI/translation.toml @@ -3526,7 +3526,6 @@ label = "Položaj Y" [crop.error] failed = "Obrezovanje PDF-ja ni uspelo" -invalidArea = "Območje obrezovanja presega meje PDF-ja" [crop.preview] title = "Izbira območja obrezovanja" diff --git a/frontend/editor/public/locales/sr-LATN-RS/translation.toml b/frontend/editor/public/locales/sr-LATN-RS/translation.toml index 2e4cb53c7b..c639e19155 100644 --- a/frontend/editor/public/locales/sr-LATN-RS/translation.toml +++ b/frontend/editor/public/locales/sr-LATN-RS/translation.toml @@ -3526,7 +3526,6 @@ label = "Y pozicija" [crop.error] failed = "Nije uspelo isecanje PDF-a" -invalidArea = "Oblast isečka prelazi granice PDF-a" [crop.preview] title = "Izbor oblasti za isecanje" diff --git a/frontend/editor/public/locales/sv-SE/translation.toml b/frontend/editor/public/locales/sv-SE/translation.toml index bd61dbedff..ba2b56e5e0 100644 --- a/frontend/editor/public/locales/sv-SE/translation.toml +++ b/frontend/editor/public/locales/sv-SE/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-position" [crop.error] failed = "Det gick inte att beskära PDF" -invalidArea = "Beskärningsområdet sträcker sig utanför PDF:ens gränser" [crop.preview] title = "Val av beskärningsområde" diff --git a/frontend/editor/public/locales/th-TH/translation.toml b/frontend/editor/public/locales/th-TH/translation.toml index 6b1c927d9f..6a294505f1 100644 --- a/frontend/editor/public/locales/th-TH/translation.toml +++ b/frontend/editor/public/locales/th-TH/translation.toml @@ -3526,7 +3526,6 @@ label = "ตำแหน่ง Y" [crop.error] failed = "ครอบตัด PDF ไม่สำเร็จ" -invalidArea = "พื้นที่ครอบตัดเกินขอบเขตของ PDF" [crop.preview] title = "การเลือกพื้นที่ครอบตัด" diff --git a/frontend/editor/public/locales/tr-TR/translation.toml b/frontend/editor/public/locales/tr-TR/translation.toml index 6ac3a86d07..ae580843e5 100644 --- a/frontend/editor/public/locales/tr-TR/translation.toml +++ b/frontend/editor/public/locales/tr-TR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y Konumu" [crop.error] failed = "PDF kırpılamadı" -invalidArea = "Kırpma alanı PDF sınırlarının dışına taşıyor" [crop.preview] title = "Kırpma Alanı Seçimi" diff --git a/frontend/editor/public/locales/uk-UA/translation.toml b/frontend/editor/public/locales/uk-UA/translation.toml index d51189230b..2f9e2daeef 100644 --- a/frontend/editor/public/locales/uk-UA/translation.toml +++ b/frontend/editor/public/locales/uk-UA/translation.toml @@ -3526,7 +3526,6 @@ label = "Позиція Y" [crop.error] failed = "Не вдалося обрізати PDF" -invalidArea = "Область обрізки виходить за межі PDF" [crop.preview] title = "Вибір області обрізки" diff --git a/frontend/editor/public/locales/vi-VN/translation.toml b/frontend/editor/public/locales/vi-VN/translation.toml index a574d29833..566568dade 100644 --- a/frontend/editor/public/locales/vi-VN/translation.toml +++ b/frontend/editor/public/locales/vi-VN/translation.toml @@ -3526,7 +3526,6 @@ label = "Vị trí Y" [crop.error] failed = "Không cắt được PDF" -invalidArea = "Vùng cắt vượt quá ranh giới PDF" [crop.preview] title = "Chọn vùng cắt" diff --git a/frontend/editor/public/locales/zh-BO/translation.toml b/frontend/editor/public/locales/zh-BO/translation.toml index 895fbc5c82..7a64d7453e 100644 --- a/frontend/editor/public/locales/zh-BO/translation.toml +++ b/frontend/editor/public/locales/zh-BO/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁剪 PDF 失败" -invalidArea = "裁剪区域超出 PDF 边界" [crop.preview] title = "裁剪区域选择" diff --git a/frontend/editor/public/locales/zh-CN/translation.toml b/frontend/editor/public/locales/zh-CN/translation.toml index ccf73691ff..b29e3442d4 100644 --- a/frontend/editor/public/locales/zh-CN/translation.toml +++ b/frontend/editor/public/locales/zh-CN/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁剪 PDF 失败" -invalidArea = "裁剪区域超出 PDF 边界" [crop.preview] title = "裁剪区域选择" diff --git a/frontend/editor/public/locales/zh-TW/translation.toml b/frontend/editor/public/locales/zh-TW/translation.toml index 269d9c9d59..efb8adb8b9 100644 --- a/frontend/editor/public/locales/zh-TW/translation.toml +++ b/frontend/editor/public/locales/zh-TW/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁切 PDF 失敗" -invalidArea = "裁切區域超出 PDF 邊界" [crop.preview] title = "裁切區域選擇" diff --git a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx index 82516059b0..0229613959 100644 --- a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx +++ b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx @@ -1,13 +1,5 @@ import { useState, useEffect } from "react"; -import { - Stack, - Text, - Box, - Group, - Center, - Alert, - Checkbox, -} from "@mantine/core"; +import { Stack, Text, Box, Group, Center, Checkbox } from "@mantine/core"; import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; @@ -161,7 +153,6 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { ); } - const isCropValid = parameters.isCropAreaValid(pdfBounds); const isFullCrop = parameters.isFullPDFCrop(pdfBounds); return ( @@ -239,18 +230,6 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { showAutomationInfo={false} /> )} - - {/* Validation Alert - Only show when autoCrop is false */} - {!parameters.parameters.autoCrop && !isCropValid && ( - - - {t( - "crop.error.invalidArea", - "Crop area extends beyond PDF boundaries", - )} - - - )} ); }; diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts index cc37421cdf..62e7ede14b 100644 --- a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts +++ b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts @@ -8,6 +8,7 @@ import { Rectangle, PDFBounds, constrainCropAreaToPDF, + createDefaultCropArea, createFullPDFCropArea, roundCropArea, isRectangle, @@ -29,6 +30,8 @@ export type CropParametersHook = BaseParametersHook & { setCropArea: (cropArea: Rectangle, pdfBounds?: PDFBounds) => void; /** Get current crop area as CropArea object */ getCropArea: () => Rectangle; + /** Reset to default inset crop area inside PDF bounds */ + resetToDefaultCropArea: (pdfBounds: PDFBounds) => void; /** Reset to full PDF dimensions */ resetToFullPDF: (pdfBounds: PDFBounds) => void; /** Check if current crop area is valid for the PDF */ @@ -76,6 +79,15 @@ export const useCropParameters = (): CropParametersHook => { [baseHook], ); + // Reset to default crop area inside PDF bounds (10% inset) + const resetToDefaultCropArea = useCallback( + (pdfBounds: PDFBounds) => { + const defaultCropArea = createDefaultCropArea(pdfBounds); + setCropArea(defaultCropArea); + }, + [setCropArea], + ); + // Reset to cover entire PDF const resetToFullPDF = useCallback( (pdfBounds: PDFBounds) => { @@ -85,31 +97,11 @@ export const useCropParameters = (): CropParametersHook => { [setCropArea], ); - // Check if current crop area is valid for the given PDF bounds + // Check if current crop area is valid (dimensions must be non-zero; out-of-bounds coordinates clamp automatically) const isCropAreaValid = useCallback( - (pdfBounds?: PDFBounds): boolean => { + (_pdfBounds?: PDFBounds): boolean => { const cropArea = getCropArea(); - - // Basic validation - if ( - cropArea.x < 0 || - cropArea.y < 0 || - cropArea.width <= 0 || - cropArea.height <= 0 - ) { - return false; - } - - // PDF bounds validation if provided - if (pdfBounds) { - const tolerance = 0.01; // Small tolerance for floating point precision - return ( - cropArea.x + cropArea.width <= pdfBounds.actualWidth + tolerance && - cropArea.y + cropArea.height <= pdfBounds.actualHeight + tolerance - ); - } - - return true; + return cropArea.width > 0 && cropArea.height > 0; }, [getCropArea], ); @@ -174,6 +166,7 @@ export const useCropParameters = (): CropParametersHook => { validateParameters: () => validateParameters(), setCropArea, getCropArea, + resetToDefaultCropArea, resetToFullPDF, isCropAreaValid, isFullPDFCrop, diff --git a/frontend/editor/src/core/utils/cropCoordinates.ts b/frontend/editor/src/core/utils/cropCoordinates.ts index 5a275c85ea..4b3bf1c600 100644 --- a/frontend/editor/src/core/utils/cropCoordinates.ts +++ b/frontend/editor/src/core/utils/cropCoordinates.ts @@ -204,7 +204,21 @@ export const isPointInThumbnail = ( }; /** - * Create a default crop area that covers the entire PDF + * Create a default crop area inside PDF bounds (10% inset from each edge, centered) + */ +export const createDefaultCropArea = (pdfBounds: PDFBounds): Rectangle => { + const insetX = pdfBounds.actualWidth * 0.1; + const insetY = pdfBounds.actualHeight * 0.1; + return { + x: Math.round(insetX * 10) / 10, + y: Math.round(insetY * 10) / 10, + width: Math.round((pdfBounds.actualWidth - insetX * 2) * 10) / 10, + height: Math.round((pdfBounds.actualHeight - insetY * 2) * 10) / 10, + }; +}; + +/** + * Create a crop area that covers the entire PDF */ export const createFullPDFCropArea = (pdfBounds: PDFBounds): Rectangle => { return { From 34694c6f5ec11b286e2c557e7eddf1813a1764f3 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:19:26 +0200 Subject: [PATCH 12/31] refactor(api): standardize syntax and simplify type declarations across security, workflow, and controller modules (#7127) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../SPDF/pdf/parser/TabulaTableParser.java | 2 +- .../StringToMapPropertyEditor.java | 3 +- .../controller/api/EditTextController.java | 2 +- .../SPDF/controller/api/UIDataController.java | 3 +- .../api/form/FormPayloadParser.java | 9 +- .../api/misc/AddCommentsController.java | 4 +- .../proprietary/audit/AuditLevel.java | 2 +- .../cluster/valkey/ValkeyJobStore.java | 6 +- .../config/AuditConfigurationProperties.java | 2 +- .../controller/api/UsageRestController.java | 2 +- .../model/UserLicenseSettings.java | 3 +- .../security/CustomLogoutSuccessHandler.java | 32 +++--- .../configuration/SecurityConfiguration.java | 68 ++++++------- .../controller/api/AuthController.java | 23 ++--- .../controller/api/UserController.java | 16 +-- .../proprietary/security/model/Authority.java | 3 +- .../security/model/InviteToken.java | 3 +- ...tomOAuth2AuthenticationFailureHandler.java | 99 ++++++++++--------- ...mSaml2ResponseAuthenticationConverter.java | 7 +- .../service/CustomOAuth2UserService.java | 7 +- .../service/KeyPersistenceService.java | 4 +- .../security/service/UserService.java | 16 +-- .../session/SessionPersistentRegistry.java | 30 +++--- .../proprietary/storage/model/FileShare.java | 3 +- .../storage/model/FileShareAccess.java | 3 +- .../storage/model/StorageCleanupEntry.java | 3 +- .../proprietary/storage/model/StoredFile.java | 3 +- .../storage/model/StoredFileBlob.java | 3 +- .../proprietary/web/AuditWebFilter.java | 3 +- .../controller/SigningSessionController.java | 5 +- .../WorkflowParticipantController.java | 3 +- .../workflow/model/WorkflowParticipant.java | 3 +- .../workflow/model/WorkflowSession.java | 3 +- .../service/SigningFinalizationService.java | 14 +-- .../service/WorkflowSessionService.java | 31 +++--- 35 files changed, 229 insertions(+), 194 deletions(-) diff --git a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java index b85ddbb08e..d3c516d1fe 100644 --- a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java +++ b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java @@ -237,7 +237,7 @@ public class TabulaTableParser implements TableParser { score -= 0.3f; } - return Math.max(0f, Math.min(1f, score)); + return Math.clamp(score, 0f, 1f); } private Bounds tableBounds(Table table) { diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java index 63476d5568..7ab1013a8a 100644 --- a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java +++ b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java @@ -15,7 +15,8 @@ public class StringToMapPropertyEditor extends PropertyEditorSupport { @Override public void setAsText(String text) throws IllegalArgumentException { try { - TypeReference> typeRef = new TypeReference<>() {}; + TypeReference> typeRef = + new TypeReference>() {}; Map map = objectMapper.readValue(text, typeRef); setValue(map); } catch (Exception e) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java index 17d1d7d8a7..b76f52144a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java @@ -237,7 +237,7 @@ public class EditTextController { Matcher matcher = edit.pattern().matcher(joined); List spans = new ArrayList<>(); - StringBuffer interpolation = new StringBuffer(); + StringBuilder interpolation = new StringBuilder(); int previousAppendPosition = 0; while (matcher.find()) { if (matcher.start() == matcher.end()) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index 7f93fb3d64..b6cef0b2d6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -95,7 +95,8 @@ public class UIDataController { try (InputStream is = resource.getInputStream()) { Map> licenseData = - objectMapper.readValue(is, new TypeReference<>() {}); + objectMapper.readValue( + is, new TypeReference>>() {}); data.setDependencies(licenseData.get("dependencies")); } catch (IOException e) { log.error("Failed to load licenses data", e); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index f48f419a6d..5236706f74 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -25,12 +25,15 @@ final class FormPayloadParser { private static final String KEY_VALUE = "value"; private static final String KEY_DEFAULT_VALUE = "defaultValue"; - private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + private static final TypeReference> MAP_TYPE = + new TypeReference>() {}; private static final TypeReference> - MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + MODIFY_FIELD_LIST_TYPE = + new TypeReference>() {}; private static final TypeReference> NEW_FIELD_LIST_TYPE = new TypeReference<>() {}; - private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> STRING_LIST_TYPE = + new TypeReference>() {}; private FormPayloadParser() {} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java index dc2dd22863..09b1d282e9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java @@ -96,7 +96,9 @@ public class AddCommentsController { List dtos; try { - dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {}); + dtos = + objectMapper.readValue( + commentsJson, new TypeReference>() {}); } catch (JacksonException e) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java index 59adc2af80..c2b0e53eb7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java @@ -59,7 +59,7 @@ public enum AuditLevel { */ public static AuditLevel fromInt(int level) { // Ensure level is within valid bounds - int boundedLevel = Math.min(Math.max(level, 0), 3); + int boundedLevel = Math.clamp(level, 0, 3); for (AuditLevel auditLevel : values()) { if (auditLevel.level == boundedLevel) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java index 750abea4fc..f03992ed4d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java @@ -44,8 +44,10 @@ public class ValkeyJobStore implements JobStore { private static final String FILE_INDEX_PREFIX = "stirling:file2job:"; private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final TypeReference> LIST_STRING = new TypeReference<>() {}; - private static final TypeReference> MAP_STRING = new TypeReference<>() {}; + private static final TypeReference> LIST_STRING = + new TypeReference>() {}; + private static final TypeReference> MAP_STRING = + new TypeReference>() {}; private final StringRedisTemplate template; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java index 366d91b11c..ac6c25ac5e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java @@ -35,7 +35,7 @@ public class AuditConfigurationProperties { // Ensure level is within valid bounds (0-3) int configLevel = auditConfig.getLevel(); - this.level = Math.min(Math.max(configLevel, 0), 3); + this.level = Math.clamp(configLevel, 0, 3); // Retention days (0 means infinite) this.retentionDays = auditConfig.getRetentionDays(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java index 1230d928cc..65bf8240a4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java @@ -48,7 +48,7 @@ public class UsageRestController { @RequestParam(value = "dataType", defaultValue = "all") String dataType, @RequestParam(value = "days", defaultValue = "30") Integer days) { - int lookbackDays = Math.max(1, Math.min(days, 365)); + int lookbackDays = Math.clamp(days, 1, 365); // Get audit events filtered by type List events = getEventsByDataType(dataType, lookbackDays); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java index bb7f52142a..1683ad9134 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.model; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.*; @@ -19,7 +20,7 @@ import lombok.*; @ToString public class UserLicenseSettings implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; public static final Long SINGLETON_ID = 1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 4bfef06c9b..d83b684166 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -70,21 +70,23 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { if (!response.isCommitted()) { if (authentication != null) { - if (authentication instanceof Saml2Authentication samlAuthentication) { - // Handle SAML2 logout redirection - getRedirect_saml2(request, response, samlAuthentication); - } else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) { - // Handle OAuth2 logout redirection - getRedirect_oauth2(request, response, oAuthToken); - } else if (authentication instanceof UsernamePasswordAuthenticationToken) { - // Handle Username/Password logout - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); - } else { - // Handle unknown authentication types - log.error( - "Authentication class unknown: {}", - authentication.getClass().getSimpleName()); - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + switch (authentication) { + case Saml2Authentication samlAuthentication -> + // Handle SAML2 logout redirection + getRedirect_saml2(request, response, samlAuthentication); + case OAuth2AuthenticationToken oAuthToken -> + // Handle OAuth2 logout redirection + getRedirect_oauth2(request, response, oAuthToken); + case UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken -> + // Handle Username/Password logout + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + default -> { + // Handle unknown authentication types + log.error( + "Authentication class unknown: {}", + authentication.getClass().getSimpleName()); + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + } } } else { if (jwtService != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 0f0c7315d9..7c5b412d33 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -392,40 +392,40 @@ public class SecurityConfiguration { // Handle OAUTH2 Logins if (securityProperties.isOauth2Active()) { http.oauth2Login( - oauth2 -> { - oauth2.loginPage("/login") - .authorizationEndpoint( - authorizationEndpoint -> { - if (clientRegistrationRepository != null) { - authorizationEndpoint - .authorizationRequestResolver( - new TauriAuthorizationRequestResolver( - clientRegistrationRepository)); - } - }) - .successHandler( - new CustomOAuth2AuthenticationSuccessHandler( - loginAttemptService, - securityProperties.getOauth2(), - userService, - jwtService, - licenseSettingsService, - applicationProperties)) - .failureHandler(new CustomOAuth2AuthenticationFailureHandler()) - // Add existing Authorities from the database - .userInfoEndpoint( - userInfoEndpoint -> - userInfoEndpoint - .oidcUserService( - new CustomOAuth2UserService( - securityProperties - .getOauth2(), - userService, - loginAttemptService)) - .userAuthoritiesMapper( - oAuth2userAuthoritiesMapper)) - .permitAll(); - }); + oauth2 -> + oauth2.loginPage("/login") + .authorizationEndpoint( + authorizationEndpoint -> { + if (clientRegistrationRepository != null) { + authorizationEndpoint + .authorizationRequestResolver( + new TauriAuthorizationRequestResolver( + clientRegistrationRepository)); + } + }) + .successHandler( + new CustomOAuth2AuthenticationSuccessHandler( + loginAttemptService, + securityProperties.getOauth2(), + userService, + jwtService, + licenseSettingsService, + applicationProperties)) + .failureHandler( + new CustomOAuth2AuthenticationFailureHandler()) + // Add existing Authorities from the database + .userInfoEndpoint( + userInfoEndpoint -> + userInfoEndpoint + .oidcUserService( + new CustomOAuth2UserService( + securityProperties + .getOauth2(), + userService, + loginAttemptService)) + .userAuthoritiesMapper( + oAuth2userAuthoritiesMapper)) + .permitAll()); } // Handle SAML if (securityProperties.isSaml2Active() && runningProOrHigher) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 86a1c5fe0c..6661c86395 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -703,17 +703,18 @@ public class AuthController { } private long extractEpochMillis(Object claimValue) { - if (claimValue == null) { - return -1L; - } - - if (claimValue instanceof java.util.Date date) { - return date.getTime(); - } - - if (claimValue instanceof Number number) { - long epochSeconds = number.longValue(); - return epochSeconds * 1000L; + switch (claimValue) { + case null -> { + return -1L; + } + case java.util.Date date -> { + return date.getTime(); + } + case Number number -> { + long epochSeconds = number.longValue(); + return epochSeconds * 1000L; + } + default -> {} } return -1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index fdacda72b2..2385eb011f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -760,14 +760,14 @@ public class UserController { for (Object principal : principals) { List sessionsInformation = sessionRegistry.getAllSessions(principal, false); - if (principal instanceof UserDetails detailsUser) { - userNameP = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - userNameP = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - userNameP = saml2User.name(); - } else if (principal instanceof String stringUser) { - userNameP = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> userNameP = detailsUser.getUsername(); + case OAuth2User oAuth2User -> userNameP = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> + userNameP = saml2User.name(); + case String stringUser -> userNameP = stringUser; + default -> {} } if (userNameP.equalsIgnoreCase(username)) { for (SessionInformation sessionInfo : sessionsInformation) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java index 659f7691bd..4ffea54740 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.model; +import java.io.Serial; import java.io.Serializable; import org.springframework.security.core.GrantedAuthority; @@ -28,7 +29,7 @@ import lombok.Setter; @Setter public class Authority implements GrantedAuthority, Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java index 975220bf48..062cce058f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -18,7 +19,7 @@ import lombok.Setter; @Setter public class InviteToken implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java index 784a9f0a2f..670b08c53f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java @@ -36,57 +36,62 @@ public class CustomOAuth2AuthenticationFailureHandler AuthenticationException exception) throws IOException, ServletException { - if (exception instanceof BadCredentialsException) { - log.error("BadCredentialsException", exception); - getRedirectStrategy().sendRedirect(request, response, "/login?error=badCredentials"); - return; - } - if (exception instanceof DisabledException) { - log.error("User is deactivated: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true"); - return; - } - if (exception instanceof LockedException) { - log.error("Account locked: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); - return; - } - if (exception instanceof OAuth2AuthenticationException oAuth2Exception) { - OAuth2Error error = oAuth2Exception.getError(); - - String errorCode = error.getErrorCode(); - - if ("Password must not be null".equals(error.getErrorCode())) { - errorCode = "userAlreadyExistsWeb"; + switch (exception) { + case BadCredentialsException badCredentialsException -> { + log.error("BadCredentialsException", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/login?error=badCredentials"); + return; } + case DisabledException disabledException -> { + log.error("User is deactivated: ", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/logout?userIsDisabled=true"); + return; + } + case LockedException lockedException -> { + log.error("Account locked: ", exception); + getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); + return; + } + case OAuth2AuthenticationException oAuth2Exception -> { + OAuth2Error error = oAuth2Exception.getError(); - log.error( - "OAuth2 Authentication error: {}", - errorCode != null ? errorCode : exception.getMessage(), - exception); - String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; - clearRedirectCookie(response); - boolean tauriState = TauriOAuthUtils.isTauriState(request); - String redirectUrl; - if (tauriState) { - String basePath = - TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); - redirectUrl = basePath; - String stateParam = request.getParameter("state"); - if (stateParam != null && !stateParam.isBlank()) { - redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); - // Extract and pass nonce for CSRF validation - String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); - if (nonce != null) { - redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); - } + String errorCode = error.getErrorCode(); + + if ("Password must not be null".equals(error.getErrorCode())) { + errorCode = "userAlreadyExistsWeb"; } - redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); - } else { - redirectUrl = buildFailureRedirectUrl(request, errorValue); + + log.error( + "OAuth2 Authentication error: {}", + errorCode != null ? errorCode : exception.getMessage(), + exception); + String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; + clearRedirectCookie(response); + boolean tauriState = TauriOAuthUtils.isTauriState(request); + String redirectUrl; + if (tauriState) { + String basePath = + TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); + redirectUrl = basePath; + String stateParam = request.getParameter("state"); + if (stateParam != null && !stateParam.isBlank()) { + redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); + // Extract and pass nonce for CSRF validation + String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); + if (nonce != null) { + redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); + } + } + redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); + } else { + redirectUrl = buildFailureRedirectUrl(request, errorValue); + } + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + return; } - getRedirectStrategy().sendRedirect(request, response, redirectUrl); - return; + default -> {} } log.error("Unhandled authentication exception", exception); super.onAuthenticationFailure(request, response, exception); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java index b2ce4adb68..96dcdecd03 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java @@ -61,7 +61,12 @@ public class CustomSaml2ResponseAuthenticationConverter @Override public Saml2Authentication convert(ResponseToken responseToken) { - Assertion assertion = responseToken.getResponse().getAssertions().getFirst(); + List assertions = responseToken.getResponse().getAssertions(); + if (assertions == null || assertions.isEmpty()) { + log.error("SAML response contains no assertions"); + return null; + } + Assertion assertion = assertions.getFirst(); Map> attributes = extractAttributes(assertion); // Debug log with actual values diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java index c1057c7e36..b8054c89d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java @@ -213,8 +213,11 @@ public class CustomOAuth2UserService implements OAuth2UserService {} + case UserDetails detailsUser -> usernameP = detailsUser.getUsername(); + case OAuth2User oAuth2User -> usernameP = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> + usernameP = saml2User.name(); + case String stringUser -> usernameP = stringUser; + default -> {} } if (usernameP.equalsIgnoreCase(username)) { sessionRegistry.expireSession(sessionsInformation.getSessionId()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java index e615416e59..1f3a4e84ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java @@ -47,14 +47,13 @@ public class SessionPersistentRegistry implements SessionRegistry { List sessionInformations = new ArrayList<>(); String principalName = null; - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> principalName = detailsUser.getUsername(); + case OAuth2User oAuth2User -> principalName = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name(); + case String stringUser -> principalName = stringUser; + default -> {} } if (principalName != null) { @@ -78,14 +77,13 @@ public class SessionPersistentRegistry implements SessionRegistry { public void registerNewSession(String sessionId, Object principal) { String principalName = null; - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> principalName = detailsUser.getUsername(); + case OAuth2User oAuth2User -> principalName = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name(); + case String stringUser -> principalName = stringUser; + default -> {} } if (principalName != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java index 1b0fd86f78..6ddd0c8a86 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -46,7 +47,7 @@ import stirling.software.proprietary.security.model.User; @Setter public class FileShare implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java index 49f75a4a4c..cb2f5d5209 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -39,7 +40,7 @@ import stirling.software.proprietary.security.model.User; @Setter public class FileShareAccess implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java index 3158f4c041..68afe20173 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -24,7 +25,7 @@ import lombok.Setter; @Setter public class StorageCleanupEntry implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java index db80bd1e91..1b098672b6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.HashSet; @@ -45,7 +46,7 @@ import stirling.software.proprietary.workflow.model.WorkflowSession; @Setter public class StoredFile implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java index 52ef1107fc..4abcffd3e6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.Column; @@ -19,7 +20,7 @@ import lombok.Setter; @Setter public class StoredFileBlob implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @Column(name = "storage_key", nullable = false, length = 128) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java index b6f5b47f3b..70847a0702 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java @@ -7,6 +7,7 @@ import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -64,7 +65,7 @@ public class AuditWebFilter extends OncePerRequestFilter { if (auth != null && auth.getAuthorities() != null) { String roles = auth.getAuthorities().stream() - .map(a -> a.getAuthority()) + .map(GrantedAuthority::getAuthority) .reduce((a, b) -> a + "," + b) .orElse(""); MDC.put("userRoles", roles); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index 73867776e9..b224a09841 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -37,6 +37,7 @@ import stirling.software.proprietary.workflow.dto.CertificateInfo; import stirling.software.proprietary.workflow.dto.CertificateValidationResponse; import stirling.software.proprietary.workflow.dto.ParticipantRequest; import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator; import stirling.software.proprietary.workflow.service.SigningFinalizationService; @@ -259,7 +260,9 @@ public class SigningSessionController { + "database until manual cleanup.", sessionId, session.getParticipants() != null - ? session.getParticipants().stream().map(p -> p.getEmail()).toList() + ? session.getParticipants().stream() + .map(WorkflowParticipant::getEmail) + .toList() : "unknown", e); throw new ResponseStatusException( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java index 5f903e4b56..4df0c93e1d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java @@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.http.ContentDisposition; @@ -429,7 +430,7 @@ public class WorkflowParticipantController { java.util.List> wetSigs = objectMapper.readValue( request.getWetSignaturesData(), - new TypeReference>>() {}); + new TypeReference>>() {}); if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Too many wet signatures submitted"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java index 2e6091b963..b119565c13 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; @@ -51,7 +52,7 @@ import stirling.software.proprietary.storage.model.ShareAccessRole; @Setter public class WorkflowParticipant implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java index 3fc6b53b44..7df5af710f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; @@ -53,7 +54,7 @@ import stirling.software.proprietary.storage.model.StoredFile; @Setter public class WorkflowSession implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java index e5e122df45..3fce8c69dd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java @@ -217,16 +217,13 @@ public class SigningFinalizationService { wetSignatures.size(), session.getSessionId()); - PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes)); - try { + try (PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes))) { for (WetSignatureMetadata wetSig : wetSignatures) { applyWetSignatureToPage(document, wetSig); } ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); return baos.toByteArray(); - } finally { - document.close(); } } @@ -242,11 +239,10 @@ public class SigningFinalizationService { } PDPage page = document.getPage(pageIndex); - PDPageContentStream contentStream = - new PDPageContentStream( - document, page, PDPageContentStream.AppendMode.APPEND, true, true); - try { + try (PDPageContentStream contentStream = + new PDPageContentStream( + document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { // Use WetSignatureMetadata.extractBase64Data() to strip data URL prefix String base64Data = wetSig.extractBase64Data(); if (base64Data == null || base64Data.isBlank()) { @@ -279,8 +275,6 @@ public class SigningFinalizationService { pdfY, width, height); - } finally { - contentStream.close(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index 4c60c60df2..a2db5deb5a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -954,21 +954,22 @@ public class WorkflowSessionService { Object pemObject = pemParser.readObject(); JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); PrivateKeyInfo keyInfo; - if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo encrypted) { - InputDecryptorProvider decryptor = - new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); - keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); - } else if (pemObject instanceof PEMEncryptedKeyPair encryptedKeyPair) { - PEMDecryptorProvider decryptor = - new JcePEMDecryptorProviderBuilder().build(password); - keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); - } else if (pemObject instanceof PEMKeyPair keyPair) { - keyInfo = keyPair.getPrivateKeyInfo(); - } else if (pemObject instanceof PrivateKeyInfo info) { - keyInfo = info; - } else { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); + switch (pemObject) { + case PKCS8EncryptedPrivateKeyInfo encrypted -> { + InputDecryptorProvider decryptor = + new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); + keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); + } + case PEMEncryptedKeyPair encryptedKeyPair -> { + PEMDecryptorProvider decryptor = + new JcePEMDecryptorProviderBuilder().build(password); + keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); + } + case PEMKeyPair keyPair -> keyInfo = keyPair.getPrivateKeyInfo(); + case PrivateKeyInfo info -> keyInfo = info; + case null, default -> + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); } return converter.getPrivateKey(keyInfo); } From 1bb6961414f96ee00031c4de77359f78761b9dd1 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:12:56 +0000 Subject: [PATCH 13/31] Update Frontend 3rd Party Licenses (#7738) Auto-generated by stirlingbot[bot] This PR updates the frontend license report based on changes to package.json dependencies. Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- frontend/editor/src/assets/3rdPartyLicenses.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/editor/src/assets/3rdPartyLicenses.json b/frontend/editor/src/assets/3rdPartyLicenses.json index 1cd5f1f8bc..b2ec99d368 100644 --- a/frontend/editor/src/assets/3rdPartyLicenses.json +++ b/frontend/editor/src/assets/3rdPartyLicenses.json @@ -227,14 +227,14 @@ { "moduleName": "@mui/icons-material", "moduleUrl": "https://github.com/mui/material-ui", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.1", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "@mui/material", "moduleUrl": "https://github.com/mui/material-ui", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.1", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -297,7 +297,7 @@ { "moduleName": "@tanstack/react-virtual", "moduleUrl": "https://github.com/TanStack/virtual", - "moduleVersion": "3.13.23", + "moduleVersion": "3.14.10", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, From e539eb1ab12f0e4616169175709e979723152adb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:13:31 +0000 Subject: [PATCH 14/31] build(deps): bump the ubuntu group across 2 directories with 1 update (#7698) > [!WARNING] > Cooldown could not be applied because no publication date was available from the registry. > Bumps the ubuntu group with 1 update in the /docker/base directory: ubuntu. Bumps the ubuntu group with 1 update in the /docker/unoserver directory: ubuntu. Updates `ubuntu` from `561618e` to `33ceb71` Updates `ubuntu` from `561618e` to `33ceb71` Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/base/Dockerfile | 8 ++++---- docker/unoserver/Dockerfile | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 06e1b6e601..9205814a31 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -5,7 +5,7 @@ ARG TARGETPLATFORM # Stage 1: Build and strip Calibre -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS calibre-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS calibre-build ARG TARGETPLATFORM ARG CALIBRE_VERSION=9.13.0 ARG CALIBRE_STRIP_WEBENGINE=false @@ -274,7 +274,7 @@ RUN if [ "${CALIBRE_STRIP_WEBENGINE}" = "true" ]; then \ # Stage 2: Build Ghostscript from source -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS gs-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS gs-build ARG TARGETPLATFORM ARG GS_VERSION=10.07.1 @@ -298,7 +298,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ # Stage 3: Build PDF Tools (QPDF and ImageMagick 7) -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS pdf-tools-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS pdf-tools-build ARG TARGETPLATFORM ARG QPDF_VERSION=12.4.0 ARG IM_VERSION=7.1.2-29 @@ -343,7 +343,7 @@ RUN mkdir -p /magick-export/usr/bin \ # Stage 4: Build Python venv -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS python-venv-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS python-venv-build ARG TARGETPLATFORM ARG UNOSERVER_VERSION=3.7 diff --git a/docker/unoserver/Dockerfile b/docker/unoserver/Dockerfile index da5ba27371..3667c4bf5a 100644 --- a/docker/unoserver/Dockerfile +++ b/docker/unoserver/Dockerfile @@ -1,7 +1,7 @@ # Standalone unoserver image for Stirling-PDF remote UNO mode. # Pinned to unoserver 3.7 to match Stirling-PDF's client (avoids wire mismatch). -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 ARG UNOSERVER_VERSION=3.7 # ~120 MB of CJK fonts — opt-in. From 1f4cc2612df838769eb20893a6f1171a67108e48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:12:18 +0000 Subject: [PATCH 15/31] build(deps): bump the eclipse-temurin group across 3 directories with 1 update (#7740) > [!WARNING] > Cooldown could not be applied because no publication date was available from the registry. > Bumps the eclipse-temurin group with 1 update in the /docker/backend directory: eclipse-temurin. Bumps the eclipse-temurin group with 1 update in the /docker/base directory: eclipse-temurin. Bumps the eclipse-temurin group with 1 update in the /docker/embedded directory: eclipse-temurin. Updates `eclipse-temurin` from `fbcf915` to `b4c93a5` Updates `eclipse-temurin` from `fbcf915` to `b4c93a5` Updates `eclipse-temurin` from `fbcf915` to `b4c93a5` Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/backend/Dockerfile | 2 +- docker/base/Dockerfile | 2 +- docker/embedded/Dockerfile | 2 +- docker/embedded/Dockerfile.fat | 2 +- docker/embedded/Dockerfile.ultra-lite | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 39bb60bdef..ec7d7a7304 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -45,7 +45,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 9205814a31..0c73738214 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -368,7 +368,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ # Final runtime image - the actual base image -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS runtime +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS runtime SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index cac30d88b1..80e163dd9a 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -61,7 +61,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 42c9a16af2..38c8bcc8f1 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -61,7 +61,7 @@ RUN --mount=type=cache,id=stirling-pdf-npm-cache,target=/root/.npm,sharing=locke --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index f1389c1600..104bfd8937 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -62,7 +62,7 @@ RUN --mount=type=cache,id=stirling-pdf-npm-cache,target=/root/.npm,sharing=locke # Stage 2: Runtime image # glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked. -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ From 3235645203cf723c7770274f2ffdfc54a48f5d0f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:13:30 +0000 Subject: [PATCH 16/31] build(deps): bump step-security/harden-runner from 2.20.0 to 2.21.0 (#7746) Bumps [step-security/harden-runner](https://github.com/step-security/harden-runner) from 2.20.0 to 2.21.0.
Release notes

Sourced from step-security/harden-runner's releases.

v2.21.0

What's Changed

  • Support for denied endpoints in block mode. This is included in the enterprise tier. Customers can deny outbound calls, for example, to public package registries.
  • Improved Support for AWS CodeBuild GitHub Actions Runners.
  • Bug fixes.

Full Changelog: https://github.com/step-security/harden-runner/compare/v2.20.1...v2.21.0

v2.20.1

What's Changed

  • AWS CodeBuild-hosted runner support
  • Implicitly allow single-labeled (internal) domains in block-mode

Full Changelog: https://github.com/step-security/harden-runner/compare/v2.20.0...v2.20.1

Commits
  • 05e3151 Merge pull request #684 from step-security/rc-42
  • 0f37afa fix: ignore denied-endpoints on non-enterprise tier
  • 93b58ee fix: resolve cache host read-first and never downgrade egress policy
  • e7399dd fix: align deny-list mode detection with agent and log when both endpoint inp...
  • c16689f test: add denied_endpoints to Configuration fixtures and cover deny-list merge
  • 40b99cf Merge pull request #682 from rohan-stepsecurity/rp/feat/codebuild-self-v2
  • fedec02 Merge branch 'rc-42' into rp/feat/codebuild-self-v2
  • 5361fb1 feat: add build artifacts
  • 286474f feat: Support Bravo agent install on CodeBuild runners
  • 051ec05 Merge pull request #683 from h0x0er/jatin/deny-list
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=step-security/harden-runner&package-manager=github_actions&previous-version=2.20.0&new-version=2.21.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/Saas-Dev-Deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Saas-Dev-Deploy.yml b/.github/workflows/Saas-Dev-Deploy.yml index 733b92bca2..dba916b243 100644 --- a/.github/workflows/Saas-Dev-Deploy.yml +++ b/.github/workflows/Saas-Dev-Deploy.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit From f6124223e433f3288245ff6c8c461f6d8b12709e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:14:20 +0000 Subject: [PATCH 17/31] build(deps): bump github/codeql-action/upload-sarif from 4.37.7 to 4.37.8 (#7750) Bumps [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) from 4.37.7 to 4.37.8.
Release notes

Sourced from github/codeql-action/upload-sarif's releases.

v4.37.8

No user facing changes.

Changelog

Sourced from github/codeql-action/upload-sarif's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.9 - 26 Aug 2026

  • Update default CodeQL bundle version to 2.26.4. #4106

4.37.8 - 21 Aug 2026

No user facing changes.

4.37.7 - 13 Aug 2026

  • Update default CodeQL bundle version to 2.26.3. #4085

4.37.6 - 04 Aug 2026

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

4.37.5 - 03 Aug 2026

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995

... (truncated)

Commits
  • db488dd Merge pull request #4102 from github/update-v4.37.8-9ee088e13
  • 1845f5b Update changelog for v4.37.8
  • 9ee088e Merge pull request #4080 from github/henrymercer/studious-giggle
  • 1aef003 Address review feedback on overlay disk flags
  • 508b83b Merge main into overlay minimum disk feature branch
  • d97b342 Merge pull request #4098 from github/mbg/permission-error-as-configuration-error
  • 47fa622 Make EACCES a ConfigurationError
  • 45693cc Refactor ENOSPC check into isDiskConfigurationError function
  • c2fd8f5 Merge pull request #4081 from github/mario-campos/version-cache-to-disk
  • c56f48e Log unexpected conditions during caching CLI output
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action/upload-sarif&package-manager=github_actions&previous-version=4.37.7&new-version=4.37.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index dbda06764b..8c7169e856 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -75,6 +75,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: sarif_file: results.sarif From b92f88361ec8837d82f2a24af036cc72d0e20ed2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:14:30 +0000 Subject: [PATCH 18/31] build(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#7711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.2.0 to 4.3.0.
Release notes

Sourced from docker/setup-buildx-action's releases.

v4.3.0

Full Changelog: https://github.com/docker/setup-buildx-action/compare/v4.2.0...v4.3.0

Commits
  • 37fe631 Merge pull request #595 from docker/dependabot/npm_and_yarn/docker/actions-to...
  • b5c4f91 [dependabot skip] chore: update generated content
  • 3e93b63 build(deps): bump @​docker/actions-toolkit from 0.92.0 to 0.95.0
  • e527031 Merge pull request #600 from docker/dependabot/npm_and_yarn/brace-expansion-1...
  • c68814b [dependabot skip] chore: update generated content
  • 3f891b0 build(deps): bump brace-expansion from 1.1.13 to 1.1.18
  • 787db26 Merge pull request #585 from docker/dependabot/npm_and_yarn/js-yaml-5.2.1
  • f779368 [dependabot skip] chore: update generated content
  • 7d5e604 build(deps): bump js-yaml from 5.2.0 to 5.3.0
  • 292c2fb Merge pull request #590 from docker/dependabot/github_actions/actions/setup-n...
  • Additional commits viewable in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/PR-Auto-Deploy-V2.yml | 2 +- .github/workflows/PR-Demo-Comment-with-react.yml | 2 +- .github/workflows/Saas-Dev-Deploy.yml | 2 +- .github/workflows/docker-compose-tests.yml | 2 +- .github/workflows/push-docker-base.yml | 2 +- .github/workflows/push-docker.yml | 2 +- .github/workflows/test-build-docker.yml | 4 ++-- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 6420712640..c27fb6b5f2 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -182,7 +182,7 @@ jobs: fetch-depth: 0 # Fetch full history for commit hash detection - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Get version number id: versionNumber diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 111dba441f..b43020ceb0 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -222,7 +222,7 @@ jobs: STIRLING_PDF_DESKTOP_UI: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/Saas-Dev-Deploy.yml b/.github/workflows/Saas-Dev-Deploy.yml index dba916b243..84aa57d5f2 100644 --- a/.github/workflows/Saas-Dev-Deploy.yml +++ b/.github/workflows/Saas-Dev-Deploy.yml @@ -45,7 +45,7 @@ jobs: fetch-depth: 0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index cf5887a205..b37babfdf6 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -57,7 +57,7 @@ jobs: # runtime token isn't exposed) since the docker driver can't use it. - name: Set up Docker Buildx if: inputs.docker-base-changed != 'true' - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 # Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend. - name: Expose GitHub runtime for Buildx cache diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml index 9da49ad7ae..6bfae2b300 100644 --- a/.github/workflows/push-docker-base.yml +++ b/.github/workflows/push-docker-base.yml @@ -69,7 +69,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 844a77b489..8e8b26be91 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -85,7 +85,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 4717c9c387..e38fcb4cd6 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -142,7 +142,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Set base image and platform for this build id: build-params @@ -229,7 +229,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Build docker/unoserver/Dockerfile uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 From b1e857fd01e8e3aca55f8903ac07d9502bd25c2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:13:47 +0100 Subject: [PATCH 19/31] build(deps): bump com.tngtech.archunit:archunit-junit5 from 1.4.2 to 1.5.0 (#7704) Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index fdc59f3680..9dbfcf9b24 100644 --- a/build.gradle +++ b/build.gradle @@ -40,7 +40,7 @@ ext { jinjavaVersion = "2.8.4" jackson2Version = "2.22.1" bucket4jVersion = "8.19.0" - archunitVersion = "1.4.2" + archunitVersion = "1.5.0" batikVersion = "1.19" jpdfiumVersion = "1.1.3" jwtVersion = "0.13.0" From 5fe7df393364f39e5e96941ce12ccf807c76563a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:14:04 +0100 Subject: [PATCH 20/31] build(deps): bump jackson2Version from 2.22.1 to 2.22.2 (#7703) Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9dbfcf9b24..6ee8baca47 100644 --- a/build.gradle +++ b/build.gradle @@ -38,7 +38,7 @@ ext { gsonVersion = "2.14.0" guavaVersion = "33.6.0-jre" jinjavaVersion = "2.8.4" - jackson2Version = "2.22.1" + jackson2Version = "2.22.2" bucket4jVersion = "8.19.0" archunitVersion = "1.5.0" batikVersion = "1.19" From 3aca7a26f6b1b1f37c7cd1627be0b163200351ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:14:19 +0100 Subject: [PATCH 21/31] build(deps-dev): bump python-dotenv from 1.2.2 to 1.2.3 in /engine (#7702) Signed-off-by: dependabot[bot] --- engine/pyproject.toml | 2 +- engine/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 23c3bc078c..3fe7ccdb22 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -20,7 +20,7 @@ engine = [ # No `voyageai` extra either; stirling.documents.voyage speaks its API directly. "pydantic-ai-slim[anthropic,openai]>=1.107.2,<2.0.0", "pydantic-settings>=2.15.0", - "python-dotenv>=1.2.2", + "python-dotenv>=1.2.3", "sqlite-vec>=0.1.9", "uvicorn>=0.52.3", ] diff --git a/engine/uv.lock b/engine/uv.lock index a3286b100d..4681861fe3 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -479,7 +479,7 @@ engine = [ { name = "pydantic", specifier = ">=2.13.4" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], specifier = ">=1.107.2,<2.0.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, - { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "python-dotenv", specifier = ">=1.2.3" }, { name = "sqlite-vec", specifier = ">=0.1.9" }, { name = "uvicorn", specifier = ">=0.52.3" }, ] @@ -1262,11 +1262,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] From 54e839ae65e7968e51c42214a790c054bf67628d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:14:31 +0100 Subject: [PATCH 22/31] build(deps-dev): bump reportlab from 5.0.0 to 5.0.1 in /engine (#7699) Signed-off-by: dependabot[bot] --- engine/pyproject.toml | 2 +- engine/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 3fe7ccdb22..179fe8c1d1 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -42,7 +42,7 @@ cucumber = [ "pillow>=12.3.0", "pypdf[crypto]>=6.15.0", "qrcode[pil]>=8.2", - "reportlab>=5.0.0", + "reportlab>=5.0.1", "requests>=2.34.2", ] # Shared Python utilities used by repository scripts and CI workflows. diff --git a/engine/uv.lock b/engine/uv.lock index 4681861fe3..8c76b57b3b 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -466,7 +466,7 @@ cucumber = [ { name = "pillow", specifier = ">=12.3.0" }, { name = "pypdf", extras = ["crypto"], specifier = ">=6.15.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, - { name = "reportlab", specifier = ">=5.0.0" }, + { name = "reportlab", specifier = ">=5.0.1" }, { name = "requests", specifier = ">=2.34.2" }, ] engine = [ @@ -1373,15 +1373,15 @@ wheels = [ [[package]] name = "reportlab" -version = "5.0.0" +version = "5.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, { name = "pillow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/d6/4b7b0cf56880eb96533e607967be6a939e344675601e033d113a0bfa1f4e/reportlab-5.0.0.tar.gz", hash = "sha256:e4494a0c6623ae213bb856fba523171b2b54a7bf629fda02d5e525a7b899a784", size = 3701928, upload-time = "2026-06-18T11:34:31.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/51/dbe28534ae12c852f61be91f039f343305fd1f34f1c66b8de75afae7a525/reportlab-5.0.1.tar.gz", hash = "sha256:ebd13154be1c8515e665de70bd2d303ae9ddc3ef47e44afd5116441ca0283a26", size = 3945711, upload-time = "2026-08-20T13:48:16.461Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/07/70085c17a369605f15e301d10ab902115019b1126c7253d964afc230c7d6/reportlab-5.0.0-py3-none-any.whl", hash = "sha256:9d5a3affa84919e1111ede580031266a570e93b1ce388219621347965ff1d93c", size = 1956710, upload-time = "2026-06-18T11:34:29.07Z" }, + { url = "https://files.pythonhosted.org/packages/db/cb/dacbc268cb68d0428ea2cbd85266195a9ab3e677449589ddae59bd7542ac/reportlab-5.0.1-py3-none-any.whl", hash = "sha256:1c36e6bb0e71780c72331eba60da7f602e8d4389a8723825af71342e49d791e8", size = 1957258, upload-time = "2026-08-20T13:48:14.026Z" }, ] [[package]] From d93049db9f4f01981a5fc922d3fb2cff64ddc7db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:14:56 +0100 Subject: [PATCH 23/31] build(deps): bump log from 0.4.33 to 0.4.34 in /frontend/editor/src-tauri (#7747) Signed-off-by: dependabot[bot] --- frontend/editor/src-tauri/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/editor/src-tauri/Cargo.lock b/frontend/editor/src-tauri/Cargo.lock index fe8352f5a7..a875adc740 100644 --- a/frontend/editor/src-tauri/Cargo.lock +++ b/frontend/editor/src-tauri/Cargo.lock @@ -2429,9 +2429,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "value-bag", ] From 96207a7304078e3323e419581d30bc0b667795c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:15:11 +0100 Subject: [PATCH 24/31] build(deps): bump @tanstack/react-query from 5.101.4 to 5.102.0 in /frontend in the tanstack group across 1 directory (#7749) Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 16 ++++++++-------- frontend/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 198751f05d..85d011f49b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -49,7 +49,7 @@ "@stripe/stripe-js": "^9.10.0", "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", - "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query": "^5.102.0", "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.14.10", "@tauri-apps/api": "^2.10.1", @@ -5219,9 +5219,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.101.4", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", - "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "version": "5.102.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.0.tgz", + "integrity": "sha512-tvBzr11Q7StuMCEsIJdqX8TAWt6WZIzfw/yrSAjObZDerwTTPeCxeLXN2R8ZSn4ZxFpQV819Xn8QmoO+vtnDvw==", "license": "MIT", "funding": { "type": "github", @@ -5229,12 +5229,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.101.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", - "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "version": "5.102.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.0.tgz", + "integrity": "sha512-0GyVyEcGt9M7jHPCua16hNVtstUXE2R4HsnNubFYcDs7DJaLzh1dSiXsADDU/cNn/SqrLfa0vRKyjUmbx4rZLA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.4" + "@tanstack/query-core": "5.102.0" }, "funding": { "type": "github", diff --git a/frontend/package.json b/frontend/package.json index 0389baaf32..877c9f1b3b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,7 +46,7 @@ "@stripe/stripe-js": "^9.10.0", "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", - "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query": "^5.102.0", "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.14.10", "@tauri-apps/api": "^2.10.1", From 0920ea9493598c40e893728117f94c458a51d63e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:17:05 +0100 Subject: [PATCH 25/31] build(deps): bump go-task/setup-task from 2.1.0 to 2.2.0 (#7532) Signed-off-by: dependabot[bot] --- .github/workflows/PR-Auto-Deploy-V2.yml | 2 +- .github/workflows/PR-Demo-Comment-with-react.yml | 2 +- .github/workflows/ai-engine.yml | 2 +- .github/workflows/backend-build.yml | 2 +- .github/workflows/build-enterprise.yml | 2 +- .github/workflows/check-generated-models.yml | 2 +- .github/workflows/check-licence.yml | 2 +- .github/workflows/check-openapi.yml | 2 +- .github/workflows/e2e-live.yml | 2 +- .github/workflows/e2e-stubbed.yml | 2 +- .github/workflows/frontend-a11y.yml | 2 +- .github/workflows/frontend-backend-licenses-update.yml | 4 ++-- .github/workflows/frontend-validation.yml | 2 +- .github/workflows/multiOSReleases.yml | 6 +++--- .github/workflows/nightly.yml | 6 +++--- .github/workflows/pre_commit.yml | 2 +- .github/workflows/push-docker.yml | 2 +- .github/workflows/swagger.yml | 2 +- .github/workflows/sync_files_v2.yml | 2 +- .github/workflows/tauri-build.yml | 2 +- .github/workflows/test-build-docker.yml | 2 +- 21 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index c27fb6b5f2..c55d70fd4e 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -353,7 +353,7 @@ jobs: - name: Install Task for Storybook if: steps.sb-changes.outputs.storybook == 'true' - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build and deploy Storybook id: storybook diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index b43020ceb0..5a4fcc8053 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -206,7 +206,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Run Gradle Command run: | if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 357dfa1e49..29b8312f4f 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -36,7 +36,7 @@ jobs: engine/uv.lock - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Quality-check engine id: engine-check diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 09c6bbc9a8..33b631ef86 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -52,7 +52,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Check Java formatting (Spotless) # Runs once per matrix combination - pick the cheapest leg # (core - no proprietary, no saas) so we don't wait for the diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index a5a346db88..a3f10d82d4 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -95,7 +95,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Install Playwright (chromium only) run: task e2e:install -- chromium - name: Build frontend (needed for playwright's vite preview webServer) diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index f917a4c602..276a1d7530 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -75,7 +75,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Verify generated models are up to date id: models-check diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index 7626122884..38b49097ed 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -38,7 +38,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Check licenses for compatibility run: task backend:licenses:check env: diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 47341834a3..5046c18e8c 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -39,7 +39,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Generate OpenAPI documentation run: task backend:swagger env: diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index b04f5022cc..505addd3d7 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -45,7 +45,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Install Playwright (chromium only) run: task e2e:install -- chromium - name: Build frontend (production bundle for vite preview) diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index 5038a7585a..2553f38f84 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -44,7 +44,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build frontend (production bundle for vite preview) env: VITE_BUILD_FOR_PREVIEW: "1" diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index 247d8375fc..f96496e181 100644 --- a/.github/workflows/frontend-a11y.yml +++ b/.github/workflows/frontend-a11y.yml @@ -36,7 +36,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: a11y gate (changed stories) run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }} - name: Upload scan reports diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 96e0edd8ac..44cf4afb75 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -97,7 +97,7 @@ jobs: run: npm ci --ignore-scripts --audit=false --fund=false - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Generate frontend license report (Push only) if: github.event_name == 'push' @@ -367,7 +367,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Check licenses and generate report id: license-check diff --git a/.github/workflows/frontend-validation.yml b/.github/workflows/frontend-validation.yml index 2650a945d6..a553c48280 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -27,7 +27,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Quality-check frontend id: frontend-check run: task frontend:check:all diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 9071997ad7..6d4258d085 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -69,7 +69,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Get version number id: versionNumber run: | @@ -169,7 +169,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build JAR run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube @@ -268,7 +268,7 @@ jobs: distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 # Build the universal JRE before desktop:prepare so the jlink:runtime # task short-circuits on its `test -d runtime/jre` status check. diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5daa60f56f..63484ee7c0 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -38,7 +38,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Install all Playwright browsers run: task e2e:install @@ -89,7 +89,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: a11y gate (every story, ${{ matrix.theme }}) run: task frontend:storybook:a11y:${{ matrix.theme }} @@ -162,7 +162,7 @@ jobs: engine/uv.lock - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Start the fat image with login and storage enabled run: docker compose -f docker/embedded/compose/test_cicd.yml up -d --build diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index fbd9efc474..f3442086a0 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -33,7 +33,7 @@ jobs: engine/uv.lock - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Run pre-commit checks run: task pre-commit diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 8e8b26be91..b3c6d442b6 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -88,7 +88,7 @@ jobs: uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Get version number id: versionNumber run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 1f53edd17b..d7408dcfde 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -63,7 +63,7 @@ jobs: SWAGGERHUB_USER: "Frooodle" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Get version number id: versionNumber run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index f688476159..d38c14ddba 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -65,7 +65,7 @@ jobs: uv sync --project engine --locked --group tools - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Sync translation TOML files run: | diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 3b7a0b04fa..157c4ead0c 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -212,7 +212,7 @@ jobs: distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - name: Setup Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build universal macOS JRE if: matrix.platform == 'macos-15' diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index e38fcb4cd6..04a6380586 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -127,7 +127,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build application run: task backend:build env: From 01c908e95d6d0ee062f9467fb4942cf27f712986 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:37:12 +0100 Subject: [PATCH 26/31] build(deps-dev): bump openai from 2.53.0 to 3.3.1 in /engine (#7700) Signed-off-by: dependabot[bot] --- engine/pyproject.toml | 2 +- engine/uv.lock | 24 +++++------------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 179fe8c1d1..d924eab264 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -51,7 +51,7 @@ tools = [ "defusedxml>=0.7.1", "fonttools>=4.63.0", "fpdf2>=2.8.8", - "openai>=2.53.0", + "openai>=3.3.1", "requests>=2.34.2", "tomli-w>=1.2.0", "tomlkit>=0.15.1", diff --git a/engine/uv.lock b/engine/uv.lock index 8c76b57b3b..ae023292b7 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -501,7 +501,7 @@ tools = [ { name = "defusedxml", specifier = ">=0.7.1" }, { name = "fonttools", specifier = ">=4.63.0" }, { name = "fpdf2", specifier = ">=2.8.8" }, - { name = "openai", specifier = ">=2.53.0" }, + { name = "openai", specifier = ">=3.3.1" }, { name = "requests", specifier = ">=2.34.2" }, { name = "tomli-w", specifier = ">=1.2.0" }, { name = "tomlkit", specifier = ">=0.15.1" }, @@ -844,21 +844,19 @@ wheels = [ [[package]] name = "openai" -version = "2.53.0" +version = "3.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, - { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116", size = 1099435, upload-time = "2026-08-03T21:42:01.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/9c/ba0c292b4032ede74c249ca314ad64eb1bb5a03a843f6e01facb02f80cd8/openai-3.3.1.tar.gz", hash = "sha256:6f22807de1a976c932cecda620e8172a8c3fdbaeed29c7f21564e0c2410edf56", size = 1282113, upload-time = "2026-08-19T16:31:35.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618", size = 1659829, upload-time = "2026-08-03T21:41:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/2b7a1b3de659bb82aef979116c74e809982b13e42c057759767552b5155f/openai-3.3.1-py3-none-any.whl", hash = "sha256:9652df7fdf8ee6f5bd58e0a12f2b1d414a18e0f06bb7a9a57c8643a5f5469bd3", size = 1690337, upload-time = "2026-08-19T16:31:32.812Z" }, ] [[package]] @@ -1566,18 +1564,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, ] -[[package]] -name = "tqdm" -version = "4.70.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, -] - [[package]] name = "truststore" version = "0.10.4" From af97e1b27bca7ba84bfb7448a6fc3512f9873670 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:45:42 +0100 Subject: [PATCH 27/31] Update Backend 3rd Party Licenses (#7713) Signed-off-by: stirlingbot[bot] --- .../resources/static/3rdPartyLicenses.json | 101 +----------------- 1 file changed, 5 insertions(+), 96 deletions(-) diff --git a/app/core/src/main/resources/static/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index a0dae640e0..6b846053e4 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -94,7 +94,7 @@ { "moduleName": "com.fasterxml.jackson.core:jackson-core", "moduleUrl": "https://github.com/FasterXML/jackson-core", - "moduleVersion": "2.22.1", + "moduleVersion": "2.22.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -108,7 +108,7 @@ { "moduleName": "com.fasterxml.jackson.core:jackson-databind", "moduleUrl": "https://github.com/FasterXML/jackson", - "moduleVersion": "2.22.1", + "moduleVersion": "2.22.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -143,7 +143,7 @@ { "moduleName": "com.fasterxml.jackson:jackson-bom", "moduleUrl": "https://github.com/FasterXML/jackson-bom", - "moduleVersion": "2.22.1", + "moduleVersion": "2.22.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -440,42 +440,14 @@ { "moduleName": "com.stirling:jpdfium", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-darwin-arm64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-darwin-x64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-linux-arm64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", + "moduleVersion": "1.1.3", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "com.stirling:jpdfium-natives-linux-x64", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-windows-x64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", + "moduleVersion": "1.1.3", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -521,36 +493,18 @@ "moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception", "moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html" }, - { - "moduleName": "com.twelvemonkeys.common:common-image", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.common:common-image", "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.common:common-io", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.common:common-io", "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.common:common-lang", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.common:common-lang", "moduleVersion": "3.14.0", @@ -569,12 +523,6 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-core", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-core", "moduleVersion": "3.14.0", @@ -587,12 +535,6 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-metadata", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-metadata", "moduleVersion": "3.14.0", @@ -605,24 +547,12 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-tiff", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-tiff", "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-webp", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-webp", "moduleVersion": "3.14.0", @@ -769,13 +699,6 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "commons-beanutils:commons-beanutils", - "moduleUrl": "https://commons.apache.org/proper/commons-beanutils", - "moduleVersion": "1.11.0", - "moduleLicense": "Apache-2.0", - "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "commons-cli:commons-cli", "moduleUrl": "http://commons.apache.org/proper/commons-cli/", @@ -790,13 +713,6 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "commons-collections:commons-collections", - "moduleUrl": "http://commons.apache.org/collections/", - "moduleVersion": "3.2.2", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "commons-io:commons-io", "moduleUrl": "https://commons.apache.org/proper/commons-io/", @@ -1360,13 +1276,6 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "org.apache.commons:commons-math3", - "moduleUrl": "http://commons.apache.org/proper/commons-math/", - "moduleVersion": "3.6.1", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "org.apache.commons:commons-text", "moduleUrl": "https://commons.apache.org/proper/commons-text", From d55d8acbfa5f56174bf7ee00b7c6f53944180099 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:56:35 +0000 Subject: [PATCH 28/31] fix(portal): keep the processor's cache across a trip to the editor (#7729) # Description of Changes ## The problem The portal's query client was created per mount: ```ts const [queryClient] = useState(createPortalQueryClient); ``` The portal is a route (`/processor/*`, a lazy element), and the switch to the editor is a client-side `navigate()`. So leaving the processor unmounts `PortalApp`, the client goes with the component, and the cache goes with the client. Coming back refetches everything, whether or not anything changed: four requests for the Users page alone (roster, grants, teams, auth config), and 21 `useQuery` sites across the portal. The editor's client sits above the router in `AppProviders` and survives the same trip. The round trip only ever cost in one direction. ## The fix The module already kept the instance in a module-level slot so `tryGetPortalQueryClient()` could find it. It just replaced it on every mount instead of reusing it, so the change is to create it lazily and hand out the same one: ```ts export function getPortalQueryClient(): QueryClient { current ??= new QueryClient({ defaultOptions: { queries: baseQueryOptions } }); return current; } ``` Still a separate instance from the editor's. The two namespace their keys apart (`["portal", ...]` against `["editor", ...]`) and invalidate independently, which this does not change. ## What this does not do `gcTime` is 5 minutes, from the shared `baseQueryOptions`. An entry with no observer is still collected on that timer, so this warms a quick trip to the editor and back, not a return after a long editing session. Raising the portal's `gcTime` is a separate decision and is not made here. ## Why it is safe **Signing out.** A cache that outlives a mount must not outlive a session, because the portal's holds the admin roster, emails and roles. Logout goes through `window.location.assign`, a full page load, so the whole JS context is discarded and no cache can survive it. Nothing in the codebase calls `queryClient.clear()` on sign-out, and nothing needs to. If logout ever becomes a client-side navigation, this needs an explicit reset, and `resetPortalQueryClient()` is the hook for it. **The one caller of the null check.** `resolveTeam` in `saas/portal/usersBackend.ts` uses `tryGetPortalQueryClient()` and falls back to a direct fetch when there is no client, which its comment describes as the unit-test path; the cache path is preferred because it honours both `staleTime` and invalidation. A longer-lived client means the preferred path is taken more often, not less. ## Testing Three tests in `queryClient.test.tsx`, and the first two fail if the client goes back to being created per call: | | | |---|---| | A remount is served from cache rather than refetching | the behaviour this changes | | Every caller gets the same instance | the mechanism | | No client is reported until the portal first mounts | the contract `resolveTeam` reads | The three existing portal caching suites called the factory expecting a fresh client per case. They now call `resetPortalQueryClient()` in a `beforeEach`, which is what keeps `sharing.test.tsx`'s "a later screen refetches nothing" case honest rather than passing on a leaked cache. `task frontend:check` passes typecheck, lint and oxfmt, and 2402 of 2404 editor tests. The two failures, `workbenchSession.test.ts` and `notificationActions.test.tsx`, are untouched here and fail the same way on `main`. --- frontend/editor/src/portal/PortalApp.tsx | 6 +- .../src/portal/queries/sharing.test.tsx | 12 +++- .../editor/src/portal/queryClient.test.tsx | 64 +++++++++++++++++++ frontend/editor/src/portal/queryClient.ts | 27 ++++++-- .../src/portal/views/Users.caching.test.tsx | 12 +++- .../src/portal/views/teamMyCache.saas.test.ts | 10 ++- 6 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 frontend/editor/src/portal/queryClient.test.tsx diff --git a/frontend/editor/src/portal/PortalApp.tsx b/frontend/editor/src/portal/PortalApp.tsx index 3122016436..9109946929 100644 --- a/frontend/editor/src/portal/PortalApp.tsx +++ b/frontend/editor/src/portal/PortalApp.tsx @@ -1,11 +1,11 @@ -import { useState, type ReactNode } from "react"; +import { type ReactNode } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; import { PortalProviders } from "@portal/PortalProviders"; import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; -import { createPortalQueryClient } from "@portal/queryClient"; +import { getPortalQueryClient } from "@portal/queryClient"; // Reset + typography, scoped to .portal-scope below. import "@portal/theme/base.css"; @@ -30,7 +30,7 @@ function ThemedSuiProvider({ children }: { children: ReactNode }) { * self-hosted mounts the account-link layer, SaaS does not. */ export function PortalApp() { - const [queryClient] = useState(createPortalQueryClient); + const queryClient = getPortalQueryClient(); return ( diff --git a/frontend/editor/src/portal/queries/sharing.test.tsx b/frontend/editor/src/portal/queries/sharing.test.tsx index 5cfb9d1629..97e23f9244 100644 --- a/frontend/editor/src/portal/queries/sharing.test.tsx +++ b/frontend/editor/src/portal/queries/sharing.test.tsx @@ -12,7 +12,10 @@ import { render, waitFor } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; -import { createPortalQueryClient } from "@portal/queryClient"; +import { + getPortalQueryClient, + resetPortalQueryClient, +} from "@portal/queryClient"; import { usePoliciesOverview } from "@portal/queries/policies"; import { useProcessorFlow } from "@portal/queries/processorFlow"; @@ -70,9 +73,12 @@ function PoliciesConsumer() { return null; } +// The client outlives a mount now, so each case starts from a cold one. +beforeEach(resetPortalQueryClient); + describe("portal query sharing", () => { it("in-view: multiple consumers of the same endpoints fetch each once", async () => { - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); render( @@ -86,7 +92,7 @@ describe("portal query sharing", () => { }); it("cross-view: a later screen reusing the data refetches nothing", async () => { - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); const home = render( diff --git a/frontend/editor/src/portal/queryClient.test.tsx b/frontend/editor/src/portal/queryClient.test.tsx new file mode 100644 index 0000000000..a750eccc0e --- /dev/null +++ b/frontend/editor/src/portal/queryClient.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import { QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { + getPortalQueryClient, + resetPortalQueryClient, + tryGetPortalQueryClient, +} from "@portal/queryClient"; + +const fetchThing = vi.fn(async () => "loaded"); + +/** Stands in for any portal view: mounts, reads one key, unmounts with the route. */ +function PortalRoute() { + const { data } = useQuery({ + queryKey: ["portal", "thing"], + queryFn: fetchThing, + }); + return {data ?? "pending"}; +} + +function mountRoute() { + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return render( + + + , + ); +} + +describe("portal query client lifetime", () => { + beforeEach(() => { + resetPortalQueryClient(); + fetchThing.mockClear(); + }); + + it("serves a remount from cache instead of refetching", async () => { + const first = mountRoute(); + await screen.findByText("loaded"); + expect(fetchThing).toHaveBeenCalledTimes(1); + + // Switching to the editor unmounts the portal route. + first.unmount(); + + mountRoute(); + // Painted from cache, not after a round trip. + expect(screen.getByText("loaded")).toBeInTheDocument(); + expect(fetchThing).toHaveBeenCalledTimes(1); + }); + + it("hands every caller the same instance", () => { + expect(getPortalQueryClient()).toBe(getPortalQueryClient()); + }); + + it("reports no client until the portal first mounts", () => { + expect(tryGetPortalQueryClient()).toBeNull(); + const client = getPortalQueryClient(); + expect(tryGetPortalQueryClient()).toBe(client); + }); +}); diff --git a/frontend/editor/src/portal/queryClient.ts b/frontend/editor/src/portal/queryClient.ts index 5c0404f17d..bdd6b3b2ec 100644 --- a/frontend/editor/src/portal/queryClient.ts +++ b/frontend/editor/src/portal/queryClient.ts @@ -3,13 +3,32 @@ import { baseQueryOptions } from "@app/query/queryClient"; let current: QueryClient | null = null; -/** Own instance, shared defaults — the portal and editor are sibling routes. */ -export function createPortalQueryClient(): QueryClient { - current = new QueryClient({ defaultOptions: { queries: baseQueryOptions } }); +/** + * One client for the session, not one per mount. The portal is a route, so + * switching to the editor unmounts it, and a per-mount client would throw the + * cache away and refetch everything on the way back. The editor's own client + * sits above the router and never pays that. + * + * Still a separate instance from the editor's: the two namespace their keys + * apart and invalidate independently. + */ +export function getPortalQueryClient(): QueryClient { + current ??= new QueryClient({ + defaultOptions: { queries: baseQueryOptions }, + }); return current; } -/** Null until the portal mounts, so resolveTeam can fall back to a direct fetch. */ +/** Null until the portal first mounts, so resolveTeam can fall back to a direct fetch. */ export function tryGetPortalQueryClient(): QueryClient | null { return current; } + +/** + * Drops the cache and the instance holding it. For tests, which need a cold + * start between cases; the app never calls it, because signing out is a full + * page load. + */ +export function resetPortalQueryClient(): void { + current = null; +} diff --git a/frontend/editor/src/portal/views/Users.caching.test.tsx b/frontend/editor/src/portal/views/Users.caching.test.tsx index 64d839ac62..a619d883b7 100644 --- a/frontend/editor/src/portal/views/Users.caching.test.tsx +++ b/frontend/editor/src/portal/views/Users.caching.test.tsx @@ -17,7 +17,10 @@ import { teamSaasHandlers, resetTeamSaasStore, } from "@portal/mocks/handlers/teamSaas"; -import { createPortalQueryClient } from "@portal/queryClient"; +import { + getPortalQueryClient, + resetPortalQueryClient, +} from "@portal/queryClient"; import { qk } from "@portal/queries/keys"; /** @@ -98,11 +101,14 @@ function renderUsers(client: QueryClient): RenderResult { ); } +// The client outlives a mount now, so each case starts from a cold one. +beforeEach(resetPortalQueryClient); + describe("Users view caching", () => { it("serves the roster from cache on remount (no refetch)", async () => { // One client across both mounts — the real app keeps it at the portal root, // above the router, for exactly this reason. - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); const first = renderUsers(client); expect(await screen.findByText("leader@acme.com")).toBeInTheDocument(); @@ -116,7 +122,7 @@ describe("Users view caching", () => { }); it("collapses the SaaS /team/my call to one per mount", async () => { - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); renderUsers(client); await screen.findByText("leader@acme.com"); diff --git a/frontend/editor/src/portal/views/teamMyCache.saas.test.ts b/frontend/editor/src/portal/views/teamMyCache.saas.test.ts index a4ba296535..8534624c52 100644 --- a/frontend/editor/src/portal/views/teamMyCache.saas.test.ts +++ b/frontend/editor/src/portal/views/teamMyCache.saas.test.ts @@ -10,7 +10,10 @@ import { } from "vitest"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; -import { createPortalQueryClient } from "@portal/queryClient"; +import { + getPortalQueryClient, + resetPortalQueryClient, +} from "@portal/queryClient"; import { qk } from "@portal/queries/keys"; import { usersBackend } from "@app/portal/usersBackend"; @@ -68,9 +71,12 @@ beforeEach(() => { teamName = "Old name"; }); +// The client outlives a mount now, so each case starts from a cold one. +beforeEach(resetPortalQueryClient); + describe("SaaS /team/my resolution cache", () => { it("dedupes within staleTime but re-resolves after invalidation", async () => { - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); // Two resolves within staleTime → one network call (the collapse). expect((await usersBackend.fetchTeams())[0]?.name).toBe("Old name"); From 31d52d4c327810450f575889ffccdd74a1e49593 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:39:57 +0000 Subject: [PATCH 29/31] Connect flow for self-hosted account linking, and the triggers that drive it (#7415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bare account-link login box with a guided Connect flow, and wires up the triggers that actually put it in front of someone. ## Top bar image ## The modal Three steps on the portal's own `FlowModal` + `StepModalHeader`, the shells procurement and prepay already wear: 1. **What you unlock** — six benefits as a plain list. image 2. **Sign in** — the existing `SupabaseLoginForm`, reseated. image 3. **Connected** — confirms, then deep links into Users, Pipelines and Policies. image Re-auth stays a single step with no pitch and no success screen. ## The triggers **`LinkGate` stops being dead code.** It was built as the drop-anywhere "link to unlock" wrapper and was imported by nothing. It is now a blocking empty state that replaces the feature it guards, wired into Pipelines, Policies, Users, Sources and Integrations. **Scoped to creating and editing, never viewing.** Existing pipelines, policies, sources and connections keep listing and running, so upgrading an unlinked instance cannot take away something that already works. The clicks that would open a builder or a create modal ask for the connection first, which is the moment an admin has already declared intent. ## Capability signal `accountLinkAvailable` on `/api/v1/config/app-config`. Gating needs two facts: whether the instance is linked (`LinkContext`) and whether it *could* be (this flag). The account-link endpoints 404 when the feature flag is off, which the client cannot distinguish from "not linked yet" — so gating on link state alone would lock all five views on every default install with no way out. `useConnectGate` holds that decision in one place and shares the app-config query key, so it costs no extra request. Read from the environment rather than `AccountLinkProperties` because `:core` cannot depend on `:proprietary`. --- .../controller/api/misc/ConfigController.java | 13 + .../public/locales/en-US/translation.toml | 72 ++-- frontend/editor/src/core/types/appConfig.ts | 6 + .../editor/src/portal/PortalProviders.tsx | 15 +- frontend/editor/src/portal/ViewRouter.tsx | 16 +- .../portal/components/ConnectAccountRail.css | 53 +++ .../components/ConnectAccountRail.stories.tsx | 46 +++ .../portal/components/ConnectAccountRail.tsx | 57 ++++ .../editor/src/portal/components/HomeHero.tsx | 2 + .../editor/src/portal/components/Sidebar.tsx | 5 + .../account-link/ConnectCallbackHost.tsx | 144 +++----- .../account-link/ConnectCallbackView.tsx | 66 ++-- .../account-link/ConnectGuardedRoute.tsx | 26 ++ .../account-link/LinkAccountModal.css | 26 -- .../account-link/LinkAccountModal.test.tsx | 186 +++++++++-- .../account-link/LinkAccountModal.tsx | 310 +++++++++++------- .../components/account-link/LinkGate.tsx | 52 --- .../account-link/connect/ConnectAskStep.tsx | 52 +++ .../connect/ConnectBenefitsSlide.stories.tsx | 12 + .../connect/ConnectBenefitsSlide.tsx | 55 ++++ .../connect/ConnectDoneSlide.test.tsx | 84 +++++ .../account-link/connect/ConnectDoneSlide.tsx | 157 +++++++++ .../connect/ConnectHandoffGhost.tsx | 28 ++ .../account-link/connect/connect.css | 112 +++++++ .../billing/LinkAccountPrompt.stories.tsx | 14 - .../components/billing/LinkAccountPrompt.tsx | 33 -- .../billing/PortalBillingGate.test.tsx | 77 ++++- .../components/billing/PortalBillingGate.tsx | 26 +- .../components/shared/StepModalHeader.css | 2 +- .../components/shared/StepModalHeader.tsx | 4 +- .../src/portal/components/sidebarGroups.tsx | 4 +- .../src/portal/contexts/LinkContext.tsx | 8 + .../editor/src/portal/contexts/UIContext.tsx | 22 ++ .../src/portal/hooks/useConnectGate.test.tsx | 78 +++++ .../editor/src/portal/hooks/useConnectGate.ts | 63 ++++ .../src/portal/hooks/useConnectHandoff.ts | 64 ++++ .../portal/hooks/useConnectPrompt.test.tsx | 74 +++++ .../src/portal/hooks/useConnectPrompt.ts | 37 +++ .../portal/hooks/useDevConnectBypass.test.tsx | 53 +++ .../src/portal/hooks/useDevConnectBypass.ts | 39 +++ .../src/portal/test/TestQueryProvider.tsx | 19 ++ .../src/portal/views/ConnectCallback.css | 31 +- .../src/portal/views/ConnectCallback.test.tsx | 88 +++-- .../src/portal/views/Integrations.test.tsx | 10 + .../editor/src/portal/views/Integrations.tsx | 24 +- .../src/portal/views/Pipelines.gated.test.tsx | 117 +++++++ .../src/portal/views/Pipelines.test.tsx | 10 + .../editor/src/portal/views/Pipelines.tsx | 19 +- frontend/editor/src/portal/views/Policies.tsx | 26 +- .../src/portal/views/Sources.gated.test.tsx | 111 +++++++ .../editor/src/portal/views/Sources.test.tsx | 10 + frontend/editor/src/portal/views/Sources.tsx | 23 +- .../src/portal/views/Users.caching.test.tsx | 10 + .../src/portal/views/Users.gated.test.tsx | 96 ++++++ .../src/portal/views/Users.saas.test.tsx | 10 + frontend/editor/src/portal/views/Users.tsx | 25 +- .../src/saas/routes/ConnectApproveView.tsx | 90 ++++- frontend/editor/src/saas/routes/connect.css | 9 +- 58 files changed, 2346 insertions(+), 575 deletions(-) create mode 100644 frontend/editor/src/portal/components/ConnectAccountRail.css create mode 100644 frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx create mode 100644 frontend/editor/src/portal/components/ConnectAccountRail.tsx create mode 100644 frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx delete mode 100644 frontend/editor/src/portal/components/account-link/LinkAccountModal.css delete mode 100644 frontend/editor/src/portal/components/account-link/LinkGate.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/connect.css delete mode 100644 frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx delete mode 100644 frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx create mode 100644 frontend/editor/src/portal/hooks/useConnectGate.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useConnectGate.ts create mode 100644 frontend/editor/src/portal/hooks/useConnectHandoff.ts create mode 100644 frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useConnectPrompt.ts create mode 100644 frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useDevConnectBypass.ts create mode 100644 frontend/editor/src/portal/views/Pipelines.gated.test.tsx create mode 100644 frontend/editor/src/portal/views/Sources.gated.test.tsx create mode 100644 frontend/editor/src/portal/views/Users.gated.test.tsx diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 36beb6610c..618d5d642d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -338,6 +338,19 @@ public class ConfigController { // Premium/Enterprise settings configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); + // Whether this instance can link a Stirling (SaaS) account at all. The account-link + // beans live in :proprietary and are @ConditionalOnProperty on this same key, so when + // it is off they are absent and /api/v1/account-link/* returns 404. The frontend cannot + // tell that 404 apart from "not linked yet", so it needs this told to it explicitly + // before it can prompt anyone to link. Read from the environment rather than + // AccountLinkProperties because :core must not depend on :proprietary. + configData.put( + "accountLinkAvailable", + applicationContext + .getEnvironment() + .getProperty( + "stirling.billing.account-link.enabled", Boolean.class, false)); + // AI Engine settings ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine(); configData.put("aiEngineEnabled", aiEngineConfig.isEnabled()); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index baee16cd02..df996b23c0 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3641,6 +3641,7 @@ system = "System Configuration" [connect] loading = "Checking this request." redirecting = "Returning you to your server." +step = "Step {{current}} of {{total}}" [connect.confirm] acknowledge = "I recognise this address and want to connect it to my team" @@ -6804,10 +6805,22 @@ after = "to enable account linking against the hosted Stirling account. In dev y before = "Set" title = "SaaS login not configured" +[portal.accountLink.connect] +close = "Close" +notNow = "Not now" +start = "Connect Stirling account" +step = "Step {{current}} of {{total}}" + +[portal.accountLink.connect.benefits] +creditsDetail = "500 free per month" +creditsLabel = "Credits" +processorDetail = "Pipelines, policies, sources and audit" +processorLabel = "Processor" +teamsDetail = "Free for up to 5 users" +teamsLabel = "Teams" + [portal.accountLink.connect.callback] -continue = "Continue" linkedNotSignedIn = "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in." -modalTitle = "Connecting this server" retry = "Try again" signedInAnyway = "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete." working = "Finishing the connection." @@ -6816,10 +6829,6 @@ working = "Finishing the connection." body = "Connection requests are short lived. Start another one." title = "Request expired" -[portal.accountLink.connect.callback.linked] -body = "This server is connected to your Stirling account." -title = "Server connected" - [portal.accountLink.connect.callback.malformed] body = "This page was opened without a valid connection response. Start the connection from settings." title = "Could not read the response" @@ -6832,11 +6841,23 @@ title = "Connection not completed" body = "Stirling did not confirm the connection. This is usually temporary." title = "Not finished yet" -[portal.accountLink.gate] -action = "Link account" -description = "Link this org's Stirling account to use billable features." -title = "Link to unlock" -titleFeature = "Link to unlock {{feature}}" +[portal.accountLink.connect.done] +accountLabel = "Account" +addPolicy = "Add a policy" +buildPipeline = "Set up a pipeline" +creditsBarLabel = "Free credits remaining" +creditsSuffix = "of {{allowance}} free credits left" +cta = "Done" +inviteTeam = "Invite your team" +lede = "This server now runs against your Stirling account." +pendingTitle = "Almost there" +switchOnProcessor = "Switch on the Processor" +title = "Connected" + +[portal.accountLink.connect.handoff] +going = "Taking you to stirling.com" +reauthLede = "Your Stirling session expired. Signing in again keeps usage and billing visible. This server stays connected either way." +title = "Connecting" [portal.accountLink.instances] active = "Active" @@ -6866,17 +6887,11 @@ never = "never" [portal.accountLink.modal] cancel = "Cancel" -continueLink = "Continue to Stirling" continueReauth = "Sign in again" -linkSubtitle = "Connect this server to the Stirling account it should bill against." linkTitle = "Connect your Stirling account" noAuthorizeUrl = "Stirling did not return somewhere to continue. Try again in a moment." -reauthSubtitle = "Your Stirling session expired. Sign in again to keep seeing usage and billing. This server stays connected either way." reauthTitle = "Sign in again" startFailed = "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again." -step1 = "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on." -step2 = "You check this server's address and approve it. A team owner has to do this the first time." -step3 = "Stirling brings you straight back here and finishes up." [portal.accountLink.modal.loginNotConfigured] after = "so this server can finish the connection when you come back." @@ -6895,6 +6910,12 @@ forbidden = "Only the team owner can view the org's linked instances." generic = "Couldn't load the team's linked instances. Try again in a moment." title = "Couldn't load linked instances" +[portal.accountLink.rail] +cta = "Connect" +later = "Not now" +sub = "Unlocks teams, PDF processor, pipelines, and policies. PDF editing stays free." +title = "Connect your Stirling account" + [portal.accountLink.state] free = "Editor plan" subscribed = "Processor plan" @@ -7018,11 +7039,6 @@ title = "Invoice history" viewAriaLabel = "View invoice {{number}} in Stripe" viewLink = "View ↗" -[portal.billing.linkPrompt] -cta = "Link Stirling account" -description = "Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use." -title = "Link your Stirling account" - [portal.billing.paymentMethod] billedMonthly = "Billed monthly" cardEnding = "{{brand}} ending {{last4}}" @@ -7178,8 +7194,8 @@ label = "Projected to exceed." [portal.billing.spendThisMonth] eyebrow = "Spend this month" -freeRemaining_one = "{{formatted}} free PDF remaining" -freeRemaining_other = "{{formatted}} free PDFs remaining" +freeRemaining_one = "{{formatted}} free credit remaining" +freeRemaining_other = "{{formatted}} free credits remaining" processed_one = "{{formattedCount}} PDF processed." processed_other = "{{formattedCount}} PDFs processed." processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each." @@ -7203,10 +7219,10 @@ eyebrow = "Processor trial" statusLabel_one = "{{used}} used" statusLabel_other = "{{used}} used" sub = "Use the PDF Editor for free. Pay to process PDFs automatically." -title_one = "Process {{allowance}} PDFs free" -title_other = "Process {{allowance}} PDFs free" -titleWithRate_one = "Process {{allowance}} PDFs free, then {{rate}}/PDF" -titleWithRate_other = "Process {{allowance}} PDFs free, then {{rate}}/PDF" +title_one = "{{allowance}} free credit to start" +title_other = "{{allowance}} free credits to start" +titleWithRate_one = "{{allowance}} free credit, then {{rate}} per PDF" +titleWithRate_other = "{{allowance}} free credits, then {{rate}} per PDF" [portal.components.billingUnit] approval = "approval" diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index 2dafb3d07a..ef31ed4f4e 100644 --- a/frontend/editor/src/core/types/appConfig.ts +++ b/frontend/editor/src/core/types/appConfig.ts @@ -22,6 +22,12 @@ export interface AppConfig { premiumEnabled?: boolean; premiumKey?: string; paygEnabled?: boolean; + /** + * Whether this instance can link a Stirling (SaaS) account. False means the account-link + * endpoints are absent (404), which is indistinguishable from "not linked" on the client, so + * anything that prompts to link must gate on this first. + */ + accountLinkAvailable?: boolean; termsAndConditions?: string; privacyPolicy?: string; cookiePolicy?: string; diff --git a/frontend/editor/src/portal/PortalProviders.tsx b/frontend/editor/src/portal/PortalProviders.tsx index a82f1c7405..5008dd8537 100644 --- a/frontend/editor/src/portal/PortalProviders.tsx +++ b/frontend/editor/src/portal/PortalProviders.tsx @@ -5,15 +5,24 @@ import { LinkAccountModal } from "@portal/components/account-link/LinkAccountMod import { AccountLinkProvider } from "@portal/contexts/AccountLinkContext"; import { ConnectCallbackHost } from "@portal/components/account-link/ConnectCallbackHost"; import { PortalChrome } from "@portal/components/PortalChrome"; +import { useConnectPrompt } from "@portal/hooks/useConnectPrompt"; -/** The one and only account-link modal. */ +/** The one and only account-link modal, whichever step it is on. */ function LinkModalHost() { - const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); + const { linkModalOpen, linkModalMode, closeLinkModal, connectOutcome } = + useUI(); + // Ask once a session while the instance is unlinked, rather than waiting to be found. + useConnectPrompt(); + + // Mounted only while open, so closing discards the flow. Kept mounted, an interrupted hand-off + // stays flagged and every later open resumes on the ghost step with no way forward. + if (!linkModalOpen) return null; return ( ); } diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 8e77c20830..c7b87f7388 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -11,6 +11,7 @@ import { Policies } from "@portal/views/Policies"; import { EditorAdmin } from "@portal/views/EditorAdmin"; import { Infrastructure } from "@portal/views/Infrastructure"; import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; +import { ConnectGuardedRoute } from "@portal/components/account-link/ConnectGuardedRoute"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; // Lazy so the generated docs manifest (bundled JSON) lands in its own chunk. @@ -32,13 +33,24 @@ export function ViewRouter() { } /> } /> } /> + {/* Building and editing need a linked account. Gated at the route so every way in is + covered: the list, the Documents review queue, the Connect flow's next steps, and a + typed URL. */} } + element={ + + + + } /> } + element={ + + + + } /> } /> {/* Source create/edit is a modal on the list now; old deep links land there. */} diff --git a/frontend/editor/src/portal/components/ConnectAccountRail.css b/frontend/editor/src/portal/components/ConnectAccountRail.css new file mode 100644 index 0000000000..9f4ad4a77e --- /dev/null +++ b/frontend/editor/src/portal/components/ConnectAccountRail.css @@ -0,0 +1,53 @@ +/* ──────────────────────────────────────────────────────────────────────── */ +/* Connect rail — the ambient prompt above Home's deployment card */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/* Flat and quiet: it sits above the hero every visit until the account is + connected, so it has to be legible without competing with the card below it. */ + +.portal-connect-rail { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.875rem 1rem; + margin-bottom: 0.75rem; + border: 1px solid var(--c-border); + border-radius: 0.5rem; + background: var(--c-surface); +} + +.portal-connect-rail__text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.portal-connect-rail__title { + font-size: 0.875rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-connect-rail__sub { + font-size: 0.8125rem; + color: var(--c-text-muted); +} + +.portal-connect-rail__actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + flex: none; +} + +@media (max-width: 40rem) { + .portal-connect-rail { + flex-direction: column; + align-items: flex-start; + } + + .portal-connect-rail__actions { + margin-left: 0; + } +} diff --git a/frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx b/frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx new file mode 100644 index 0000000000..c813db9b3c --- /dev/null +++ b/frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { ConnectAccountRail } from "@portal/components/ConnectAccountRail"; + +/** + * Both halves of "can link but has not" come from outside the component, and the global mock answers + * app-config without the flag — so a story that wants the rail has to ask for it. + */ +const canLink = { + msw: { + handlers: [ + http.get("/api/v1/config/app-config", () => + HttpResponse.json({ accountLinkAvailable: true }), + ), + ], + }, +}; + +const withLinkState = (state: LinkState) => [ + (Story: () => React.JSX.Element) => ( + + + + ), +]; + +const meta: Meta = { + title: "Portal/AccountLink/ConnectAccountRail", + component: ConnectAccountRail, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** On Home: the ask, plus a way to defer it for this session. */ +export const Default: Story = { + parameters: canLink, + decorators: withLinkState("unlinked"), +}; + +/** Connected, so the rail removes itself. Renders nothing on purpose. */ +export const Hidden: Story = { + parameters: canLink, + decorators: withLinkState("linked-free"), +}; diff --git a/frontend/editor/src/portal/components/ConnectAccountRail.tsx b/frontend/editor/src/portal/components/ConnectAccountRail.tsx new file mode 100644 index 0000000000..9f1e09c0ce --- /dev/null +++ b/frontend/editor/src/portal/components/ConnectAccountRail.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; +import "@portal/components/ConnectAccountRail.css"; + +const DISMISSED_KEY = "portal::connect-rail-dismissed"; + +function readDismissed(): boolean { + try { + return sessionStorage.getItem(DISMISSED_KEY) === "true"; + } catch { + return false; + } +} + +/** Session-scoped dismissal, so the ask comes back until it is answered rather than for good. */ +export function ConnectAccountRail() { + const { t } = useTranslation(); + const { gated, loading, connect } = useConnectGate(); + const [dismissed, setDismissed] = useState(readDismissed); + + if (loading || !gated || dismissed) return null; + + const dismiss = () => { + try { + sessionStorage.setItem(DISMISSED_KEY, "true"); + } catch { + // Storage refusing is no reason to leave the rail stuck on screen. + } + setDismissed(true); + }; + + return ( +
+
+ + {t("portal.accountLink.rail.title", "Connect your Stirling account")} + + + {t( + "portal.accountLink.rail.sub", + "Unlocks teams, PDF processor, pipelines, and policies. PDF editing stays free.", + )} + +
+
+ + +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/HomeHero.tsx b/frontend/editor/src/portal/components/HomeHero.tsx index 954eac9908..f2fdd36b51 100644 --- a/frontend/editor/src/portal/components/HomeHero.tsx +++ b/frontend/editor/src/portal/components/HomeHero.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { Skeleton } from "@app/ui"; import { useUI } from "@portal/contexts/UIContext"; import { EditorStatusCard } from "@portal/components/EditorStatusCard"; +import { ConnectAccountRail } from "@portal/components/ConnectAccountRail"; import { ControlledDealStatusHero } from "@portal/components/procurement/ProcurementBanner"; import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; import { useProcurement } from "@portal/components/procurement/useProcurement"; @@ -28,6 +29,7 @@ export function HomeHero() { return ( <> + {procurement.loading ? ( // Hold the rail's shape rather than committing to a footer: branching before the snapshot // lands paints the no-deal rail first, flashing on every refresh of an active deal. diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 2895ad4ec4..6d4cad1945 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, @@ -43,6 +44,7 @@ export function Sidebar() { const { displayName, profilePictureUrl } = useAccountIdentity(); const credits = useFreeCreditsSummary(); const openPlan = useOpenPlan(); + const { gated, connect } = useConnectGate(); // Collapse is a desktop-only affordance: on mobile the sidebar is an // off-canvas drawer, so the icon-rail state never applies there. @@ -67,6 +69,9 @@ export function Sidebar() { closeMobileNav(); if (entry.externalUrl) { window.open(entry.externalUrl, "_blank", "noopener,noreferrer"); + } else if (entry.requiresLink && gated) { + // Ask here: navigating first strands them on a page with nothing on it. + connect(); } else { setActiveView(id as ViewId); } diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx index fab501da4a..2fcbfdea0f 100644 --- a/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx @@ -1,21 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { Modal } from "@app/ui"; -import { PORTAL_BASENAME } from "@app/routes/portalBasename"; -import { withBasePath } from "@app/constants/app"; -import { - completeConnect, - startConnect, - type ConnectPhase, -} from "@portal/api/link"; +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { completeConnect, type ConnectPhase } from "@portal/api/link"; import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; import { useAccountLinkContext } from "@portal/contexts/AccountLinkContext"; -import { - ConnectCallbackView, - type ConnectCallbackState, -} from "@portal/components/account-link/ConnectCallbackView"; -import "@portal/views/ConnectCallback.css"; +import { useUI } from "@portal/contexts/UIContext"; +import type { ConnectCallbackState } from "@portal/components/account-link/ConnectCallbackView"; /** What the callback route hands over, read from the URL fragment before stripping it. */ export interface AccountLinkReturn { @@ -30,41 +19,23 @@ interface LocationState { } /** - * Finishes the handshake and reports the outcome, over the portal the admin - * started from. - * - * Mounted alongside the other portal-wide modal rather than being its own route: - * the result is a step in a task, so the page behind it should still be there. + * Renders nothing: the result belongs on step 3 of the dialog the admin left, so this publishes the + * outcome and the single dialog host reopens there. Mounted app-wide because the callback route + * only reads the fragment and navigates, so it is gone by the time there is an outcome. */ export function ConnectCallbackHost() { const location = useLocation(); - const navigate = useNavigate(); - const { t } = useTranslation(); const { refresh } = useAccountLinkContext(); + const { publishConnectOutcome } = useUI(); const handover = (location.state as LocationState | null)?.accountLinkReturn; - const [state, setState] = useState(null); - const [sessionRestored, setSessionRestored] = useState(false); - const nonceRef = useRef(null); const startedRef = useRef(false); - const finish = useCallback( - async (nonce: string) => { - setState("working"); - try { - const outcome = toViewState((await completeConnect(nonce)).phase); - setState(outcome); - // The portal read its status on mount, before this existed. Without this - // the page behind the modal still says unlinked until a reload. - if (outcome === "linked") await refresh(); - } catch { - // Could not reach our own backend. The handshake is still open, so this - // is worth another attempt rather than a restart. - setState("retry"); - } - }, - [refresh], - ); + // Refs so the effect runs on the hand-over alone: it consumes a single-use nonce. + const publishRef = useRef(publishConnectOutcome); + publishRef.current = publishConnectOutcome; + const refreshRef = useRef(refresh); + refreshRef.current = refresh; useEffect(() => { if (!handover || startedRef.current) return; @@ -72,17 +43,16 @@ export function ConnectCallbackHost() { const { type, nonce, accessToken, refreshToken } = handover; if (type !== "link" || !nonce) { - setState("malformed"); + publishRef.current({ state: "malformed", sessionRestored: false }); return; } - nonceRef.current = nonce; void (async () => { + let sessionRestored = false; if (accessToken && refreshToken) { try { const supabase = ensureSaasSupabase(); - // Logged, not swallowed: silently this resurfaces later as "session - // expired" on the usage page, with nothing tying it back here. + // Logged, not swallowed: this resurfaces later as "session expired" otherwise. if (!supabase) { console.warn( "[account-link] no Supabase client: VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY are not set for this build", @@ -95,7 +65,7 @@ export function ConnectCallbackHost() { if (error) { console.warn("[account-link] setSession failed:", error.message); } else { - setSessionRestored(true); + sessionRestored = true; } } } catch (e) { @@ -106,68 +76,36 @@ export function ConnectCallbackHost() { "[account-link] callback carried no tokens; the approval page had no session to pass", ); } - await finish(nonce); + + await claim(nonce, sessionRestored); })(); - }, [handover, finish]); - /** - * Retry means different things either side of a still-valid handshake: finish the one we have, or open a new one when it is past saving. - */ - const onRetry = useCallback(() => { - if (state === "retry" && nonceRef.current) { - void finish(nonceRef.current); - return; + /** Passes itself as {@code reclaim} so Try again re-claims rather than opening a handshake. */ + async function claim(nonce: string, sessionRestored: boolean) { + publishRef.current({ state: "working", sessionRestored }); + const again = () => void claim(nonce, sessionRestored); + try { + const state = toViewState((await completeConnect(nonce)).phase); + publishRef.current({ + state, + sessionRestored, + reclaim: state === "retry" ? again : undefined, + }); + // Without this the page behind the dialog says unlinked until a reload. + if (state === "linked") await refreshRef.current(); + } catch { + // Our own backend is unreachable; the handshake is untouched, so retrying beats restarting. + publishRef.current({ state: "retry", sessionRestored, reclaim: again }); + } } - setState("working"); - // Same callback the modal sends. Without it the backend falls back to the bare - // origin, which drops the app's base path and lands the return on nothing. - void startConnect( - window.location.hostname, - new URL( - withBasePath("/account-link/callback"), - window.location.origin, - ).toString(), - ) - .then((status) => { - if (status.authorizeUrl) { - window.location.assign(status.authorizeUrl); - } else { - setState("rejected"); - } - }) - .catch(() => setState("retry")); - }, [state, finish]); + }, [handover]); - // Drops the handover with it, so a back navigation does not reopen the result. - const done = useCallback(() => { - setState(null); - navigate(PORTAL_BASENAME, { replace: true }); - }, [navigate]); - - if (!state) return null; - - return ( - - - - ); + return null; } /** - * PENDING and UNAVAILABLE collapse into one "try again" state: both mean the handshake is intact but unfinished, which is the same thing to do about it. + * PENDING and UNAVAILABLE collapse into one "try again" state: both mean the handshake is intact but + * unfinished, which is the same thing to do about it. */ function toViewState(phase: ConnectPhase): ConnectCallbackState { switch (phase) { diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx index 4a22592e45..ae9e268849 100644 --- a/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx @@ -1,5 +1,7 @@ import { useTranslation } from "react-i18next"; -import { Banner, Button, Spinner } from "@app/ui"; +import { Banner, Spinner } from "@app/ui"; +import { ConnectDoneSlide } from "@portal/components/account-link/connect/ConnectDoneSlide"; +import "@portal/components/account-link/connect/connect.css"; /** Outcomes of returning from the approval page. */ export type ConnectCallbackState = @@ -10,26 +12,39 @@ export type ConnectCallbackState = | "rejected" | "malformed"; -export interface ConnectCallbackViewProps { +export interface ConnectOutcome { state: ConnectCallbackState; /** True once the SaaS session landed, regardless of how the link itself went. */ sessionRestored: boolean; - onRetry: () => void; + /** Present only while the handshake is still open, so a retry re-claims rather than opening one. */ + reclaim?: () => void; +} + +export function isRetryableOutcome(state: ConnectCallbackState): boolean { + return state !== "linked" && state !== "malformed"; +} + +export interface ConnectCallbackViewProps { + state: ConnectCallbackState; + sessionRestored: boolean; onDone: () => void; } -/** Presentation for the account-link callback. */ +/** + * Five states in one step: a failed link is still step 3 of the flow they started, and a separate + * error dialog would discard the progress bar that says where they are. Actions live in the + * dialog's footer, not here, so they stay where steps 1 and 2 put them. + */ export function ConnectCallbackView({ state, sessionRestored, - onRetry, onDone, }: ConnectCallbackViewProps) { const { t } = useTranslation(); if (state === "working") { return ( -
+

{t( @@ -44,21 +59,14 @@ export function ConnectCallbackView({ if (state === "linked") { return (

- +

{t( - "portal.accountLink.connect.callback.linked.body", - "This server is connected to your Stirling account.", + "portal.accountLink.connect.done.lede", + "This server now runs against your Stirling account.", )} - - {/* The inverse of the failure note below: the link took but the sign-in did - not, which otherwise only shows up later as "session expired" on a page - that gives no hint the two are related. */} +

+ {/* Link took, sign-in did not: otherwise this resurfaces later as "session expired" with + nothing tying it back here. */} {sessionRestored ? null : (

{t( @@ -67,22 +75,19 @@ export function ConnectCallbackView({ )}

)} - +
); } - const { tone, title, body, retryable } = failure(state, t); + const { tone, title, body } = failure(state, t); return (
{body} - {/* The SaaS sign-in and the server link are separate outcomes. Say so when - one worked and the other did not, or the admin re-runs the whole thing - to fix a problem that is already half solved. */} + {/* Two separate outcomes: without this the admin re-runs the lot to fix a half-solved + problem. */} {sessionRestored ? (

{t( @@ -91,11 +96,6 @@ export function ConnectCallbackView({ )}

) : null} -
); } @@ -115,7 +115,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.expired.body", "Connection requests are short lived. Start another one.", ), - retryable: true, }; case "rejected": return { @@ -128,7 +127,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.rejected.body", "This request was declined or has already been used. Start another one if that was not intended.", ), - retryable: true, }; case "malformed": return { @@ -141,7 +139,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.malformed.body", "This page was opened without a valid connection response. Start the connection from settings.", ), - retryable: false, }; default: return { @@ -156,7 +153,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.unfinished.body", "Stirling did not confirm the connection. This is usually temporary.", ), - retryable: true, }; } } diff --git a/frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx b/frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx new file mode 100644 index 0000000000..e5bd45eff9 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx @@ -0,0 +1,26 @@ +import { useEffect, type ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +interface Props { + children: ReactNode; + fallback: string; +} + +/** + * At the route, not on the buttons: the pipeline builder is reachable from its list, the Documents + * queue, the connect flow's next steps and a typed URL, and a guard per entry point is one more to + * remember each time someone adds a link. + */ +export function ConnectGuardedRoute({ children, fallback }: Props) { + const { gated, loading, connect } = useConnectGate(); + + useEffect(() => { + if (gated) connect(); + }, [gated, connect]); + + // Unknown is not gated: bouncing first would throw a linked admin off a page they are entitled to. + if (loading) return null; + if (gated) return ; + return <>{children}; +} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.css b/frontend/editor/src/portal/components/account-link/LinkAccountModal.css deleted file mode 100644 index bc96172f0a..0000000000 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.css +++ /dev/null @@ -1,26 +0,0 @@ -/* Connect-account modal. Imported by the component rather than relying on the - account-link view's stylesheet: this modal is mounted at the app root, so it - renders on pages that never import that view. */ - -.portal-link__modal-body { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.portal-link__steps { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin: 0; - padding-left: 1.25rem; - font-size: 0.875rem; - line-height: 1.5; - color: var(--c-text-muted); -} - -.portal-link__modal-actions { - display: flex; - justify-content: flex-end; - gap: 0.5rem; -} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx index 018d435b31..515b740e59 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx @@ -1,34 +1,66 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { act, render, waitFor } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; -/** The modal every "link account" CTA in the portal opens. */ -const { startConnect, startReauth } = vi.hoisted(() => ({ +/** The step machine: what drives each step, and what must not skip or repeat one. */ +const { startConnect, startReauth, fetchWallet, EMAIL } = vi.hoisted(() => ({ startConnect: vi.fn(), startReauth: vi.fn(), + fetchWallet: vi.fn(), + EMAIL: "admin@acme.example", })); vi.mock("@portal/api/link", () => ({ startConnect, startReauth })); +vi.mock("@portal/api/billing", () => ({ fetchWallet })); vi.mock("@portal/auth/saasSupabase", () => ({ isSaasSupabaseConfigured: true, + // Step 3 reads the connected account's email off this session. + ensureSaasSupabase: () => ({ + auth: { + getSession: () => + Promise.resolve({ data: { session: { user: { email: EMAIL } } } }), + }, + }), })); import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; +import type { ConnectOutcome } from "@portal/components/account-link/ConnectCallbackView"; +import { freeWallet } from "@portal/components/billing/walletFixtures"; const AUTHORIZE = "http://localhost:5174/link?request=req-1"; -function renderModal(mode?: "link" | "reauth") { +const BENEFITS = "Pipelines, policies, sources and audit"; +const GHOST = /Taking you to stirling\.com/; +const CONNECT = /Connect Stirling account/; + +function renderModal( + mode?: "link" | "reauth", + outcome: ConnectOutcome | null = null, +) { return render( - - {}} mode={mode} /> - , + + + {}} + mode={mode} + outcome={outcome} + /> + + , ); } -/** Clicks the primary action (the secondary one is Cancel). */ -function clickContinue(getAllByRole: (role: string) => HTMLElement[]) { - const buttons = getAllByRole("button"); - act(() => buttons[buttons.length - 1].click()); +function click(label: string | RegExp) { + act(() => screen.getByRole("button", { name: label }).click()); +} + +/** Read off the body because the dialog portals out; the badge itself is uninterpolated here. */ +function filledSteps(): number { + return document.body.querySelectorAll( + ".portal-stepmodal__progress .is-filled", + ).length; } describe("LinkAccountModal", () => { @@ -36,6 +68,7 @@ describe("LinkAccountModal", () => { beforeEach(() => { vi.clearAllMocks(); + fetchWallet.mockResolvedValue(freeWallet); startConnect.mockResolvedValue({ phase: "PENDING", authorizeUrl: AUTHORIZE, @@ -54,28 +87,39 @@ describe("LinkAccountModal", () => { value: { origin: "http://localhost:5173", hostname: "localhost", + href: "http://localhost:5173/app", + search: "", assign, }, }); }); + it("opens on the pitch, and asks nothing of the backend until told", () => { + renderModal(); + + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(screen.getByRole("button", { name: CONNECT })).toBeTruthy(); + expect(filledSteps()).toBe(1); + expect(startConnect).not.toHaveBeenCalled(); + }); + it("offers no sign-in form, because a sign-in started here cannot complete", () => { const { container } = renderModal(); + click(CONNECT); - // The provider buttons this modal used to carry sent the admin to Stirling and - // abandoned them there. Nothing should collect credentials on this origin. + // A sign-in started on this origin cannot complete, so nothing here may collect credentials. expect(container.querySelector("input[type=password]")).toBeNull(); expect(container.querySelector("input[type=email]")).toBeNull(); }); - it("starts a link handshake and hands the browser to Stirling", async () => { - const { getAllByRole } = renderModal(); - - clickContinue(getAllByRole); + it("hands over on the first click, showing the ghost while it goes", async () => { + renderModal(); + click(CONNECT); await waitFor(() => expect(startConnect).toHaveBeenCalled()); - // Callback built from this page's own origin, which the backend then checks - // against the request's Origin header. + expect(screen.getByText(GHOST)).toBeTruthy(); + expect(filledSteps()).toBe(2); + // The backend checks this against the request's Origin header. expect(startConnect).toHaveBeenCalledWith( "localhost", "http://localhost:5173/account-link/callback", @@ -84,13 +128,16 @@ describe("LinkAccountModal", () => { expect(startReauth).not.toHaveBeenCalled(); }); - it("uses the reauth endpoint when only the session needs renewing", async () => { - const { getAllByRole } = renderModal("reauth"); + it("uses the reauth endpoint, with no pitch and no steps", async () => { + renderModal("reauth"); - clickContinue(getAllByRole); + // A server that is already connected is not sold anything. + expect(screen.queryByText(BENEFITS)).toBeNull(); + expect(screen.queryByText(/Step 1 of 3/)).toBeNull(); - // A different endpoint on purpose: reauth presents the device credential so - // Stirling pins the handshake to the team that already owns this server. + click(/Sign in again/); + + // A different endpoint: reauth presents the credential, so the team is pinned server-side. await waitFor(() => expect(startReauth).toHaveBeenCalledWith( "http://localhost:5173/account-link/callback", @@ -100,14 +147,18 @@ describe("LinkAccountModal", () => { await waitFor(() => expect(assign).toHaveBeenCalledWith(AUTHORIZE)); }); - it("stays put and explains itself when the handshake cannot start", async () => { + it("falls back to step 1 with the reason when the handshake cannot start", async () => { startConnect.mockRejectedValue(new Error("offline")); - const { getAllByRole } = renderModal(); - clickContinue(getAllByRole); + renderModal(); + click(CONNECT); await waitFor(() => expect(startConnect).toHaveBeenCalled()); expect(assign).not.toHaveBeenCalled(); + // The ghost unmounts when the request settles, so the reason lands on step 1. + expect(await screen.findByText(/outbound network access/)).toBeTruthy(); + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(filledSteps()).toBe(1); }); it("does not navigate when there is nothing to navigate to", async () => { @@ -119,10 +170,85 @@ describe("LinkAccountModal", () => { teamId: 7, }); - const { getAllByRole } = renderModal(); - clickContinue(getAllByRole); + renderModal(); + click(CONNECT); await waitFor(() => expect(startConnect).toHaveBeenCalled()); expect(assign).not.toHaveBeenCalled(); }); + + /** Busy is never cleared on success, because the page was meant to be gone. */ + describe("coming back from a hand-off that never completed", () => { + it("clears the in-flight flag when the page is shown again", async () => { + renderModal(); + click(CONNECT); + + await waitFor(() => expect(screen.getByText(GHOST)).toBeTruthy()); + + act(() => { + window.dispatchEvent(new Event("pageshow")); + }); + + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(filledSteps()).toBe(1); + }); + + /** + * Not the close path itself (the host unmounts, so a fresh mount is clean by construction) but + * the property behind it: hoist the flag into UIContext and the trap returns, failing here. + */ + it("keeps the in-flight flag local, so a fresh mount cannot inherit one", async () => { + const first = renderModal(); + click(CONNECT); + await waitFor(() => expect(screen.getByText(GHOST)).toBeTruthy()); + + first.unmount(); + renderModal(); + + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(screen.queryByText(GHOST)).toBeNull(); + }); + }); + + describe("resuming after the round trip", () => { + it("lands on step 3 rather than restarting the pitch", async () => { + renderModal("link", { state: "linked", sessionRestored: true }); + + expect( + await screen.findByText(/now runs against your Stirling account/), + ).toBeTruthy(); + expect(screen.queryByText(BENEFITS)).toBeNull(); + // Left on 2 of 3, arrived on 3: the whole reason the bar spans the redirect. + expect(filledSteps()).toBe(3); + expect(await screen.findByText(EMAIL)).toBeTruthy(); + expect(await screen.findByText("Invite your team")).toBeTruthy(); + }); + + it("opens a fresh handshake for a spent one, showing the ghost again", async () => { + renderModal("link", { state: "expired", sessionRestored: false }); + + expect(await screen.findByText("Request expired")).toBeTruthy(); + click(/Try again/); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + // Busy outranks the stale outcome, or they sit on "Request expired" until the browser goes. + expect(screen.getByText(GHOST)).toBeTruthy(); + }); + + it("offers no retry while the claim is still in flight", async () => { + renderModal("link", { state: "working", sessionRestored: false }); + + expect(await screen.findByText(/Finishing the connection/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Try again/ })).toBeNull(); + }); + + it("does not offer a retry for a response it could not read", async () => { + renderModal("link", { state: "malformed", sessionRestored: false }); + + expect( + await screen.findByText(/Could not read the response/), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Try again/ })).toBeNull(); + }); + }); }); diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx index 8bd47a9eb4..2b6315eb17 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx @@ -1,143 +1,205 @@ -import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Banner, Button, Modal } from "@app/ui"; -import { withBasePath } from "@app/constants/app"; -import { startConnect, startReauth } from "@portal/api/link"; -import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; -import "@portal/components/account-link/LinkAccountModal.css"; +import { Button } from "@app/ui"; +import { FlowModal } from "@portal/components/shared/FlowModal"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; +import { ConnectAskStep } from "@portal/components/account-link/connect/ConnectAskStep"; +import { ConnectHandoffGhost } from "@portal/components/account-link/connect/ConnectHandoffGhost"; +import { + ConnectCallbackView, + isRetryableOutcome, + type ConnectOutcome, +} from "@portal/components/account-link/ConnectCallbackView"; +import { useConnectHandoff } from "@portal/hooks/useConnectHandoff"; +import "@portal/views/ConnectCallback.css"; + +/** + * Ordered, so a step's position in this list is its number and the list's length is the total. + * Adding or removing a step means editing this and its arm of `stepBody`, nothing else. + */ +const STEP_ORDER = ["ask", "handoff", "outcome"] as const; + +type StepId = (typeof STEP_ORDER)[number]; interface Props { open: boolean; onClose: () => void; - /** - * "link" connects this server to a team for the first time; "reauth" only re-establishes the browser's Stirling session for a server that is already linked. - */ + /** "reauth" only re-establishes the browser session, so it stays one step with no pitch. */ mode?: "link" | "reauth"; + /** Published by the callback route; present means the admin is returning from Stirling. */ + outcome?: ConnectOutcome | null; } -/** Sends the admin off to Stirling to connect this server. */ -export function LinkAccountModal({ open, onClose, mode = "link" }: Props) { +/** + * The progress bar spans the redirect on purpose: the admin leaves on the hand-off and returns on + * the outcome step of the dialog they left, rather than being greeted by a different one. + */ +export function LinkAccountModal({ + open, + onClose, + mode = "link", + outcome = null, +}: Props) { const { t } = useTranslation(); const reauth = mode === "reauth"; - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const handoff = useConnectHandoff(reauth); - const begin = useCallback(async () => { - setBusy(true); - setError(null); - try { - const callbackUrl = new URL( - withBasePath("/account-link/callback"), - window.location.origin, - ).toString(); - const status = reauth - ? await startReauth(callbackUrl) - : await startConnect(window.location.hostname, callbackUrl); - if (status.authorizeUrl) { - window.location.assign(status.authorizeUrl); - return; - } - // Already linked, or a handshake we cannot act on. Nothing to navigate to. - setError( - t( - "portal.accountLink.modal.noAuthorizeUrl", - "Stirling did not return somewhere to continue. Try again in a moment.", + // Busy outranks a stale outcome, or a retry sits on the old result until the browser leaves. + let step: StepId = "ask"; + if (handoff.busy) step = "handoff"; + else if (outcome) step = "outcome"; + + const title = stepTitle(); + const current = STEP_ORDER.indexOf(step) + 1; + + // Re-auth is one step, so it carries no count and no progress bar. + const stepChrome = reauth + ? {} + : { + step: current, + total: STEP_ORDER.length, + stepLabel: t( + "portal.accountLink.connect.step", + "Step {{current}} of {{total}}", + { current, total: STEP_ORDER.length }, ), - ); - } catch { - setError( - t( - "portal.accountLink.modal.startFailed", - "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again.", - ), - ); - } finally { - setBusy(false); - } - }, [reauth, t]); + }; return ( - -
-
    -
  1. - {t( - "portal.accountLink.modal.step1", - "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on.", - )} -
  2. -
  3. - {t( - "portal.accountLink.modal.step2", - "You check this server's address and approve it. A team owner has to do this the first time.", - )} -
  4. -
  5. - {t( - "portal.accountLink.modal.step3", - "Stirling brings you straight back here and finishes up.", - )} -
  6. -
- - {!isSaasSupabaseConfigured && ( - - {t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "} - VITE_SUPABASE_URL{" "} - {t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "} - VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "} - {t( - "portal.accountLink.modal.loginNotConfigured.after", - "so this server can finish the connection when you come back.", - )} - - )} - - {error && {error}} - -
- - -
-
-
+ + {stepBody()} + ); + + function stepTitle(): string { + if (reauth) { + return t("portal.accountLink.modal.reauthTitle", "Sign in again"); + } + if (step === "ask") { + return t( + "portal.accountLink.modal.linkTitle", + "Connect your Stirling account", + ); + } + if (step === "handoff") { + return t("portal.accountLink.connect.handoff.title", "Connecting"); + } + if (outcome?.state === "linked") { + return t("portal.accountLink.connect.done.title", "Connected"); + } + return t("portal.accountLink.connect.done.pendingTitle", "Almost there"); + } + + function stepBody() { + switch (step) { + case "ask": + return ; + case "handoff": + return ; + case "outcome": + return outcome ? ( + + ) : null; + } + } + + function closeButton() { + return ( + + ); + } + + function retryButton(onRetry: () => void) { + return ( + + ); + } + + function stepFooter() { + if (step === "ask") { + const dismiss = reauth + ? t("portal.accountLink.modal.cancel", "Cancel") + : t("portal.accountLink.connect.notNow", "Not now"); + const start = reauth + ? t("portal.accountLink.modal.continueReauth", "Sign in again") + : t("portal.accountLink.connect.start", "Connect Stirling account"); + return ( + <> + + + + ); + } + + // The request is out and the browser is leaving; Close so a stall is not a dead end. + if (step === "handoff") { + return ( + <> + + {closeButton()} + + ); + } + + // A retry over a call that has not answered is how you get two handshakes. + if (outcome?.state === "working") { + return ( + <> + + {closeButton()} + + ); + } + + // Still open: re-claim rather than spend the approval a leader gave by hand. + if (outcome?.reclaim) { + return ( + <> + {closeButton()} + {retryButton(outcome.reclaim)} + + ); + } + + if (outcome && isRetryableOutcome(outcome.state)) { + return ( + <> + {closeButton()} + {retryButton(handoff.begin)} + + ); + } + + return ( + <> + + + + ); + } } diff --git a/frontend/editor/src/portal/components/account-link/LinkGate.tsx b/frontend/editor/src/portal/components/account-link/LinkGate.tsx deleted file mode 100644 index d5743d0041..0000000000 --- a/frontend/editor/src/portal/components/account-link/LinkGate.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import { Banner, Button } from "@app/ui"; -import { useLink } from "@portal/contexts/LinkContext"; -import { useUI } from "@portal/contexts/UIContext"; - -interface Props { - /** The billable feature — rendered only when the org is linked. */ - children: ReactNode; - /** Feature name for the lock copy, e.g. "AI extraction". */ - feature?: string; -} - -/** - * Gates billable features on the account-link state. When the org is unlinked it - * renders a "link to unlock" prompt instead of the feature; once linked (free or - * subscribed) the children render. Drop this around any surface that should only - * work against a linked SaaS wallet. - */ -export function LinkGate({ children, feature }: Props) { - const { t } = useTranslation(); - const { featuresUnlocked } = useLink(); - const { openLinkModal } = useUI(); - - if (featuresUnlocked) return <>{children}; - - return ( - openLinkModal()}> - {t("portal.accountLink.gate.action", "Link account")} - - } - /> - ); -} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx new file mode 100644 index 0000000000..7c795645b7 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; +import { Banner } from "@app/ui"; +import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; +import { ConnectBenefitsSlide } from "@portal/components/account-link/connect/ConnectBenefitsSlide"; +import "@portal/components/account-link/connect/connect.css"; + +interface Props { + /** Re-auth says why it is being asked; a first link is pitched instead. */ + reauth: boolean; + /** A hand-off that failed to start drops back here, so this is where its reason belongs. */ + error?: string | null; +} + +export function ConnectAskStep({ reauth, error }: Props) { + const { t } = useTranslation(); + + return ( + <> + {reauth ? ( +

+ {t( + "portal.accountLink.connect.handoff.reauthLede", + "Your Stirling session expired. Signing in again keeps usage and billing visible. This server stays connected either way.", + )} +

+ ) : ( + + )} + + {!isSaasSupabaseConfigured && ( + + {t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "} + VITE_SUPABASE_URL{" "} + {t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "} + {t( + "portal.accountLink.modal.loginNotConfigured.after", + "so this server can finish the connection when you come back.", + )} + + )} + + {error && {error}} + + ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx new file mode 100644 index 0000000000..bdad6f32b0 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConnectBenefitsSlide } from "@portal/components/account-link/connect/ConnectBenefitsSlide"; + +const meta: Meta = { + title: "Portal/AccountLink/Connect/BenefitsSlide", + component: ConnectBenefitsSlide, +}; +export default meta; +type Story = StoryObj; + +/** Step 1 of the Connect flow: the case for linking. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx new file mode 100644 index 0000000000..a0258f7802 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from "react-i18next"; +import "@portal/components/account-link/connect/connect.css"; + +/** + * Processor is one row naming its parts rather than four competing ones, and credits come last: + * first, and the screen reads as a price list. + * + *

TODO(#7712): the credits row promises a monthly allowance the billing model does not grant — + * {@code freeGrantUnits} is a one-time lifetime pool — so either the grant becomes recurring or the + * copy drops "per month" before this reaches customers. + */ +export function ConnectBenefitsSlide() { + const { t } = useTranslation(); + + const unlocks: { key: string; label: string; detail: string }[] = [ + { + key: "processor", + label: t( + "portal.accountLink.connect.benefits.processorLabel", + "Processor", + ), + detail: t( + "portal.accountLink.connect.benefits.processorDetail", + "Pipelines, policies, sources and audit", + ), + }, + { + key: "teams", + label: t("portal.accountLink.connect.benefits.teamsLabel", "Teams"), + detail: t( + "portal.accountLink.connect.benefits.teamsDetail", + "Free for up to 5 users", + ), + }, + { + key: "credits", + label: t("portal.accountLink.connect.benefits.creditsLabel", "Credits"), + detail: t( + "portal.accountLink.connect.benefits.creditsDetail", + "500 free per month", + ), + }, + ]; + + return ( +

+ {unlocks.map((unlock) => ( +
+
{unlock.label}
+
{unlock.detail}
+
+ ))} +
+ ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx new file mode 100644 index 0000000000..fa57d33fbf --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; + +/** + * The figure is checkable against a real account, so it must be the wallet's and never a hardcoded + * 500. The upgrade row appears only on a nearly spent trial: a prompt to pay is the wrong note on a + * screen confirming a free connection. + */ +const { fetchWallet } = vi.hoisted(() => ({ fetchWallet: vi.fn() })); +vi.mock("@portal/api/billing", () => ({ fetchWallet })); + +import { ConnectDoneSlide } from "@portal/components/account-link/connect/ConnectDoneSlide"; +import { freeWallet } from "@portal/components/billing/walletFixtures"; + +/** The shared fixture, wound down to the balance under test. */ +const wallet = (freeRemaining: number) => ({ + ...freeWallet, + freeRemaining, + billableUsed: freeWallet.freeAllowance - freeRemaining, +}); + +const SWITCH_ON = /Switch on the Processor/; + +const renderDone = () => + render( + + + {}} /> + + , + ); + +describe("ConnectDoneSlide", () => { + it("shows the wallet's remaining balance, not a fixed grant", async () => { + fetchWallet.mockResolvedValue(wallet(128)); + renderDone(); + await waitFor(() => expect(screen.getByText("128")).toBeTruthy()); + }); + + it("shows a spent grant as zero rather than hiding it", async () => { + fetchWallet.mockResolvedValue(wallet(0)); + renderDone(); + await waitFor(() => expect(screen.getByText("0")).toBeTruthy()); + }); + + it("omits the meter entirely when the wallet cannot be read", async () => { + fetchWallet.mockRejectedValue(new Error("not linked")); + renderDone(); + await waitFor(() => + expect(screen.getByText("Invite your team")).toBeTruthy(), + ); + expect(document.querySelector(".paygf-meter")).toBeNull(); + expect(screen.queryByText(SWITCH_ON)).toBeNull(); + }); + + it("asks the admin to switch the Processor on once the trial is nearly gone", async () => { + fetchWallet.mockResolvedValue(wallet(40)); + renderDone(); + await waitFor(() => expect(screen.getByText(SWITCH_ON)).toBeTruthy()); + const rows = [...document.querySelectorAll(".portal-connect__next-item")]; + expect(rows[0]?.textContent).toMatch(SWITCH_ON); + }); + + it("leaves the upgrade unmentioned while there is trial left to use", async () => { + fetchWallet.mockResolvedValue(wallet(500)); + renderDone(); + await waitFor(() => + expect(document.querySelector(".paygf-meter")).toBeTruthy(), + ); + expect(screen.queryByText(SWITCH_ON)).toBeNull(); + }); + + it("always offers the next steps", async () => { + fetchWallet.mockResolvedValue(wallet(500)); + renderDone(); + await waitFor(() => + expect(screen.getByText("Invite your team")).toBeTruthy(), + ); + expect(screen.getByText("Set up a pipeline")).toBeTruthy(); + expect(screen.getByText("Add a policy")).toBeTruthy(); + }); +}); diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx new file mode 100644 index 0000000000..4854048ab8 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { MeterBar, remainingMeter } from "@app/billing"; +import { fetchWallet, type Wallet } from "@portal/api/billing"; +import { useLinkedAccountEmail } from "@portal/hooks/useLinkedAccountEmail"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +import "@portal/components/billing/billing.css"; +import "@portal/components/account-link/connect/connect.css"; + +interface Props { + /** Closes the dialog first, so a next step does not land behind the overlay. */ + onNavigate: () => void; +} + +/** Below this the meter grows an upgrade row; above it the meter is information, not a prompt. */ +const LOW_CREDITS = 100; + +function Chevron() { + return ( + + + + ); +} + +/** + * The bar without the plan card around it: at dialog width that card's headline and price wrapped + * over four lines. Upgrading is a row rather than a button on the bar for the same reason, and it + * goes to Usage rather than starting checkout, which already lives there with its quotes and + * resumable bundle. + */ +export function ConnectDoneSlide({ onNavigate }: Props) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const email = useLinkedAccountEmail(); + const [wallet, setWallet] = useState(null); + + useEffect(() => { + let cancelled = false; + void fetchWallet() + .then((w) => { + if (!cancelled) setWallet(w); + }) + .catch(() => { + // The wallet needs a live SaaS session and a team that has finished provisioning. Neither + // is guaranteed the instant a link completes, and neither is worth blocking this screen on. + }); + return () => { + cancelled = true; + }; + }, []); + + const go = (path: string) => { + onNavigate(); + navigate(path); + }; + + const lowOnCredits = wallet != null && wallet.freeRemaining < LOW_CREDITS; + + const nextSteps: { key: string; label: string; path: string }[] = [ + ...(lowOnCredits + ? [ + { + key: "processor", + label: t( + "portal.accountLink.connect.done.switchOnProcessor", + "Switch on the Processor", + ), + path: toPortalPath(VIEW_PATHS.usage), + }, + ] + : []), + { + key: "team", + label: t( + "portal.accountLink.connect.done.inviteTeam", + "Invite your team", + ), + path: toPortalPath(VIEW_PATHS.users), + }, + { + key: "pipeline", + label: t( + "portal.accountLink.connect.done.buildPipeline", + "Set up a pipeline", + ), + path: `${toPortalPath(VIEW_PATHS.pipelines)}/new`, + }, + { + key: "policy", + label: t("portal.accountLink.connect.done.addPolicy", "Add a policy"), + path: toPortalPath(VIEW_PATHS.policies), + }, + ]; + + return ( + <> + {wallet && ( +
+ {/* No status chip: its tone goes red on an exhausted trial, which on a success screen + reads as something having gone wrong. */} + +
+ )} + + {/* Proves it landed on the account they meant. Read from the session the callback deposited, + so it is absent exactly when that hand-off failed — which the note above already says. */} + {email && ( +
+ + {t("portal.accountLink.connect.done.accountLabel", "Account")} + + {email} +
+ )} + +
    + {nextSteps.map((step) => ( +
  • + +
  • + ))} +
+ + ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx new file mode 100644 index 0000000000..251652de73 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next"; +import { Skeleton } from "@app/ui"; +import "@portal/components/account-link/connect/connect.css"; + +/** + * A ghost rather than a screen: the admin has already decided. It earns its place when the local + * backend is slow to open the handshake, where a blank dialog would look broken. + */ +export function ConnectHandoffGhost() { + const { t } = useTranslation(); + + return ( +
+

+ {t( + "portal.accountLink.connect.handoff.going", + "Taking you to stirling.com", + )} +

+ +
+ + + +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/connect.css b/frontend/editor/src/portal/components/account-link/connect/connect.css new file mode 100644 index 0000000000..393695bb0a --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/connect.css @@ -0,0 +1,112 @@ +/* Connect flow, the three account-link steps. + + Deliberately flat: bordered rows and plain type, not icon tiles or coloured + cards. FlowModal stacks and spaces the body, so these blocks own only their + own internals. */ + +.portal-connect__lede { + margin: 0; + font-size: 0.875rem; + line-height: 1.55; + color: var(--c-text-muted); +} + +.portal-connect__list { + margin: 0; + border-top: 1px solid var(--c-border); +} + +.portal-connect__row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--c-border); +} + +.portal-connect__row-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-connect__row-detail { + margin: 0; + font-size: 0.8125rem; + color: var(--c-text-muted); + text-align: right; +} + +/* ── Step 2: the ghost ──────────────────────────────────────────────────── */ + +.portal-connect__ghost { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-connect__ghost-bars { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* ── Step 3 ─────────────────────────────────────────────────────────────── */ + +/* Standalone, so it needs the top edge the benefits list would have given it. */ +.portal-connect__row--standalone { + border-top: 1px solid var(--c-border); +} + +/* The shared bar without the plan card it wears on Usage; padded into the row rhythm. */ +.portal-connect__meter { + padding: 0.25rem 0 0.5rem; +} + +/* Rows, not full-width buttons: three competing primaries were the loudest thing + on a screen whose job is to confirm. Same flat row as step 1, so both ends match. */ +.portal-connect__next { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; + border-top: 1px solid var(--c-border); +} + +/* The Account row above draws a bottom edge; both would render as a double line. */ +.portal-connect__row + .portal-connect__next { + border-top: 0; +} + +.portal-connect__next-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + padding: 0.625rem 0.25rem; + border: 0; + border-bottom: 1px solid var(--c-border); + background: none; + font: inherit; + font-size: 0.875rem; + font-weight: 500; + color: var(--c-text); + text-align: left; + cursor: pointer; +} + +.portal-connect__next-item:hover { + color: var(--c-accent-text); +} + +.portal-connect__next-chevron { + flex: none; + color: var(--c-text-subtle); +} + +.portal-connect__next-item:hover .portal-connect__next-chevron { + color: var(--c-accent-text); +} diff --git a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx deleted file mode 100644 index 20a9ab2e80..0000000000 --- a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; -import "@portal/components/billing/billing.css"; - -const meta: Meta = { - title: "Portal/Billing/LinkAccountPrompt", - component: LinkAccountPrompt, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** Unlinked billing page — CTA opens the login modal (UIProvider from the preview decorator). */ -export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx deleted file mode 100644 index 394668c56b..0000000000 --- a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card, EmptyState } from "@app/ui"; -import { useUI } from "@portal/contexts/UIContext"; - -/** - * Unlinked state — the billing page asks the admin to link their Stirling - * account to claim the 500-PDF free grant. The CTA opens the login modal - * directly (no detour through Settings). - */ -export function LinkAccountPrompt() { - const { t } = useTranslation(); - const { openLinkModal } = useUI(); - return ( - - openLinkModal()}> - {t("portal.billing.linkPrompt.cta", "Link Stirling account")} - - } - /> - - ); -} diff --git a/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx b/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx index f15d8ade51..097eebd52e 100644 --- a/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx +++ b/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx @@ -1,39 +1,82 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; -const linkState = { isLinked: false }; +/** + * Usage must not render while unlinked: it reports `linked` as a fact from its wallet read, so a + * browser holding a SaaS session with no link to this server would flip the whole portal to linked. + */ +const gate = { gated: false, loading: false, available: true }; +const connect = vi.fn(); +const applyLinkFacts = vi.fn(); + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ ...gate, connect, guard: (f: unknown) => f }), +})); vi.mock("@portal/contexts/LinkContext", () => ({ - useLink: () => linkState, - useApplyLinkFacts: () => vi.fn(), + useApplyLinkFacts: () => applyLinkFacts, })); vi.mock("@portal/contexts/UIContext", () => ({ useUI: () => ({ openLinkModal: vi.fn() }), })); -vi.mock("@portal/components/billing/LinkAccountPrompt", () => ({ - LinkAccountPrompt: () =>
, -})); vi.mock("@portal/views/Usage", () => ({ - Usage: () =>
, + Usage: ({ onWalletLoaded }: { onWalletLoaded?: (w: unknown) => void }) => { + onWalletLoaded?.({ status: "free" }); + return
; + }, })); import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; +const renderGate = () => + render( + + + } /> + } /> + + , + ); + describe("PortalBillingGate — self-hosted", () => { beforeEach(() => { - linkState.isLinked = false; + connect.mockReset(); + applyLinkFacts.mockReset(); + gate.gated = false; + gate.loading = false; }); - it("shows the link prompt when unlinked (billing gated on link)", () => { - linkState.isLinked = false; - render(); - expect(screen.getByTestId("link-prompt")).toBeInTheDocument(); - expect(screen.queryByTestId("usage")).not.toBeInTheDocument(); + it("asks for the connection when reached unconnected", () => { + gate.gated = true; + renderGate(); + expect(connect).toHaveBeenCalledTimes(1); }); - it("renders the Usage page once linked", () => { - linkState.isLinked = true; - render(); + it("sends them back rather than onto a page about an account they lack", () => { + gate.gated = true; + renderGate(); + expect(screen.queryByTestId("usage")).toBeNull(); + expect(screen.getByTestId("home")).toBeInTheDocument(); + }); + + it("never reports the instance as linked while it is not", () => { + gate.gated = true; + renderGate(); + // Not rendering the page is what stops the claim. + expect(applyLinkFacts).not.toHaveBeenCalled(); + }); + + it("holds while the capability is still unknown, rather than bouncing", () => { + gate.loading = true; + renderGate(); + expect(screen.queryByTestId("usage")).toBeNull(); + expect(screen.queryByTestId("home")).toBeNull(); + }); + + it("renders the page once connected", () => { + renderGate(); + expect(connect).not.toHaveBeenCalled(); expect(screen.getByTestId("usage")).toBeInTheDocument(); - expect(screen.queryByTestId("link-prompt")).not.toBeInTheDocument(); + expect(applyLinkFacts).toHaveBeenCalledWith(true, false); }); }); diff --git a/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx b/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx index e448cfb93a..b02ed7db29 100644 --- a/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx +++ b/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx @@ -1,23 +1,20 @@ import { useCallback } from "react"; -import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext"; +import { useApplyLinkFacts } from "@portal/contexts/LinkContext"; import { useUI } from "@portal/contexts/UIContext"; -import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; +import { ConnectGuardedRoute } from "@portal/components/account-link/ConnectGuardedRoute"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { Usage } from "@portal/views/Usage"; import type { Wallet } from "@portal/api/billing"; /** - * Billing access gate — the seam the SaaS build overrides. + * The seam the SaaS build shadows, and the backstop for a typed URL — the nav already refuses to + * come here unlinked (the sidebar's requiresLink). * - *

Self-hosted (this base): billing only makes sense once the instance has - * linked its SaaS account, so gate on link state — unlinked shows the link prompt; - * linked renders the (flavor-agnostic) Usage page and maps its callbacks onto the - * link/tier dimension: the wallet's subscription status refines the plan/tier - * badge, and a lapsed SaaS session re-opens the account-link re-auth. This keeps - * the "link" concept entirely out of the Usage page. The SaaS build shadows this - * with a passthrough — there is no linking there. + *

Usage must not render while unlinked: {@link onWalletLoaded} reports linked as a fact, and the + * browser can hold a SaaS session with no link to this server, so rendering it flipped the portal + * to linked. */ export function PortalBillingGate() { - const { isLinked } = useLink(); const applyLinkFacts = useApplyLinkFacts(); const { openLinkModal } = useUI(); @@ -27,6 +24,9 @@ export function PortalBillingGate() { ); const onReauth = useCallback(() => openLinkModal("reauth"), [openLinkModal]); - if (!isLinked) return ; - return ; + return ( + + + + ); } diff --git a/frontend/editor/src/portal/components/shared/StepModalHeader.css b/frontend/editor/src/portal/components/shared/StepModalHeader.css index 7296e9752f..fd918f1c62 100644 --- a/frontend/editor/src/portal/components/shared/StepModalHeader.css +++ b/frontend/editor/src/portal/components/shared/StepModalHeader.css @@ -26,7 +26,7 @@ color: var(--c-text); } -/* Trademarked wordmark SVG (theme-switched via .wordmark-light-only/.wordmark-dark-only). Height +/* Trademarked wordmark SVG (theme-switched via .wordmark/.wordmark-dark-only). Height matches the portal nav's 22px wordmark so the modal and app read as one brand. No `display` here — the theme-switch utilities own visibility. */ .portal-stepmodal__wordmark { diff --git a/frontend/editor/src/portal/components/shared/StepModalHeader.tsx b/frontend/editor/src/portal/components/shared/StepModalHeader.tsx index 3156d17a9e..9190893745 100644 --- a/frontend/editor/src/portal/components/shared/StepModalHeader.tsx +++ b/frontend/editor/src/portal/components/shared/StepModalHeader.tsx @@ -55,10 +55,12 @@ export function StepModalHeader({

{brand ? (
+ {/* `wordmark`, not `wordmark-light-only`: theme.css hides the former in dark mode and + has no rule for the latter, so both used to render at once. */} Stirling }, { id: "integrations", icon: }, { id: "infrastructure", icon: }, - { id: "usage", icon: }, + { id: "usage", icon: , requiresLink: true }, { id: "docs", icon: }, ]; diff --git a/frontend/editor/src/portal/contexts/LinkContext.tsx b/frontend/editor/src/portal/contexts/LinkContext.tsx index 7fd7cfe536..8e36b80cee 100644 --- a/frontend/editor/src/portal/contexts/LinkContext.tsx +++ b/frontend/editor/src/portal/contexts/LinkContext.tsx @@ -89,6 +89,14 @@ export function useLink(): LinkContextValue { return v; } +/** + * Null rather than throwing where there is no provider. The SaaS portal mounts none on purpose, so + * absent means "linking does not apply here" — a real answer, not a mistake. + */ +export function useLinkOptional(): LinkContextValue | null { + return useContext(LinkContext); +} + /** * Derives the linked state from raw facts: whether the org has linked its SaaS * account and whether it carries a live subscription. Keeps the unlinked / diff --git a/frontend/editor/src/portal/contexts/UIContext.tsx b/frontend/editor/src/portal/contexts/UIContext.tsx index b5ebada257..b116486271 100644 --- a/frontend/editor/src/portal/contexts/UIContext.tsx +++ b/frontend/editor/src/portal/contexts/UIContext.tsx @@ -5,6 +5,7 @@ import { useState, type ReactNode, } from "react"; +import type { ConnectOutcome } from "@portal/components/account-link/ConnectCallbackView"; interface UIContextValue { /** Off-canvas sidebar drawer on small screens (no-op chrome on desktop). */ @@ -46,6 +47,13 @@ interface UIContextValue { linkModalMode: "link" | "reauth"; openLinkModal: (mode?: "link" | "reauth") => void; closeLinkModal: () => void; + /** + * A one-shot signal like {@link UIContextValue.trialSetupRequested}: the callback route and the + * dialog mount separately, and there must only ever be one link dialog. + */ + connectOutcome: ConnectOutcome | null; + publishConnectOutcome: (outcome: ConnectOutcome) => void; + clearConnectOutcome: () => void; /** * A request to begin the enterprise trial, raised from wherever the buyer said yes (the billing * upsell, a sales link). The deal controller lives on Home, so this is a one-shot signal rather @@ -91,6 +99,9 @@ export function UIProvider({ children }: { children: ReactNode }) { const [linkModalOpen, setLinkModalOpen] = useState(false); const [trialSetupRequested, setTrialSetupRequested] = useState(false); const [linkModalMode, setLinkModalMode] = useState<"link" | "reauth">("link"); + const [connectOutcome, setConnectOutcome] = useState( + null, + ); // When the link modal is opened from inside Settings, remember the section to // restore so closing the modal returns the admin to where they were. const [reopenSettingsAfterLink, setReopenSettingsAfterLink] = useState< @@ -155,9 +166,19 @@ export function UIProvider({ children }: { children: ReactNode }) { setTrialSetupRequested(true); }, clearTrialSetupRequest: () => setTrialSetupRequested(false), + connectOutcome, + publishConnectOutcome: (outcome: ConnectOutcome) => { + setMobileNavOpen(false); + setConnectOutcome(outcome); + setLinkModalMode("link"); + setLinkModalOpen(true); + }, + clearConnectOutcome: () => setConnectOutcome(null), closeLinkModal: () => { setLinkModalOpen(false); setLinkModalMode("link"); + // A reopen from a CTA is a fresh flow, not a handshake already dismissed. + setConnectOutcome(null); if (reopenSettingsAfterLink) { setSettingsInitialSection(reopenSettingsAfterLink); setSettingsInitialFocus(null); @@ -177,6 +198,7 @@ export function UIProvider({ children }: { children: ReactNode }) { linkModalMode, reopenSettingsAfterLink, trialSetupRequested, + connectOutcome, ], ); diff --git a/frontend/editor/src/portal/hooks/useConnectGate.test.tsx b/frontend/editor/src/portal/hooks/useConnectGate.test.tsx new file mode 100644 index 0000000000..00cd34d643 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectGate.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { UIProvider } from "@portal/contexts/UIContext"; + +/** + * The gate answers two questions, and conflating them is the failure that matters: an instance + * running with the account-link flag off CANNOT link, so gating on link state alone would lock + * Pipelines, Policies, Users, Sources and Integrations on every default install with no way out. + */ +const { json } = vi.hoisted(() => ({ json: vi.fn() })); +vi.mock("@portal/api/http", () => ({ + apiClient: { local: { json } }, + errorMessage: (e: unknown) => String(e), +})); + +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +function Probe() { + const { gated, loading, available } = useConnectGate(); + return ( + + {loading + ? "loading" + : `${available ? "available" : "unavailable"}:${gated ? "gated" : "open"}`} + + ); +} + +function renderProbe(linkState: LinkState) { + return render( + + + + + + + , + ); +} + +const settled = async (expected: string) => + waitFor(() => expect(screen.getByTestId("state").textContent).toBe(expected)); + +describe("useConnectGate", () => { + beforeEach(() => json.mockReset()); + + it("gates an unlinked instance that can link", async () => { + json.mockResolvedValue({ accountLinkAvailable: true }); + renderProbe("unlinked"); + await settled("available:gated"); + }); + + it("does not gate when linking is unavailable, whatever the link state", async () => { + json.mockResolvedValue({ accountLinkAvailable: false }); + renderProbe("unlinked"); + await settled("unavailable:open"); + }); + + it("does not gate a linked instance", async () => { + json.mockResolvedValue({ accountLinkAvailable: true }); + renderProbe("linked-free"); + await settled("available:open"); + }); + + it("treats a missing flag as unavailable rather than gating on a guess", async () => { + json.mockResolvedValue({}); + renderProbe("unlinked"); + await settled("unavailable:open"); + }); + + it("does not gate while the capability is still unknown", async () => { + json.mockResolvedValue({ accountLinkAvailable: true }); + renderProbe("unlinked"); + expect(screen.getByTestId("state").textContent).toBe("loading"); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useConnectGate.ts b/frontend/editor/src/portal/hooks/useConnectGate.ts new file mode 100644 index 0000000000..253ee26212 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectGate.ts @@ -0,0 +1,63 @@ +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@portal/api/http"; +import { qk } from "@portal/queries/keys"; +import { useLinkOptional } from "@portal/contexts/LinkContext"; +import { useUI } from "@portal/contexts/UIContext"; +import { useDevConnectBypass } from "@portal/hooks/useDevConnectBypass"; + +interface AppConfigShape { + accountLinkAvailable?: boolean; +} + +interface ConnectGate { + /** Can link but has not, so gated features must ask first. */ + gated: boolean; + /** Capability still unknown; hold the decision rather than flash a gate. */ + loading: boolean; + /** Whether linking is possible here at all, i.e. the feature flag is on. */ + available: boolean; + connect: () => void; + /** Wraps a create or edit handler so the click asks for a connection instead. */ + guard: ( + action: (...args: A) => void, + ) => (...args: A) => void; +} + +/** + * Two facts, not one: linked, and *could* be linked. The account-link endpoints 404 with the flag + * off, which the client cannot tell from "not linked yet", so gating on link state alone would lock + * these features on every default install. + */ +export function useConnectGate(): ConnectGate { + // Optional: the SaaS portal mounts no LinkProvider, and no provider means nothing to gate. + const link = useLinkOptional(); + const { openLinkModal } = useUI(); + const devBypass = useDevConnectBypass(); + + const query = useQuery({ + queryKey: qk.appConfig(), + queryFn: () => + apiClient.local.json("/api/v1/config/app-config"), + }); + + const available = Boolean(query.data?.accountLinkAvailable) && link != null; + const loading = query.isPending; + const gated = available && !link?.isLinked && !devBypass; + + const connect = useCallback(() => openLinkModal(), [openLinkModal]); + + const guard = useCallback( + (action: (...args: A) => void) => + (...args: A) => { + if (gated) { + openLinkModal(); + return; + } + action(...args); + }, + [gated, openLinkModal], + ); + + return { gated, loading, available, connect, guard }; +} diff --git a/frontend/editor/src/portal/hooks/useConnectHandoff.ts b/frontend/editor/src/portal/hooks/useConnectHandoff.ts new file mode 100644 index 0000000000..1349086085 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectHandoff.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { withBasePath } from "@app/constants/app"; +import { startConnect, startReauth } from "@portal/api/link"; + +interface ConnectHandoff { + /** Stays true through a successful hand-off: the page is leaving, so nothing resolves. */ + busy: boolean; + error: string | null; + begin: () => void; +} + +export function useConnectHandoff(reauth: boolean): ConnectHandoff { + const { t } = useTranslation(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + // Back from Stirling can restore this page with its heap intact, leaving busy stuck on and the + // dialog pinned to the ghost step. Being shown at all means we are not mid-navigation. + const shown = () => setBusy(false); + window.addEventListener("pageshow", shown); + return () => window.removeEventListener("pageshow", shown); + }, []); + + const begin = useCallback(() => { + setBusy(true); + setError(null); + void (async () => { + try { + // Stated, not inferred: only the frontend knows its own base path. + const callbackUrl = new URL( + withBasePath("/account-link/callback"), + window.location.origin, + ).toString(); + const status = reauth + ? await startReauth(callbackUrl) + : await startConnect(window.location.hostname, callbackUrl); + if (status.authorizeUrl) { + window.location.assign(status.authorizeUrl); + return; + } + // Already linked, or a handshake we cannot act on. Nothing to navigate to. + setError( + t( + "portal.accountLink.modal.noAuthorizeUrl", + "Stirling did not return somewhere to continue. Try again in a moment.", + ), + ); + setBusy(false); + } catch { + setError( + t( + "portal.accountLink.modal.startFailed", + "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again.", + ), + ); + setBusy(false); + } + })(); + }, [reauth, t]); + + return { busy, error, begin }; +} diff --git a/frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx b/frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx new file mode 100644 index 0000000000..dc54fce35c --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; + +/** + * The cadence decision: dismissible, but it always comes back. Persisting "seen" would end the ask + * after one dismissal, so the marker has to be session scoped and has to be written when the prompt + * opens rather than when it closes, or an admin who ignores the dialog gets it again on every + * re-render. + */ +const { connect, gate } = vi.hoisted(() => ({ + connect: vi.fn(), + gate: { gated: true, loading: false, available: true }, +})); + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ ...gate, connect, guard: (f: unknown) => f }), +})); + +import { useConnectPrompt } from "@portal/hooks/useConnectPrompt"; + +function Probe() { + useConnectPrompt(); + return null; +} + +describe("useConnectPrompt", () => { + beforeEach(() => { + connect.mockReset(); + sessionStorage.clear(); + gate.gated = true; + gate.loading = false; + }); + + it("opens the flow once while unlinked", async () => { + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + }); + + it("does not open again in the same session", async () => { + const { unmount } = render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + unmount(); + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + }); + + it("asks again in a fresh session", async () => { + render().unmount(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + sessionStorage.clear(); + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(2)); + }); + + it("never persists beyond the session", async () => { + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + expect(localStorage.length).toBe(0); + }); + + it("stays quiet when the instance is not gated", async () => { + gate.gated = false; + render(); + await new Promise((r) => setTimeout(r, 0)); + expect(connect).not.toHaveBeenCalled(); + }); + + it("waits for the capability rather than prompting on an unknown", async () => { + gate.loading = true; + render(); + await new Promise((r) => setTimeout(r, 0)); + expect(connect).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useConnectPrompt.ts b/frontend/editor/src/portal/hooks/useConnectPrompt.ts new file mode 100644 index 0000000000..0a751b5c86 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectPrompt.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef } from "react"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +const PROMPTED_KEY = "portal::connect-prompted"; + +function alreadyPrompted(): boolean { + try { + return sessionStorage.getItem(PROMPTED_KEY) === "true"; + } catch { + return false; + } +} + +function markPrompted(): void { + try { + sessionStorage.setItem(PROMPTED_KEY, "true"); + } catch { + // Prompting again later is the harmless direction. + } +} + +/** + * Session storage, not the onboarding localStorage helpers: one dismissal should not end the ask + * for good, and asking once per visit needs no timer to tune. + */ +export function useConnectPrompt(): void { + const { gated, loading, connect } = useConnectGate(); + const fired = useRef(false); + + useEffect(() => { + if (loading || !gated || fired.current || alreadyPrompted()) return; + fired.current = true; + // Marked on open, not on close, so a session gets one whatever the admin does with it. + markPrompted(); + connect(); + }, [gated, loading, connect]); +} diff --git a/frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx b/frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx new file mode 100644 index 0000000000..f4d28f5515 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; + +/** + * The gate is the only thing making these features need a link, so a switch a customer could reach + * would hand them over. The last test is the one that matters: with DEV folded off, the parameter + * does nothing at all. + */ +import { useDevConnectBypass } from "@portal/hooks/useDevConnectBypass"; + +function Probe() { + return {String(useDevConnectBypass())}; +} + +/** Mounts fresh at the given URL, so a second call models a later visit rather than a re-render. */ +const at = (search: string) => { + cleanup(); + window.history.replaceState({}, "", `/processor${search}`); + render(); + return screen.getByTestId("bypass").textContent; +}; + +describe("useDevConnectBypass", () => { + beforeEach(() => sessionStorage.clear()); + afterEach(() => { + vi.unstubAllEnvs(); + window.history.replaceState({}, "", "/"); + }); + + it("is off by default, so dev still sees what customers see", () => { + expect(at("")).toBe("false"); + }); + + it("turns on with the parameter", () => { + expect(at("?bypassConnect=true")).toBe("true"); + }); + + it("survives the navigation the gate itself performs", () => { + at("?bypassConnect=true"); + expect(at("")).toBe("true"); + }); + + it("ignores any other value", () => { + expect(at("?bypassConnect=1")).toBe("false"); + }); + + it("does nothing in a build, which is what ships to customers", () => { + vi.stubEnv("DEV", false); + expect(at("?bypassConnect=true")).toBe("false"); + // And nothing was left behind for a later dev session to pick up. + expect(sessionStorage.getItem("accountLink::dev-bypass")).toBeNull(); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useDevConnectBypass.ts b/frontend/editor/src/portal/hooks/useDevConnectBypass.ts new file mode 100644 index 0000000000..b201ac7d80 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useDevConnectBypass.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; + +const PARAM = "bypassConnect"; +const SESSION_KEY = "accountLink::dev-bypass"; + +function stored(): boolean { + try { + return sessionStorage.getItem(SESSION_KEY) === "true"; + } catch { + return false; + } +} + +/** + * Dev-only escape from the connect gate, fenced behind {@code import.meta.env.DEV} so Vite folds + * the branch away entirely: in a shipped build there is no param and no key that does anything. + * It cannot be a setting — the gate is the only thing making these features need a link, so any + * switch a customer could reach would hand them over. + */ +export function useDevConnectBypass(): boolean { + // Session-scoped so it survives the navigation the gate itself performs. + const [bypassed, setBypassed] = useState( + () => import.meta.env.DEV && stored(), + ); + + useEffect(() => { + if (!import.meta.env.DEV) return; + const params = new URLSearchParams(window.location.search); + if (params.get(PARAM) !== "true") return; + try { + sessionStorage.setItem(SESSION_KEY, "true"); + } catch { + // Still bypassed for this render. + } + setBypassed(true); + }, []); + + return bypassed; +} diff --git a/frontend/editor/src/portal/test/TestQueryProvider.tsx b/frontend/editor/src/portal/test/TestQueryProvider.tsx index d843d816d3..1d3587c858 100644 --- a/frontend/editor/src/portal/test/TestQueryProvider.tsx +++ b/frontend/editor/src/portal/test/TestQueryProvider.tsx @@ -1,6 +1,8 @@ import { useState, type ReactNode } from "react"; import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { UIProvider } from "@portal/contexts/UIContext"; /** * Wraps a portal component under test in a fresh QueryClient (retries off for @@ -27,3 +29,20 @@ export function PortalTestProviders({ children }: { children: ReactNode }) { ); } + +/** {@link PortalTestProviders} plus the contexts the connect gate reads. Unlinked by default. */ +export function PortalViewProviders({ + children, + linkState = "unlinked", +}: { + children: ReactNode; + linkState?: LinkState; +}) { + return ( + + + {children} + + + ); +} diff --git a/frontend/editor/src/portal/views/ConnectCallback.css b/frontend/editor/src/portal/views/ConnectCallback.css index 35e9b09ef7..99c0539658 100644 --- a/frontend/editor/src/portal/views/ConnectCallback.css +++ b/frontend/editor/src/portal/views/ConnectCallback.css @@ -1,24 +1,13 @@ -/* Account-link callback. A transient page the admin passes through, so it is - centred and says one thing rather than trying to be a settings screen. */ +/* Step 3 of the connect flow: what the round trip to Stirling came back with. + + Was a standalone page, centred with its own margins and max-width. It is now a + step body inside FlowModal, which already owns the dialog's width, padding and + spacing, so this only stacks its own blocks. */ .portal-connect-callback { display: flex; flex-direction: column; - align-items: center; - gap: 1rem; - max-width: 30rem; - margin: 4rem auto; - padding: 0 1rem; - text-align: center; -} - -.portal-connect-callback > * { - width: 100%; -} - -/* The button is the one thing that should not stretch to the banner's width. */ -.portal-connect-callback button { - width: auto; + gap: 0.75rem; } .portal-connect-callback p { @@ -30,3 +19,11 @@ .portal-connect-callback__note { font-size: 0.8125rem; } + +/* Only "working" is centred: a spinner with a line under it, which has nothing to + align against. */ +.portal-connect-callback--working { + align-items: center; + gap: 0.625rem; + padding: 1.5rem 0; +} diff --git a/frontend/editor/src/portal/views/ConnectCallback.test.tsx b/frontend/editor/src/portal/views/ConnectCallback.test.tsx index bdadee03e4..b934e11cd7 100644 --- a/frontend/editor/src/portal/views/ConnectCallback.test.tsx +++ b/frontend/editor/src/portal/views/ConnectCallback.test.tsx @@ -2,10 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, render, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MantineProvider } from "@mantine/core"; +import { UIProvider, useUI } from "@portal/contexts/UIContext"; +import type { ConnectOutcome } from "@portal/components/account-link/ConnectCallbackView"; -/** - * The callback handles a live session token in a URL fragment, so the behaviour worth pinning is what it does with it: strip it immediately, refuse anything it cannot verify, and keep the two outcomes (SaaS sign-in, server link) independent of each other. - */ +/** A live session token rides in the fragment: strip it at once, refuse what cannot be verified. */ const { completeConnect, startConnect, setSession, refresh } = vi.hoisted( () => ({ completeConnect: vi.fn(), @@ -32,19 +32,38 @@ function landOn(fragment: string) { window.history.replaceState(null, "", `/account-link/callback${fragment}`); } -/** - * Route and host together: the route reads the fragment, the portal renders the - * outcome. Exercising them apart would test the hand-off rather than the flow. - */ +/** Stands in for the dialog that consumes the outcome. */ +let published: ConnectOutcome[] = []; + +function OutcomeSpy() { + const { connectOutcome } = useUI(); + if ( + connectOutcome && + published[published.length - 1]?.state !== connectOutcome.state + ) { + published.push(connectOutcome); + } + return null; +} + +const lastOutcome = () => published[published.length - 1]; + +/** Route and host together: apart, this would test the hand-off rather than the flow. */ function renderFlow() { return render( - - - } /> - } /> - + + + + + } + /> + } /> + + , ); @@ -53,6 +72,7 @@ function renderFlow() { describe("account-link callback", () => { beforeEach(() => { vi.clearAllMocks(); + published = []; completeConnect.mockResolvedValue({ phase: "LINKED", authorizeUrl: null, @@ -67,8 +87,7 @@ describe("account-link callback", () => { renderFlow(); - // Synchronous, before any await: the fragment must not survive long enough - // to be read from the address bar or land in a history entry. + // Before any await: the fragment must not reach the address bar or a history entry. expect(window.location.hash).toBe(""); await waitFor(() => expect(completeConnect).toHaveBeenCalled()); }); @@ -109,8 +128,7 @@ describe("account-link callback", () => { renderFlow(); - // The two outcomes are independent: a failed sign-in must not strand the - // server unlinked. + // Independent outcomes: a failed sign-in must not strand the server unlinked. await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); }); @@ -131,6 +149,18 @@ describe("account-link callback", () => { await waitFor(() => expect(window.location.hash).toBe("")); expect(completeConnect).not.toHaveBeenCalled(); expect(setSession).not.toHaveBeenCalled(); + await waitFor(() => expect(lastOutcome()?.state).toBe("malformed")); + expect(lastOutcome()?.reclaim).toBeUndefined(); + }); + + it("hands the result to the dialog rather than rendering its own", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + const { container } = renderFlow(); + + await waitFor(() => expect(lastOutcome()?.state).toBe("linked")); + expect(lastOutcome()?.sessionRestored).toBe(true); + expect(container.querySelector(".portal-connect-callback")).toBeNull(); }); it("refuses a fragment that is not a link response", async () => { @@ -160,16 +190,30 @@ describe("account-link callback", () => { teamId: null, }); - const { getAllByRole } = renderFlow(); + renderFlow(); await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(1)); - // Last button, not the only one: the modal shell contributes a close button. - const buttons = getAllByRole("button"); - act(() => buttons[buttons.length - 1].click()); + await waitFor(() => expect(lastOutcome()?.state).toBe("retry")); - // Retries the existing handshake; starting a new one would waste the - // approval a human just gave. + act(() => lastOutcome()!.reclaim!()); + + // Re-claims rather than opening a new handshake, which would spend a leader's approval. await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(2)); expect(startConnect).not.toHaveBeenCalled(); }); + + it("gives a spent handshake no re-claim, so the dialog asks for a new one", async () => { + landOn(`#type=link&nonce=${NONCE}`); + completeConnect.mockResolvedValue({ + phase: "EXPIRED", + authorizeUrl: null, + secondsRemaining: null, + teamId: null, + }); + + renderFlow(); + + await waitFor(() => expect(lastOutcome()?.state).toBe("expired")); + expect(lastOutcome()?.reclaim).toBeUndefined(); + }); }); diff --git a/frontend/editor/src/portal/views/Integrations.test.tsx b/frontend/editor/src/portal/views/Integrations.test.tsx index 8c4e684bfa..acf1f1b1b1 100644 --- a/frontend/editor/src/portal/views/Integrations.test.tsx +++ b/frontend/editor/src/portal/views/Integrations.test.tsx @@ -13,6 +13,16 @@ import type { IntegrationConfig } from "@portal/api/integrations"; const render = (ui: Parameters[0]) => baseRender(ui, { wrapper: MantineProvider }); +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, diff --git a/frontend/editor/src/portal/views/Integrations.tsx b/frontend/editor/src/portal/views/Integrations.tsx index 793c21f880..fc40feb1e9 100644 --- a/frontend/editor/src/portal/views/Integrations.tsx +++ b/frontend/editor/src/portal/views/Integrations.tsx @@ -31,6 +31,7 @@ import { } from "@portal/components/sources/connectionTypes"; import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations"; import { COMING_SOON_SOURCE_TYPES } from "@portal/components/sources/sourceTypes"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/theme/surface.css"; import "@portal/views/Integrations.css"; @@ -94,6 +95,7 @@ type IntegrationRow = { export function Integrations() { const { t } = useTranslation(); + const { guard } = useConnectGate(); const [connections, setConnections] = useState( null, ); @@ -212,13 +214,23 @@ export function Integrations() { return counts; }, [catalogue]); - const openCreate = useCallback((typeId: string) => { - setModal({ open: true, editing: null, fixedTypeId: typeId }); - }, []); + // Connecting an integration and editing one both need a linked account. Memoised + // because both land in the row-building useMemo deps below. + const openCreate = useMemo( + () => + guard((typeId: string) => { + setModal({ open: true, editing: null, fixedTypeId: typeId }); + }), + [guard], + ); - const openEdit = useCallback((connection: IntegrationConfig) => { - setModal({ open: true, editing: connection }); - }, []); + const openEdit = useMemo( + () => + guard((connection: IntegrationConfig) => { + setModal({ open: true, editing: connection }); + }), + [guard], + ); const remove = useCallback( async (connection: IntegrationConfig) => { diff --git a/frontend/editor/src/portal/views/Pipelines.gated.test.tsx b/frontend/editor/src/portal/views/Pipelines.gated.test.tsx new file mode 100644 index 0000000000..993edc876b --- /dev/null +++ b/frontend/editor/src/portal/views/Pipelines.gated.test.tsx @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { PortalViewProviders } from "@portal/test/TestQueryProvider"; + +/** + * The page must look exactly as it always does: the ask is a dialog on the attempt, not a lock + * screen in place of the feature. The route half is what a later link cannot walk around. + */ +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: true, + loading: false, + available: true, + connect, + guard: + (_action: (...args: A) => void) => + () => + connect(), + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchPipelines = vi.fn(); +vi.mock("@portal/api/pipelines", () => ({ + fetchPipelines: () => fetchPipelines(), +})); + +import { Pipelines } from "@portal/views/Pipelines"; +import { ConnectGuardedRoute } from "@portal/components/account-link/ConnectGuardedRoute"; + +const PIPELINE = { + id: "plc-1", + name: "Redact claims", + enabled: true, + status: "active", + trigger: "schedule", + sources: [{ id: "src-claims", name: "Claims intake" }], + steps: ["/api/v1/security/auto-redact"], + output: "inline", + owner: "security@acme.com", +}; + +function renderAt(initial: string) { + return render( + + + + } /> + +
builder
+ + } + /> +
+
+
, + ); +} + +describe("Pipelines when the account is not connected", () => { + beforeEach(() => { + connect.mockReset(); + fetchPipelines.mockReset(); + }); + + it("leaves the empty state exactly as it is", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] }); + renderAt("/processor/pipelines"); + expect( + await screen.findByText("portal.pipelines.empty.title"), + ).toBeInTheDocument(); + }); + + it("still lists pipelines that already exist", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [PIPELINE] }); + renderAt("/processor/pipelines"); + expect(await screen.findByText("Redact claims")).toBeInTheDocument(); + }); + + it("asks to connect instead of opening the builder", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] }); + renderAt("/processor/pipelines"); + await screen.findByText("portal.pipelines.empty.title"); + fireEvent.click(screen.getByText("portal.pipelines.actions.newPipeline")); + expect(connect).toHaveBeenCalled(); + expect(screen.queryByText("builder")).toBeNull(); + }); + + it("asks to connect instead of opening an existing pipeline", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [PIPELINE] }); + renderAt("/processor/pipelines"); + fireEvent.click(await screen.findByText("Redact claims")); + expect(connect).toHaveBeenCalled(); + }); + + it("turns away a direct arrival at the builder, however it was reached", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] }); + renderAt("/processor/pipelines/new"); + expect( + await screen.findByText("portal.pipelines.empty.title"), + ).toBeInTheDocument(); + expect(screen.queryByText("builder")).toBeNull(); + expect(connect).toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/views/Pipelines.test.tsx b/frontend/editor/src/portal/views/Pipelines.test.tsx index 655abf62cb..250722706e 100644 --- a/frontend/editor/src/portal/views/Pipelines.test.tsx +++ b/frontend/editor/src/portal/views/Pipelines.test.tsx @@ -14,6 +14,16 @@ const render = ( options?: Parameters[1], ) => baseRender(ui, { wrapper: PortalTestProviders, ...options }); +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + // Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ useTranslation: () => ({ diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx index f2203de972..6311e3c438 100644 --- a/frontend/editor/src/portal/views/Pipelines.tsx +++ b/frontend/editor/src/portal/views/Pipelines.tsx @@ -9,11 +9,13 @@ import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { PipelinesIcon } from "@portal/components/icons"; import { KpiStrip } from "@portal/components/pipelines/KpiStrip"; import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/views/Pipelines.css"; export function Pipelines() { const { t } = useTranslation(); const navigate = useNavigate(); + const { guard } = useConnectGate(); const state = usePipelines(); const { data, loading } = state; const { isLoading } = useSectionFlags(state); @@ -26,13 +28,18 @@ export function Pipelines() { // the loading and empty states don't flash a row of placeholder cards. const hasPipelines = pipelines.length > 0; - const openCreate = () => - navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`); - const connectSource = () => - navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`); + // Building and editing a pipeline both need a linked account, so both ask for one first. + const openCreate = guard(() => + navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`), + ); + // Guarded in its own right so the ask happens here rather than after a pointless hop to Sources. + const connectSource = guard(() => + navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`), + ); // A row opens that pipeline's own page (view / edit / run / delete live there). - const openPipeline = (pipeline: PipelineView) => - navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`); + const openPipeline = guard((pipeline: PipelineView) => + navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`), + ); return (
diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx index 9e316852fd..b8cb42112a 100644 --- a/frontend/editor/src/portal/views/Policies.tsx +++ b/frontend/editor/src/portal/views/Policies.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -23,11 +23,13 @@ import { PolicyCatalogueTable } from "@portal/components/policies/PolicyCatalogu import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel"; import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard"; import { useAiEngineEnabled } from "@portal/hooks/useAiEngineEnabled"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/views/Policies.css"; export function Policies() { const { t } = useTranslation(); const queryClient = useQueryClient(); + const { gated, connect } = useConnectGate(); const state = usePoliciesOverview(); const { data, loading, error: fetchError } = state; const { isLoading } = useSectionFlags(state); @@ -38,18 +40,27 @@ export function Policies() { const [pageError, setPageError] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); + // Held in a ref so the effects below do not re-run on its identity. They write back to the URL, + // so a callback that changes each render would loop: strip the param, re-render, run again. + const connectRef = useRef(connect); + connectRef.current = connect; + + // Deep link from the Home processor flow. It sets the wizard directly rather than going through + // openEntry, so the gate belongs here too: guarding openEntry alone would leave ?setup= as a way + // past it. useEffect(() => { const setupId = searchParams.get("setup"); if (!setupId || !data) return; const entry = data.catalogue.find((e) => e.category.id === setupId); if (entry && !entry.category.comingSoon) { - if (entry.policy) setDetail(entry); + if (gated) connectRef.current(); + else if (entry.policy) setDetail(entry); else setWizard(entry); } const next = new URLSearchParams(searchParams); next.delete("setup"); setSearchParams(next, { replace: true }); - }, [searchParams, data, setSearchParams]); + }, [searchParams, data, setSearchParams, gated]); const { enabled: aiEngineEnabled, loading: aiEngineLoading } = useAiEngineEnabled(); @@ -95,6 +106,13 @@ export function Policies() { const openEntry = useCallback( (entry: CatalogueEntry) => { + // Ask rather than open an editor whose save would fail; viewing the catalogue stays open. + // Via the ref so this keeps its identity: the deep-link effect depends on it and writes the + // URL back, which would otherwise loop. + if (gated) { + connectRef.current(); + return; + } // Block setup of an AI-required policy until the engine is confirmed on (so a // click during the app-config load can't open a wizard for a disabled // feature); a configured policy stays openable so it can be paused/deleted. @@ -103,7 +121,7 @@ export function Policies() { if (entry.policy) setDetail(entry); else setWizard(entry); }, - [aiEngineEnabled], + [aiEngineEnabled, gated], ); // Open a category passed as ?category= (deep link from the super diff --git a/frontend/editor/src/portal/views/Sources.gated.test.tsx b/frontend/editor/src/portal/views/Sources.gated.test.tsx new file mode 100644 index 0000000000..944ab8c37c --- /dev/null +++ b/frontend/editor/src/portal/views/Sources.gated.test.tsx @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { PortalViewProviders } from "@portal/test/TestQueryProvider"; + +/** + * The deep link into the create flow, which the gate has to cover in its own right. + * + * `?new=1` opens the modal from an effect rather than through the click handler, so guarding + * openCreate does nothing for it. Both the Documents review queue and the pipelines empty state + * arrive here that way, so each is a way past the gate unless the deep link is guarded too. + */ +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); +const gate = { gated: true }; + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: gate.gated, + loading: false, + available: true, + connect, + guard: + (action: (...args: A) => void) => + (...args: A) => { + if (gate.gated) connect(); + else action(...args); + }, + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchSources = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + fetchSources: () => fetchSources(), + fetchSource: vi.fn(), + createSource: vi.fn(), + deleteSource: vi.fn(), + isFolderAccessDeniedError: () => false, +})); +vi.mock("@portal/api/integrations", () => ({ + fetchIntegrations: () => Promise.resolve([]), + fetchIntegrationCapabilities: () => Promise.resolve({ customApi: false }), + fetchS3Connections: () => Promise.resolve([]), + deleteIntegration: vi.fn(), +})); + +import { Sources } from "@portal/views/Sources"; + +const EDITOR_ROW = { + id: "editor", + name: "Editor", + type: "editor", + status: "active", + referenceCount: 0, + referencingPolicies: [], + config: [], + docsTotal: 0, + docs24h: 0, + docs30d: 0, +}; + +const renderAt = (initial: string) => + render( + + + + } /> + + + , + ); + +describe("Sources deep link when the account is not connected", () => { + beforeEach(() => { + connect.mockReset(); + gate.gated = true; + fetchSources.mockReset(); + fetchSources.mockResolvedValue({ kpis: [], sources: [EDITOR_ROW] }); + }); + + // Renders only once the fetch resolves, so finding it is also the await. + const LIST = "portal.sources.table.source"; + const MODAL = "portal.sources.builder.createTitle"; + + it("asks to connect instead of opening the create modal", async () => { + renderAt("/processor/sources?new=1"); + expect(await screen.findByText(LIST)).toBeInTheDocument(); + expect(connect).toHaveBeenCalled(); + expect(screen.queryByText(MODAL)).toBeNull(); + }); + + it("leaves the page looking exactly as it always does", async () => { + renderAt("/processor/sources"); + expect(await screen.findByText(LIST)).toBeInTheDocument(); + expect(connect).not.toHaveBeenCalled(); + }); + + it("still honours the deep link once connected", async () => { + gate.gated = false; + renderAt("/processor/sources?new=1"); + expect(await screen.findByText(LIST)).toBeInTheDocument(); + expect(connect).not.toHaveBeenCalled(); + expect(await screen.findByText(MODAL)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx index 166d4016fc..3891f1955d 100644 --- a/frontend/editor/src/portal/views/Sources.test.tsx +++ b/frontend/editor/src/portal/views/Sources.test.tsx @@ -22,6 +22,16 @@ const Providers = ({ children }: { children: ReactNode }) => ( const render = (ui: Parameters[0]) => baseRender(ui, { wrapper: Providers }); +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + // Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ useTranslation: () => ({ diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index f676b4f3db..bb740f5d69 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useSearchParams } from "react-router-dom"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; @@ -10,11 +10,13 @@ import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; import { SourceModal } from "@portal/components/sources/SourceModal"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/views/Sources.css"; export function Sources() { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); + const { guard, gated, connect } = useConnectGate(); const state = useSources(); const { data, loading } = state; @@ -27,13 +29,20 @@ export function Sources() { sourceId: string | null; }>({ open: false, sourceId: null }); + // Ref so the effect does not loop: it writes the param back, which would re-run it. + const connectRef = useRef(connect); + connectRef.current = connect; + + // Sets the modal directly, so it needs the gate in its own right: guarding openCreate would + // leave ?new=1 as a way past it. useEffect(() => { if (searchParams.get("new") !== "1") return; - setModal({ open: true, sourceId: null }); + if (gated) connectRef.current(); + else setModal({ open: true, sourceId: null }); const next = new URLSearchParams(searchParams); next.delete("new"); setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams]); + }, [searchParams, setSearchParams, gated]); const sources = data?.sources ?? []; @@ -42,9 +51,11 @@ export function Sources() { const configuredCount = sources.filter((s) => s.type !== "editor").length; const showKpis = isLoading || configuredCount > 0; - const openCreate = () => setModal({ open: true, sourceId: null }); - const openSource = (source: SourceView) => - setModal({ open: true, sourceId: source.id }); + // Connecting a source and editing one both need a linked account. + const openCreate = guard(() => setModal({ open: true, sourceId: null })); + const openSource = guard((source: SourceView) => + setModal({ open: true, sourceId: source.id }), + ); // The Connections tab moved to its own Integrations view. if (searchParams.get("tab") === "connections") { diff --git a/frontend/editor/src/portal/views/Users.caching.test.tsx b/frontend/editor/src/portal/views/Users.caching.test.tsx index a619d883b7..1a9e3e28c6 100644 --- a/frontend/editor/src/portal/views/Users.caching.test.tsx +++ b/frontend/editor/src/portal/views/Users.caching.test.tsx @@ -29,6 +29,16 @@ import { qk } from "@portal/queries/keys"; * one /team/my resolve. Same SaaS mocks as Users.saas.test.tsx. */ +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + vi.mock("@app/auth", () => ({ getStoredToken: () => null, clearStoredToken: vi.fn(), diff --git a/frontend/editor/src/portal/views/Users.gated.test.tsx b/frontend/editor/src/portal/views/Users.gated.test.tsx new file mode 100644 index 0000000000..b5286964fc --- /dev/null +++ b/frontend/editor/src/portal/views/Users.gated.test.tsx @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { PortalViewProviders } from "@portal/test/TestQueryProvider"; + +/** + * `?invite` opens the modal from an effect, so guarding openInvite did nothing for it. Same hole as + * Sources' `?new=1`, and it survived that fix. + */ +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); +const gate = { gated: true }; + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: gate.gated, + loading: false, + available: true, + connect, + guard: + (action: (...args: A) => void) => + (...args: A) => { + if (gate.gated) connect(); + else action(...args); + }, + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +vi.mock("@portal/contexts/TierContext", () => ({ + useTier: () => ({ tier: "pro" }), +})); +vi.mock("@app/auth", () => ({ + getStoredToken: () => null, + clearStoredToken: vi.fn(), +})); +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => null, + configureSupabase: vi.fn(), +})); +vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); + +vi.mock("@portal/hooks/useUsersData", () => ({ + useUsersData: () => ({ + usersState: { data: [], loading: false, error: null }, + grantsState: { data: [], loading: false, error: null }, + teamsState: { data: [], loading: false, error: null }, + authState: { data: null, loading: false, error: null }, + refresh: vi.fn(), + }), +})); + +import { Users } from "@portal/views/Users"; + +const INVITE_MODAL = "users.invite.title"; + +const renderAt = (initial: string) => + render( + + + + } /> + + + , + ); + +describe("Users deep link when the account is not connected", () => { + beforeEach(() => { + connect.mockReset(); + gate.gated = true; + }); + + it("asks to connect instead of opening the invite modal", () => { + renderAt("/processor/users?invite"); + expect(connect).toHaveBeenCalled(); + expect(screen.queryByText(INVITE_MODAL)).toBeNull(); + }); + + it("leaves the page alone when there is no deep link", () => { + renderAt("/processor/users"); + expect(connect).not.toHaveBeenCalled(); + }); + + it("still honours the deep link once connected", () => { + gate.gated = false; + renderAt("/processor/users?invite"); + expect(connect).not.toHaveBeenCalled(); + expect(screen.getByText(INVITE_MODAL)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/views/Users.saas.test.tsx b/frontend/editor/src/portal/views/Users.saas.test.tsx index 5313857c2c..1ea72e5a10 100644 --- a/frontend/editor/src/portal/views/Users.saas.test.tsx +++ b/frontend/editor/src/portal/views/Users.saas.test.tsx @@ -32,6 +32,16 @@ import { */ // Keep apiClient.local's transport hermetic (no real token / Supabase at import). +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + vi.mock("@app/auth", () => ({ getStoredToken: () => null, clearStoredToken: vi.fn(), diff --git a/frontend/editor/src/portal/views/Users.tsx b/frontend/editor/src/portal/views/Users.tsx index 79c6c11611..95b51091f0 100644 --- a/frontend/editor/src/portal/views/Users.tsx +++ b/frontend/editor/src/portal/views/Users.tsx @@ -21,6 +21,7 @@ import { import { deleteTeam as apiDeleteTeam } from "@portal/api/teams"; import { errorMessage } from "@portal/api/http"; import { usersCapabilities as caps } from "@app/portal/usersCapabilities"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import { UsersDirectory } from "@portal/components/users/UsersDirectory"; import { PendingInvitations } from "@portal/components/users/PendingInvitations"; import { InviteMemberModal } from "@portal/components/users/InviteMemberModal"; @@ -46,6 +47,7 @@ interface Confirm { */ export function Users() { const { t } = useTranslation(); + const { guard, gated, connect } = useConnectGate(); const { usersState, grantsState, teamsState, authState, refresh } = useUsersData(); @@ -62,13 +64,20 @@ export function Users() { const [confirm, setConfirm] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); + + // Ref so the effect does not loop: it writes the param back, which would re-run it. + const connectRef = useRef(connect); + connectRef.current = connect; + + // Sets the modal directly, so it needs the gate in its own right. useEffect(() => { if (searchParams.get("invite") === null) return; - setInviteOpen(true); + if (gated) connectRef.current(); + else setInviteOpen(true); const next = new URLSearchParams(searchParams); next.delete("invite"); setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams]); + }, [searchParams, setSearchParams, gated]); // Scroll to and flash the row for ?member= (deep link from the super // search), once the roster has rendered; then strip the param. Scoped to the @@ -194,10 +203,12 @@ export function Users() { if (!grant) return; run(() => revokeGrant(grant.id)); } - function openInvite(teamId: number | null) { + // Teams need a linked account, so inviting or creating one asks for the connection first. + const openInvite = guard((teamId: number | null) => { setInviteTeamId(teamId); setInviteOpen(true); - } + }); + const openNewTeam = guard(() => setNewTeamOpen(true)); // Kebab actions function toggleEnabled(member: Member) { @@ -282,11 +293,7 @@ export function Users() {
{caps.createTeam && ( - )} diff --git a/frontend/editor/src/saas/routes/ConnectApproveView.tsx b/frontend/editor/src/saas/routes/ConnectApproveView.tsx index 26a2fe1150..c89a27ef49 100644 --- a/frontend/editor/src/saas/routes/ConnectApproveView.tsx +++ b/frontend/editor/src/saas/routes/ConnectApproveView.tsx @@ -1,8 +1,51 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { useTranslation } from "@app/hooks/useTranslation"; import { Banner, Button, Checkbox, Spinner } from "@app/ui"; import { LocalIcon } from "@app/components/shared/LocalIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; + +/** + * This page is step 2 of a flow that started on the instance, so it wears the same chrome: the admin + * is being asked for a security decision by what would otherwise look like a different product. + * + *

TODO: re-auth still wears the first link's copy and consent checkbox, which asks the approver + * to agree to a binding that already exists. + */ +const TOTAL_STEPS = 3; + +function ApproveShell({ + title, + stepped, + children, +}: { + title: string; + /** Re-auth is one step on the instance side, so counting to three here would describe nothing. */ + stepped: boolean; + children: ReactNode; +}) { + const { t } = useTranslation(); + return ( +

+ {/* No onClose: this is a page, so there is nowhere to close back to. */} + + {children} +
+ ); +} export type ApprovePhase = | "loading" @@ -16,6 +59,8 @@ export interface PendingConnect { requestId: string; callbackOrigin: string; insecureTransport: boolean; + /** REAUTH cannot rebind: the team is pinned from the device credential at request time. */ + mode?: "LINK" | "REAUTH"; } export interface ConnectApproveViewProps { @@ -44,23 +89,31 @@ export function ConnectApproveView({ // Gates the primary action: anyone can create a request, so the approver reading // the address is the only thing between one and a linked team. const [acknowledged, setAcknowledged] = useState(false); + const stepped = pending?.mode !== "REAUTH"; if (phase === "loading" || phase === "redirecting") { return ( -
- -

- {phase === "redirecting" + -

+ : t("connect.loading", "Checking this request.") + } + > +
+ +
+ ); } if (phase === "notFound") { return ( -
+ -
+ ); } if (phase === "declined") { return ( -
+ -
+ ); } return ( -
-

- {t("connect.confirm.title", "Connect this server?")} -

+

{t( "connect.confirm.lead", @@ -177,6 +233,6 @@ export function ConnectApproveView({ {t("connect.confirm.approve", "Connect server")}

-
+ ); } diff --git a/frontend/editor/src/saas/routes/connect.css b/frontend/editor/src/saas/routes/connect.css index 5a709abfbe..391f9dad28 100644 --- a/frontend/editor/src/saas/routes/connect.css +++ b/frontend/editor/src/saas/routes/connect.css @@ -8,11 +8,10 @@ text-align: left; } -.saas-connect__title { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--c-text); +.saas-connect__waiting { + display: flex; + justify-content: center; + padding: 1.5rem 0; } .saas-connect__lead { From 4ef2e3811c71af7b2c39da98d82e6ce5b03af3af Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:35:57 +0000 Subject: [PATCH 30/31] ci(preview): give PR previews the Stirling account config they need to link (#7728) Add CI steps to enable PR deploy servers to link to prod saas. This will allow pr testing of payment flows, usage of real credits etc --- .github/workflows/PR-Auto-Deploy-V2.yml | 46 +++++++++++++++++++++++++ docker/embedded/Dockerfile | 14 ++++++++ 2 files changed, 60 insertions(+) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index c55d70fd4e..b4bbd111e1 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -220,6 +220,42 @@ jobs: echo "app_short=${APP_HASH:0:8}" >> $GITHUB_OUTPUT fi + # The Stirling account previews connect to. Derived from the ref rather than stored as a URL + # so it cannot drift from the key: a mismatched pair is accepted by the browser and rejected + # by Supabase, surfacing much later as "session expired" on Usage rather than at sign-in. + # Secret only to match Saas-Dev-Deploy.yml, which owns the same value; a project ref is not + # itself sensitive, which is why SAAS_API_BASE_URL next to it is a plain variable. + - name: Resolve Stirling account config + id: saas + env: + PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }} + API_BASE_OVERRIDE: ${{ vars.SAAS_API_BASE_URL }} + run: | + # Set, this is the one value both halves use: the browser's portal reads and the backend's + # register/entitlement calls have to land on the same SaaS, and nothing checks that they + # do. Unset, only the backend gets a base, from its own compiled-in default. + API_BASE="${API_BASE_OVERRIDE:-https://stirling.com/app}" + echo "backend_base=${API_BASE}" >> "$GITHUB_OUTPUT" + + if [ -z "${PROJECT_REF}" ]; then + echo "Not configured for this environment: the preview will build without a Stirling" + echo "account, and the connect dialog will say so. To wire one up, set on the" + echo "pr-preview environment the secrets SAAS_DB_PROJECT_REF and" + echo "SAAS_SUPABASE_PUBLISHABLE_KEY, both from the same Supabase project." + echo "supabase_url=" >> "$GITHUB_OUTPUT" + echo "frontend_base=" >> "$GITHUB_OUTPUT" + else + # Only whether, not which: the ref is a secret here, so Actions masks it out of any + # line it appears in, derived URL included. + echo "Stirling account configured, at ${API_BASE}." + echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT" + # Deliberately the override and not API_BASE: the backend's default is a subpath URL + # nobody has confirmed answers /api/v1, and prod CORS does not list preview hostnames, + # so portal reads stay off until someone sets a base they have checked. Empty leaves the + # committed .env default alone, which is the clean "not configured" state. + echo "frontend_base=${API_BASE_OVERRIDE}" >> "$GITHUB_OUTPUT" + fi + - name: Check if image exists id: check-image run: | @@ -246,6 +282,9 @@ jobs: build-args: | VERSION_TAG=v2-alpha BUILD_PORTAL=${{ env.BUILD_PORTAL }} + VITE_SUPABASE_URL=${{ steps.saas.outputs.supabase_url }} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }} + VITE_SAAS_API_URL=${{ steps.saas.outputs.frontend_base }} platforms: linux/amd64 - name: Set up SSH @@ -279,6 +318,13 @@ jobs: environment: DISABLE_ADDITIONAL_FEATURES: "false" STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true" + STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL: "${{ steps.saas.outputs.backend_base }}" + # Off so preview traffic never accrues against a real wallet or trips its cap. The + # 402 gate is separate and stays on, so gating is still testable here. + STIRLING_BILLING_ACCOUNT_LINK_METERING_ENABLED: "false" + # Stated rather than inferred from the request: the callback has to come back to the + # preview hostname, not to the container's own :8080 behind this proxy. + SYSTEM_FRONTENDURL: "https://${V2_PORT}.ssl.stirlingpdf.cloud" SECURITY_ENABLELOGIN: "true" SECURITY_INITIALLOGIN_USERNAME: "${TEST_LOGIN_USERNAME}" SECURITY_INITIALLOGIN_PASSWORD: "${TEST_LOGIN_PASSWORD}" diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 80e163dd9a..6f525b2732 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -48,9 +48,23 @@ ENV STIRLING_FLAVOR=${STIRLING_FLAVOR} # portal or AI layers change; defaults false so normal builds skip the extra app. ARG BUILD_PORTAL=false +# Which Stirling account the portal connects to. Build-time because Vite inlines VITE_* into the +# bundle; there is no runtime override. Empty leaves the committed .env.proprietary defaults, which +# is what an ordinary image wants: no Stirling account and no connect flow. The publishable key is +# client-side by design, not a secret. Pass the URL and the key from the same Supabase project or +# the browser accepts the pair and Supabase rejects it, which surfaces later as "session expired". +ARG VITE_SUPABASE_URL="" +ARG VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="" +ARG VITE_SAAS_API_URL="" + # Bundle only the JPDFium native for this image's target arch. ARG TARGETARCH +# Exported only when non-empty: Vite reads process.env ahead of the .env files, so exporting an +# empty value would blank the committed default rather than fall back to it. RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \ + if [ -n "${VITE_SUPABASE_URL}" ]; then export VITE_SUPABASE_URL="${VITE_SUPABASE_URL}"; fi; \ + if [ -n "${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}" ]; then export VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}"; fi; \ + if [ -n "${VITE_SAAS_API_URL}" ]; then export VITE_SAAS_API_URL="${VITE_SAAS_API_URL}"; fi; \ STIRLING_FLAVOR=${STIRLING_FLAVOR} \ gradle clean build \ -PbuildWithFrontend=true \ From ceeec53df4b72bd13576cd5146c07adf1e5ba96b Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:12:06 +0000 Subject: [PATCH 31/31] Let a pipeline run on the editor, on upload or export (#7581) Redesigns the policies system so that the backend has an understanding of policies running over the Editor. The Editor is not set up as a source for the backend because the backend can't actively get files from it, they come in via the frontend sending them to the backend, so instead pipelines have a specific editor key in them to encode whether the pipeline is triggered on file upload/export in the editor. Also make a big effort in the frontend code towards genericising policy running. Previously, there was specific support in the main policy executor for each policy that it had to run, which was not going to be appropriate long-term, especially when users can run any pipeline in the editor. There's more work needed here for me to really be happy with it but this PR is plenty large on its own and moves it in the right direction. All of the above was required to allow arbitrary user pipelines to run in the editor. This PR makes it so that the user can select Editor as a source in the pipeline creator, along with whether it should run on upload or export. image --------- Co-authored-by: James Brunton --- .../policy/controller/PolicyController.java | 12 +- .../policy/model/EditorConfig.java | 34 ++ .../proprietary/policy/model/Policy.java | 36 ++- .../overview/PolicyOverviewService.java | 6 +- .../DefaultClassificationPolicySeeder.java | 9 +- .../policy/source/SourceOverviewService.java | 10 +- .../policy/store/InProcessPolicyStore.java | 3 +- .../policy/store/JpaPolicyStore.java | 67 +++- .../overview/PolicyOverviewServiceTest.java | 39 +++ ...DefaultClassificationPolicySeederTest.java | 20 +- .../source/SourceOverviewServiceTest.java | 10 +- .../policy/store/JpaPolicyStoreTest.java | 124 ++++++++ .../public/locales/en-US/translation.toml | 9 + .../fileManager/CompactFileDetails.tsx | 3 +- .../components/filesPage/VersionTimeline.tsx | 10 +- .../src/core/components/shared/ToolChain.tsx | 35 +-- .../core/contexts/file/FileReducer.test.ts | 35 ++- .../src/core/contexts/file/FileReducer.ts | 20 +- .../src/core/services/fileStubHelpers.ts | 4 +- .../classification-heuristic-upload.spec.ts | 14 +- .../stubbed/editor-pipeline-auto-run.spec.ts | 85 +++++ frontend/editor/src/core/types/file.ts | 3 + .../src/core/utils/toolOperationLabel.test.ts | 29 ++ .../src/core/utils/toolOperationLabel.ts | 17 + frontend/editor/src/portal/api/pipelines.ts | 2 + frontend/editor/src/portal/api/policies.ts | 7 + .../pipelines/PipelineInputTrigger.tsx | 140 +++++++++ .../pipelines/graph/PipelineGraph.tsx | 6 +- .../components/policies/PolicyDetailPanel.tsx | 16 +- .../components/policies/PolicySetupWizard.tsx | 22 +- .../src/portal/views/PipelineBuilder.css | 12 + .../src/portal/views/PipelineBuilder.test.tsx | 39 +++ .../src/portal/views/PipelineBuilder.tsx | 213 +++++++------ .../policies/PolicyAutoRunController.tsx | 7 +- .../policies/classificationLocalPass.ts | 169 ++++++++++ .../components/policies/policyLocalPass.ts | 34 ++ .../policies/policyRunStore.test.ts | 23 -- .../components/policies/policyRunStore.ts | 22 +- .../policies/useClientSideClassification.ts | 251 --------------- .../policies/usePolicyAutoRun.batch.test.tsx | 129 ++++++-- .../policies/usePolicyAutoRun.chain.test.tsx | 297 ++++++------------ .../usePolicyAutoRun.escalation.test.tsx | 155 --------- .../policies/usePolicyAutoRun.import.test.tsx | 1 + .../policies/usePolicyAutoRun.race.test.tsx | 28 +- .../usePolicyAutoRun.reentry.test.tsx | 1 + .../policies/usePolicyAutoRun.retry.test.tsx | 1 + .../components/policies/usePolicyAutoRun.ts | 158 ++-------- ...test.tsx => usePolicyLocalPasses.test.tsx} | 150 +++++++-- .../policies/usePolicyLocalPasses.ts | 144 +++++++++ .../data/classificationPolicy.test.ts | 96 +++--- .../proprietary/data/classificationPolicy.ts | 60 ++-- .../src/proprietary/hooks/usePolicies.test.ts | 75 ++++- .../src/proprietary/hooks/usePolicies.ts | 22 +- .../src/proprietary/policies/codec.test.ts | 63 +++- .../editor/src/proprietary/policies/codec.ts | 12 +- .../editor/src/proprietary/policies/types.ts | 17 + .../services/policyBackend.test.ts | 135 ++++++++ .../src/proprietary/services/policyBackend.ts | 12 +- .../proprietary/services/policyDispatch.ts | 97 ++++++ .../proprietary/services/policyExport.test.ts | 129 ++++++++ .../src/proprietary/services/policyExport.ts | 38 ++- .../services/policyPipeline.test.ts | 6 +- .../proprietary/services/policyPipeline.ts | 22 +- .../services/policyStorage.test.ts | 37 +++ .../src/proprietary/services/policyStorage.ts | 35 ++- .../editor/src/proprietary/types/policies.ts | 6 + 66 files changed, 2405 insertions(+), 1118 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java create mode 100644 frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts create mode 100644 frontend/editor/src/core/utils/toolOperationLabel.test.ts create mode 100644 frontend/editor/src/core/utils/toolOperationLabel.ts create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/classificationLocalPass.ts create mode 100644 frontend/editor/src/proprietary/components/policies/policyLocalPass.ts delete mode 100644 frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts delete mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx rename frontend/editor/src/proprietary/components/policies/{useClientSideClassification.test.tsx => usePolicyLocalPasses.test.tsx} (62%) create mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyLocalPasses.ts create mode 100644 frontend/editor/src/proprietary/services/policyBackend.test.ts create mode 100644 frontend/editor/src/proprietary/services/policyDispatch.ts create mode 100644 frontend/editor/src/proprietary/services/policyExport.test.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 5a5a3018b1..3dac4e284e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -336,6 +336,15 @@ public class PolicyController { * nothing to check. */ private void requireAccessibleOutput(Policy policy) { + // An editor policy hands its results back to the workspace the file came from. A stored + // destination would send the run to a folder or bucket instead, leaving the editor's copy + // untouched - and the editor's import would then have nothing to collect. + if (policy.editor().allowed() && !policy.outputIds().isEmpty()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "An editor policy delivers back to the editor and can't also have a" + + " destination"); + } for (String outputId : policy.outputIds()) { Source destination = sourceStore @@ -393,7 +402,8 @@ public class PolicyController { policy.steps(), policy.output(), policy.outputIds(), - teamId); + teamId, + policy.editor()); } /** Output secrets never leave the server: reads return the redaction sentinel instead. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java new file mode 100644 index 0000000000..9b15adea2d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.policy.model; + +/** + * How a policy participates in the editor: it fires in the browser as each file passes through, + * rather than being swept from a stored {@code Source} on a trigger. + * + *

An object rather than a bare flag so the moment it fires ({@code runOn}) travels with the + * decision, and so later editor-only settings have somewhere to live. + * + * @param allowed whether the editor may run this policy at all + * @param runOn which moment it fires on: {@code "upload"} or {@code "export"} + */ +public record EditorConfig(boolean allowed, String runOn) { + + public static final String UPLOAD = "upload"; + public static final String EXPORT = "export"; + + public EditorConfig { + runOn = EXPORT.equals(runOn) ? EXPORT : UPLOAD; + } + + /** Not an editor policy: swept server-side, or run only on demand. */ + public static EditorConfig disabled() { + return new EditorConfig(false, UPLOAD); + } + + public static EditorConfig onUpload() { + return new EditorConfig(true, UPLOAD); + } + + public static EditorConfig onExport() { + return new EditorConfig(true, EXPORT); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index 14b1eb325c..63b26f380c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.policy.model; import java.util.List; +import java.util.Optional; /** * A stored automation: ordered tool steps, input bindings, and output destinations. @@ -24,13 +25,29 @@ public record Policy( List steps, OutputSpec output, List outputIds, - Long teamId) { + Long teamId, + EditorConfig editor) { public Policy { inputs = inputs == null ? List.of() : List.copyOf(inputs); steps = steps == null ? List.of() : steps; output = output == null ? OutputSpec.inline() : output; outputIds = outputIds == null ? List.of() : List.copyOf(outputIds); + editor = editor == null ? EditorConfig.disabled() : editor; + } + + /** Without editor participation: a swept or on-demand policy. */ + public Policy( + String id, + String name, + String owner, + boolean enabled, + List inputs, + List steps, + OutputSpec output, + List outputIds, + Long teamId) { + this(id, name, owner, enabled, inputs, steps, output, outputIds, teamId, null); } /** @@ -70,6 +87,14 @@ public record Policy( return inputs.stream().map(PipelineInput::sourceId).toList(); } + /** + * The moment this policy fires in the editor ("upload" / "export"), or empty when the editor + * does not run it. Legacy blobs are lifted onto {@link EditorConfig} when they are read. + */ + public Optional editorRunOn() { + return editor.allowed() ? Optional.of(editor.runOn()) : Optional.empty(); + } + /** The distinct trigger types configured across this policy's inputs (manual inputs aside). */ public List triggerTypes() { return inputs.stream() @@ -82,17 +107,20 @@ public record Policy( /** A copy with the inline output replaced (e.g. resolved for the engine, or migrated). */ public Policy withOutput(OutputSpec resolved) { - return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId); + return new Policy( + id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId, editor); } /** A copy under a different owner (e.g. moving a seed off a placeholder name). */ public Policy withOwner(String newOwner) { - return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId); + return new Policy( + id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId, editor); } /** A copy referencing the given saved output destinations. */ public Policy withOutputIds(List newOutputIds) { - return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId); + return new Policy( + id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId, editor); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java index b2be7b668e..0d7845a209 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -114,10 +114,14 @@ public class PolicyOverviewService { /** * Summarise a policy's triggers for the overview row: "manual" when no input is triggered, * otherwise the distinct trigger types across its inputs (e.g. "folder-watch, schedule"). + * + *

An editor policy has no wire input to trigger, but it is not manual either - it fires in + * the editor on every upload or export, so it reports that rather than reading as on-demand. */ private static String triggerSummary(Policy policy) { List types = policy.triggerTypes(); - return types.isEmpty() ? "manual" : String.join(", ", types); + if (!types.isEmpty()) return String.join(", ", types); + return policy.editorRunOn().map(runOn -> "editor-" + runOn).orElse("manual"); } private static String outputSummary(OutputSpec output) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java index 9b347366bc..7d35198633 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.model.TeamCreatedEvent; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; @@ -98,9 +99,8 @@ public class DefaultClassificationPolicySeeder { static Policy defaultPolicy(Long teamId) { Map options = new HashMap<>(); options.put("categoryId", CATEGORY); - options.put("runOn", "upload"); options.put("mode", "new_version"); - options.put("sources", List.of("editor")); + options.put("sources", List.of()); options.put("scopeTypes", List.of()); options.put("reviewerEmail", ""); return new Policy( @@ -113,6 +113,9 @@ public class DefaultClassificationPolicySeeder { List.of(), List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())), new OutputSpec("inline", options), - teamId); + List.of(), + teamId, + // Classification runs in the editor on every upload. + EditorConfig.onUpload()); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java index 0f9df21440..10792591d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java @@ -107,14 +107,12 @@ public class SourceOverviewService { } /** - * Whether a policy runs from the editor. Editor membership is carried in the policy's output - * metadata ({@code output.options.sources}) - a client-side list the editor writes when a - * policy targets it - rather than as a persisted {@code sourceId}, because the editor is - * virtual and has no stored source to reference. + * Whether a policy runs from the editor. Read from the policy's first-class {@link + * stirling.software.proprietary.policy.model.EditorConfig}, never inferred from a sources list + * (the editor is not a real source). */ private static boolean runsFromEditor(Policy policy) { - Object sources = policy.output().options().get("sources"); - return sources instanceof List list && list.contains(EditorSource.ID); + return policy.editor().allowed(); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index 70d67bba0f..08bc253863 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -38,7 +38,8 @@ public class InProcessPolicyStore implements PolicyStore { policy.steps(), policy.output(), policy.outputIds(), - policy.teamId()); + policy.teamId(), + policy.editor()); policies.put(id, stored); // Existing policy keeps its position; a new one appends to the end of its team's queue. sortOrders.computeIfAbsent(id, key -> nextSortOrder(stored.teamId())); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 6edaa76c78..f335a4d754 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.store; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Service; @@ -11,8 +12,10 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyBinding; +import stirling.software.proprietary.policy.source.EditorSource; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @@ -48,7 +51,8 @@ public class JpaPolicyStore implements PolicyStore { policy.steps(), policy.output(), policy.outputIds(), - policy.teamId()); + policy.teamId(), + policy.editor()); PolicyEntity entity = new PolicyEntity(); entity.setId(id); @@ -148,7 +152,9 @@ public class JpaPolicyStore implements PolicyStore { // One unreadable row must never abort a bulk read or crash startup. private Optional toPolicy(PolicyEntity entity) { try { - JsonNode node = upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson())); + JsonNode node = + liftEditorConfig( + upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson()))); return Optional.of(objectMapper.treeToValue(node, Policy.class)); } catch (Exception e) { log.error( @@ -191,4 +197,61 @@ public class JpaPolicyStore implements PolicyStore { obj.remove("sourceIds"); return obj; } + + /** Categories whose editor moment defaulted to export before it was stored (see runOn.ts). */ + private static final Set EXPORT_BY_DEFAULT = Set.of("security"); + + /** + * Derive {@code editor} for a blob written before editor participation had its own field, from + * its {@code output.options}: allowed when {@code sources} lists {@code "editor"}, or - for a + * catalogue policy - when there is no {@code sources} list at all (an unnarrowed catalogue + * policy runs in the editor). + * + *

Runs on every read, deliberately outside {@link #upgradeLegacyShape}'s early return: a + * blob written after triggers moved onto {@code inputs} but before this field existed still + * needs lifting, and that early return would skip exactly those rows. + */ + private JsonNode liftEditorConfig(JsonNode root) { + if (!(root instanceof ObjectNode obj) || obj.hasNonNull("editor")) { + return root; + } + JsonNode options = obj.path("output").path("options"); + String categoryId = text(options, "categoryId"); + JsonNode sources = options.get("sources"); + boolean listed = sources != null && sources.isArray() && !sources.isEmpty(); + boolean allowed; + if (listed) { + // An explicit scope list decides: only the editor's own id puts it on the editor. + allowed = false; + for (JsonNode source : sources) { + if (source.isValueNode() && EditorSource.ID.equals(source.asString())) { + allowed = true; + break; + } + } + } else { + // No list: a catalogue policy ran in the editor by default, but a builder pipeline + // (no category) could not reach the editor at all, so silence is not consent there. + allowed = !categoryId.isBlank(); + } + ObjectNode editor = objectMapper.createObjectNode(); + editor.put("allowed", allowed); + editor.put("runOn", legacyRunOn(options, categoryId)); + obj.set("editor", editor); + return obj; + } + + /** The stored moment, or the category default the client applied when none was stored. */ + private static String legacyRunOn(JsonNode options, String categoryId) { + String stored = text(options, "runOn"); + if (EditorConfig.EXPORT.equals(stored) || EditorConfig.UPLOAD.equals(stored)) { + return stored; + } + return EXPORT_BY_DEFAULT.contains(categoryId) ? EditorConfig.EXPORT : EditorConfig.UPLOAD; + } + + private static String text(JsonNode parent, String field) { + JsonNode node = parent.path(field); + return node.isValueNode() ? node.asString() : ""; + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java index 373b596136..4d0830f9c5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java @@ -15,6 +15,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; @@ -223,6 +224,44 @@ class PolicyOverviewServiceTest { teamId)); } + @Test + void editorPolicyReportsItsRunMomentRatherThanReadingAsManual() { + policyStore.save( + new Policy( + null, + "Editor flatten", + "owner", + true, + List.of(), + List.of(new PipelineStep("/api/v1/misc/flatten", Map.of())), + OutputSpec.inline(), + List.of(), + 1L, + EditorConfig.onUpload())); + + PolicyView view = find(service.overview(), "Editor flatten"); + + assertEquals("editor-upload", view.trigger()); + } + + @Test + void sweptPolicyWithNoTriggeredInputIsStillManual() { + policyStore.save( + new Policy( + null, + "Swept compress", + "owner", + true, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline(), + 1L)); + + PolicyView view = find(service.overview(), "Swept compress"); + + assertEquals("manual", view.trigger()); + } + private static PolicyView find(PoliciesOverviewResponse response, String name) { return response.pipelines().stream() .filter(view -> view.name().equals(name)) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java index f6e82bd011..fb4fa6d419 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java @@ -64,14 +64,30 @@ class DefaultClassificationPolicySeederTest { assertThat(policy.teamId()).isEqualTo(7L); assertThat(policy.output().type()).isEqualTo("inline"); assertThat(policy.output().options().get("categoryId")).isEqualTo("classification"); - assertThat(policy.output().options().get("runOn")).isEqualTo("upload"); assertThat(policy.output().options().get("mode")).isEqualTo("new_version"); - assertThat(policy.output().options().get("sources")).isEqualTo(List.of("editor")); + // Editor participation is the policy's own flag, not a marker in the output options. + assertThat(policy.editor().allowed()).isTrue(); + assertThat(policy.editor().runOn()).isEqualTo("upload"); assertThat(policy.steps()).hasSize(1); assertThat(policy.steps().get(0).operation()) .isEqualTo("/api/v1/ai/tools/classify-and-label"); } + @Test + void marksEditorParticipationOnEditorConfigAndSeedsNoSources() { + when(policyStore.findByTeam(7L)).thenReturn(List.of()); + + seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme")); + + ArgumentCaptor saved = ArgumentCaptor.forClass(Policy.class); + verify(policyStore).save(saved.capture()); + Policy policy = saved.getValue(); + // Editor participation is on EditorConfig, not the sources list; the seed carries no + // sources. + assertThat(policy.editor().allowed()).isTrue(); + assertThat(policy.output().options().get("sources")).isEqualTo(List.of()); + } + @Test void doesNotSeedWhenAClassificationPolicyAlreadyExists() { when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L))); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java index a8c295acc8..66d75f8be0 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java @@ -15,6 +15,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; @@ -222,9 +223,7 @@ class SourceOverviewServiceTest { OutputSpec.inline())); } - /** - * A policy that targets the editor: membership rides in its output metadata, not a sourceId. - */ + /** A policy that targets the editor: membership on its {@link EditorConfig}, not a sourceId. */ private void editorPolicy(String name) { policyStore.save( new Policy( @@ -234,7 +233,10 @@ class SourceOverviewServiceTest { true, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), - new OutputSpec("inline", Map.of("sources", List.of("editor"))))); + OutputSpec.inline(), + List.of(), + null, + EditorConfig.onUpload())); } private void teamPolicy(String name, Long teamId, String... sourceIds) { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java index 2a1d2b4f11..ae95b3a3ce 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java @@ -18,6 +18,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; @@ -113,6 +114,129 @@ class JpaPolicyStoreTest { upgraded.inputs()); } + /** + * The regression this guards: before the editor lift, a blob written by the pre-{@code editor} + * seeder deserialized straight onto {@link EditorConfig#disabled()}, silently taking every + * upgraded install's Classification policy off the editor. + * + *

The {@code inputs} variant is the important one - {@link + * JpaPolicyStore#upgradeLegacyShape} returns early on it, so a lift living inside that method + * would miss exactly the rows written between the trigger migration and this field. + */ + @Test + void getLiftsALegacyEditorSourceOntoEditorConfigWhenInputsArePresent() { + Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"editor\"],")); + + assertEquals(EditorConfig.onUpload(), lifted.editor()); + assertEquals(Optional.of("upload"), lifted.editorRunOn()); + } + + @Test + void getLiftsALegacyEditorSourceOnThePreInputsShapeToo() { + // Oldest shape: policy-level trigger + sourceIds, so both migrations have to compose. + Policy lifted = + readLegacy( + legacyJson( + "\"trigger\":{\"type\":\"schedule\",\"options\":{}}," + + "\"sourceIds\":[\"s1\"],", + "\"sources\":[\"editor\"],")); + + assertEquals(EditorConfig.onUpload(), lifted.editor()); + assertEquals( + List.of(new PipelineInput("s1", new TriggerConfig("schedule", Map.of()))), + lifted.inputs()); + } + + @Test + void getTreatsAnUnnarrowedCataloguePolicyAsEditorRun() { + // Empty and absent both meant "nobody narrowed it", which the editor read as its own. + assertTrue(readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[],")).editor().allowed()); + assertTrue(readLegacy(legacyJson("\"inputs\":[],", "")).editor().allowed()); + } + + @Test + void getLeavesACataloguePolicyScopedElsewhereOffTheEditor() { + Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"sharepoint\"],")); + + assertFalse(lifted.editor().allowed()); + assertEquals(Optional.empty(), lifted.editorRunOn()); + } + + @Test + void getLeavesASourcelessBuilderPipelineOffTheEditor() { + // No categoryId: a pipeline built on the Pipelines page, which never reached the editor. + String json = + "{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[]," + + "\"steps\":[],\"output\":{\"type\":\"inline\",\"options\":{}}}"; + + assertFalse(readLegacy(json).editor().allowed()); + } + + @Test + void getKeepsTheCategoryDefaultMomentWhenNoRunOnWasStored() { + // Security enforced on export before runOn was persisted (frontend runOn.ts + // DEFAULT_RUN_ON). + String json = + "{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[]," + + "\"steps\":[],\"output\":{\"type\":\"inline\",\"options\":{" + + "\"categoryId\":\"security\",\"sources\":[\"editor\"]}}}"; + + assertEquals(EditorConfig.onExport(), readLegacy(json).editor()); + } + + @Test + void getNeverOverridesAnExplicitlyStoredEditorBlock() { + // A deliberate opt-out survives, so the lift stays safe to leave in permanently. + String json = + "{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[]," + + "\"steps\":[],\"editor\":{\"allowed\":false,\"runOn\":\"upload\"}," + + "\"output\":{\"type\":\"inline\",\"options\":{" + + "\"categoryId\":\"classification\",\"sources\":[\"editor\"]}}}"; + + assertFalse(readLegacy(json).editor().allowed()); + } + + /** + * Pins the wire shape the stubbed Playwright spec hardcodes: the derived block is additive, so + * a real response carries it alongside the untouched legacy options bag. + */ + @Test + void getLeavesTheLegacyOptionsBagIntactSoTheResponseCarriesBoth() { + Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"editor\"],")); + + assertEquals(List.of("editor"), lifted.output().options().get("sources")); + String wire = objectMapper.writeValueAsString(lifted); + assertTrue( + wire.contains("\"editor\":{\"allowed\":true,\"runOn\":\"upload\"}"), + "expected the derived editor block on the wire, got: " + wire); + } + + /** + * The blob main's DefaultClassificationPolicySeeder wrote, with the shape bits parameterised. + */ + private static String legacyJson(String shapeFields, String sourcesField) { + return "{\"id\":\"p1\",\"name\":\"Classification Policy\",\"owner\":\"system\"," + + "\"enabled\":true," + + shapeFields + + "\"steps\":[{\"operation\":\"/api/v1/ai/tools/classify-and-label\"," + + "\"parameters\":{}}]," + + "\"output\":{\"type\":\"inline\",\"options\":{" + + "\"categoryId\":\"classification\",\"runOn\":\"upload\"," + + "\"mode\":\"new_version\"," + + sourcesField + + "\"scopeTypes\":[],\"reviewerEmail\":\"\"}},\"teamId\":1}"; + } + + private Policy readLegacy(String policyJson) { + PolicyEntity entity = new PolicyEntity(); + entity.setId("p1"); + entity.setName("legacy"); + entity.setEnabled(true); + entity.setPolicyJson(policyJson); + when(repository.findById("p1")).thenReturn(Optional.of(entity)); + return store.get("p1").orElseThrow(); + } + @Test void saveDenormalizesTeamIdForScopedQueries() { store.save( diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index df996b23c0..345cf07ab2 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -8220,6 +8220,9 @@ chooseDestination = "Choose a destination" chooseOperation = "Choose what this step does" chooseSource = "Choose a source" discard = "Discard changes" +editorDestination = "Editor" +editorDestinationDetail = "Replaces the file you ran it on" +editorDestinationHelp = "This pipeline runs on the files in your workspace, and its results replace the file it ran on. There is nowhere else to send them." inputs = "Input" inputSource = "Input source" inputTrigger = "Trigger" @@ -8231,6 +8234,10 @@ needsSource = "No source chosen" noToolMatches = "No tools match your search." pause = "Pause" rename = "Rename pipeline" +runOn = "Runs on" +runOnExport = "Every export" +runOnTooltip = "Choose when this pipeline runs on your files: when you add them, or when you export them." +runOnUpload = "Every upload" searchTools = "Search tools" sendToSystem = "Send to another system" stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}." @@ -8371,6 +8378,8 @@ steps = "Steps" trigger = "Trigger" [portal.pipelines.trigger] +editor-export = "Every export" +editor-upload = "Every upload" folder-watch = "Folder watch" manual = "Manual" schedule = "Scheduled" diff --git a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx index 988ecf789c..27f55ce060 100644 --- a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx +++ b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx @@ -7,6 +7,7 @@ import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import { useTranslation } from "react-i18next"; import { getFileSize } from "@app/utils/fileUtils"; +import { toolOperationLabel } from "@app/utils/toolOperationLabel"; import { StirlingFileStub } from "@app/types/fileContext"; import { PrivateContent } from "@app/components/shared/PrivateContent"; @@ -115,7 +116,7 @@ const CompactFileDetails: React.FC = ({ {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( {currentFile.toolHistory - .map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)) + .map((tool) => toolOperationLabel(tool, t)) .join(" → ")} )} diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx index f484f936ef..77d8808c17 100644 --- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx @@ -10,7 +10,7 @@ import HistoryIcon from "@mui/icons-material/History"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import { FileId, ToolOperation } from "@app/types/file"; -import { ToolId } from "@app/types/toolId"; +import { toolOperationLabel } from "@app/utils/toolOperationLabel"; import { StirlingFileStub } from "@app/types/fileContext"; import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; import { downloadFileFromStorage } from "@app/utils/downloadUtils"; @@ -64,10 +64,10 @@ function deltaToolFor( return curr[priorLen] ?? null; } -/** Translated tool name via `home.{toolId}.title`. */ -function ToolLabel({ toolId }: { toolId: ToolId }) { +/** The operation's own label when it has one, else its translated tool name. */ +function ToolLabel({ operation }: { operation: ToolOperation }) { const { t } = useTranslation(); - return {t(`home.${toolId}.title`, toolId)}; + return {toolOperationLabel(operation, t)}; } export interface VersionTimelineProps { @@ -242,7 +242,7 @@ export function VersionTimeline({ style={{ color: "var(--c-text)" }} > {delta ? ( - + ) : ( t("filesPage.versionOrigin", "Original upload") )} diff --git a/frontend/editor/src/core/components/shared/ToolChain.tsx b/frontend/editor/src/core/components/shared/ToolChain.tsx index 249e7802cc..7974614759 100644 --- a/frontend/editor/src/core/components/shared/ToolChain.tsx +++ b/frontend/editor/src/core/components/shared/ToolChain.tsx @@ -6,8 +6,8 @@ import React from "react"; import { Text, Tooltip, Badge, Group } from "@mantine/core"; import { ToolOperation } from "@app/types/file"; +import { toolOperationLabel } from "@app/utils/toolOperationLabel"; import { useTranslation } from "react-i18next"; -import { ToolId } from "@app/types/toolId"; interface ToolChainProps { toolChain: ToolOperation[]; @@ -29,11 +29,7 @@ const ToolChain: React.FC = ({ const { t } = useTranslation(); if (!toolChain || toolChain.length === 0) return null; - const toolIds = toolChain.map((tool) => tool.toolId); - - const getToolName = (toolId: ToolId) => { - return t(`home.${toolId}.title`, toolId); - }; + const getToolName = (tool: ToolOperation) => toolOperationLabel(tool, t); // Create full tool chain for tooltip const fullChainDisplay = @@ -42,7 +38,7 @@ const ToolChain: React.FC = ({ {toolChain.map((tool, index) => ( - {getToolName(tool.toolId)} + {getToolName(tool)} {index < toolChain.length - 1 && ( @@ -53,18 +49,21 @@ const ToolChain: React.FC = ({ ))} ) : ( - {toolIds.map(getToolName).join(" → ")} + {toolChain.map(getToolName).join(" → ")} ); // Create truncated display based on available space const getTruncatedDisplay = () => { - if (toolIds.length <= 2) { + if (toolChain.length <= 2) { // Show all tools if 2 or fewer - return { text: toolIds.map(getToolName).join(" → "), isTruncated: false }; + return { + text: toolChain.map(getToolName).join(" → "), + isTruncated: false, + }; } else { // Show first tool ... last tool for longer chains return { - text: `${getToolName(toolIds[0])} → +${toolIds.length - 2} → ${getToolName(toolIds[toolIds.length - 1])}`, + text: `${getToolName(toolChain[0])} → +${toolChain.length - 2} → ${getToolName(toolChain[toolChain.length - 1])}`, isTruncated: true, }; } @@ -75,10 +74,10 @@ const ToolChain: React.FC = ({ // Compact style for very small spaces if (displayStyle === "compact") { const compactText = - toolIds.length === 1 - ? getToolName(toolIds[0]) - : `${toolIds.length} tools`; - const isCompactTruncated = toolIds.length > 1; + toolChain.length === 1 + ? getToolName(toolChain[0]) + : `${toolChain.length} tools`; + const isCompactTruncated = toolChain.length > 1; const compactElement = ( = ({ {toolChain.slice(0, 3).map((tool, index) => ( - {getToolName(tool.toolId)} + {getToolName(tool)} {index < Math.min(toolChain.length - 1, 2) && ( @@ -131,7 +130,7 @@ const ToolChain: React.FC = ({ ... - {getToolName(toolChain[toolChain.length - 1].toolId)} + {getToolName(toolChain[toolChain.length - 1])} )} @@ -140,7 +139,7 @@ const ToolChain: React.FC = ({ ); return isBadgesTruncated ? ( - + {badgesElement} ) : ( diff --git a/frontend/editor/src/core/contexts/file/FileReducer.test.ts b/frontend/editor/src/core/contexts/file/FileReducer.test.ts index cdba68b013..92f82f3ed0 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.test.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.test.ts @@ -192,6 +192,39 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)", ]); }); + it("carries a no-label [] verdict forward (classified, not unclassified)", () => { + // "a" was classified and found nothing ([]) - distinct from null (never classified). The output + // must inherit [] so the local pass treats it as already-classified and never re-classifies (or + // re-bills) it. + const start = stateWith([stub("a", { classificationLabels: [] })]); + const next = fileContextReducer(start, { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId], + outputStirlingFileStubs: [stub("b")], + }, + }); + expect(next.files.byId["b" as FileId].classificationLabels).toEqual([]); + }); + + it("prefers a real label over a merge input's no-label [] verdict", () => { + // Merge of a labelled file and a no-label one: the output should keep the real label. + const start = stateWith([ + stub("a", { classificationLabels: [] }), + stub("b", { classificationLabels: ["Invoice"] }), + ]); + const next = fileContextReducer(start, { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId, "b" as FileId], + outputStirlingFileStubs: [stub("c")], + }, + }); + expect(next.files.byId["c" as FileId].classificationLabels).toEqual([ + "Invoice", + ]); + }); + it("an output's own classificationLabels win over the input's", () => { // A re-classify produces an output that already carries (fresher) labels. const start = stateWith([stub("a", { classificationLabels: ["Invoice"] })]); @@ -211,7 +244,7 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)", it("carries classificationConfidence forward with the labels", () => { // The confidence is part of the verdict: without it the escalation decision - // (shouldDispatchToAi) dies at the version boundary and a chained + // (localVerdictNeedsEscalation) dies at the version boundary and a chained // classification never runs. const start = stateWith([ stub("a", { diff --git a/frontend/editor/src/core/contexts/file/FileReducer.ts b/frontend/editor/src/core/contexts/file/FileReducer.ts index 1ed23b11d6..ba69492b23 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.ts @@ -389,16 +389,16 @@ export function fileContextReducer( // Carry the document's classification verdict forward across the edit: any // tool that versions/derives a classified file keeps it in its label // groups instead of dropping to "Other" and waiting on a PDF re-read. - // Inherited from the first input that has labels, together with that - // verdict's confidence - the escalation decision (shouldDispatchToAi) is - // about the document, not about which step produced the current bytes, so - // it must survive the version boundary. An output that already carries its - // own verdict (e.g. a fresh classify result) keeps it. - const verdictDonor = inputFileIds - .map((id) => state.files.byId[id]) - .find( + // Inherited together with that verdict's confidence - the escalation + // decision (localVerdictNeedsEscalation) is about the document, not about + // which step produced the current bytes, so it must survive the version + // boundary. An output that already carries its own verdict (e.g. a fresh + // classify result) keeps it. + const inputStubs = inputFileIds.map((id) => state.files.byId[id]); + const verdictDonor = + inputStubs.find( (s) => s?.classificationLabels && s.classificationLabels.length > 0, - ); + ) ?? inputStubs.find((s) => s?.classificationLabels !== undefined); // Mark every consume output as tool-produced (the single chokepoint for // both versioned edits and independent artifacts like convert/split/merge) @@ -409,7 +409,7 @@ export function fileContextReducer( ...stub, derivedFromTool: true, sourceFileIds, - ...(stub.classificationLabels == null && verdictDonor + ...(stub.classificationLabels === undefined && verdictDonor ? { classificationLabels: verdictDonor.classificationLabels, classificationConfidence: diff --git a/frontend/editor/src/core/services/fileStubHelpers.ts b/frontend/editor/src/core/services/fileStubHelpers.ts index 60489c8c94..836d000af7 100644 --- a/frontend/editor/src/core/services/fileStubHelpers.ts +++ b/frontend/editor/src/core/services/fileStubHelpers.ts @@ -14,6 +14,8 @@ export async function createStirlingFilesAndStubs( files: File[], parentStub: StirlingFileStub, toolId: ToolId, + /** Shown instead of the tool's name in version history (a policy passes its pipeline name). */ + label?: string, ): Promise<{ stirlingFiles: StirlingFile[]; stubs: StirlingFileStub[] }> { const stirlingFiles: StirlingFile[] = []; const stubs: StirlingFileStub[] = []; @@ -22,7 +24,7 @@ export async function createStirlingFilesAndStubs( const processedFileMetadata = await generateProcessedFileMetadata(file); const childStub = createChildStub( parentStub, - { toolId, timestamp: Date.now() }, + { toolId, timestamp: Date.now(), ...(label ? { label } : {}) }, file, processedFileMetadata?.thumbnailUrl, processedFileMetadata, diff --git a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts index 856ddd58f5..7d42434385 100644 --- a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts @@ -12,14 +12,20 @@ const FIXTURES = path.join( "../test-fixtures/classification/unlabelled", ); -/** The stored policy DefaultClassificationPolicySeeder writes for a new team. */ +/** + * What GET /api/v1/policies returns for the row an older + * DefaultClassificationPolicySeeder wrote - i.e. one stored before editor + * participation had its own field. `JpaPolicyStore.liftEditorConfig` derives the + * `editor` block from the legacy `output.options` on read; the lift is additive, + * so a real response carries both. Migration of the stored shape itself is + * covered by JpaPolicyStoreTest, which exercises the Java the stub stands in for. + */ const SEEDED_POLICY = { id: "seeded-classification", name: "Classification Policy", owner: "system", enabled: true, - trigger: null, - sourceIds: [], + inputs: [], steps: [{ operation: "/api/v1/ai/tools/classify-and-label", parameters: {} }], output: { type: "inline", @@ -32,7 +38,9 @@ const SEEDED_POLICY = { reviewerEmail: "", }, }, + outputIds: [], teamId: 1, + editor: { allowed: true, runOn: "upload" }, }; test("a 10-file upload wave classifies every file into its group", async ({ diff --git a/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts b/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts new file mode 100644 index 0000000000..a71bc98f33 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts @@ -0,0 +1,85 @@ +import path from "path"; +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles } from "@app/tests/helpers/ui-helpers"; + +// A pipeline reaches the editor auto-run through its own editor flag; a swept one must not. + +test.use({ autoGoto: false }); + +const SAMPLE = path.join( + import.meta.dirname, + "../test-fixtures/classification/unlabelled/invoice_acme.pdf", +); + +/** A builder-made pipeline: no categoryId, one harmless step. */ +function builderPipeline(editor: { allowed: boolean; runOn: string }) { + return { + id: "builder-pipeline-1", + name: "Flatten everything", + owner: "system", + enabled: true, + trigger: null, + sourceIds: [], + steps: [{ operation: "/api/v1/misc/flatten", parameters: {} }], + output: { type: "inline", options: { mode: "new_version" } }, + editor, + teamId: 1, + }; +} + +/** Install the policy list + capture every stored-policy run dispatch. */ +async function armed(page: import("@playwright/test").Page, policy: unknown) { + const dispatched: string[] = []; + await page.route("**/api/v1/policies", (route) => + route.fulfill({ json: [policy] }), + ); + await page.route("**/api/v1/policies/*/run", (route) => { + dispatched.push(new URL(route.request().url()).pathname); + return route.fulfill({ json: { jobId: "job-1" } }); + }); + return dispatched; +} + +test("an editor pipeline set to run on upload dispatches when a file is added", async ({ + page, +}) => { + const dispatched = await armed( + page, + builderPipeline({ allowed: true, runOn: "upload" }), + ); + + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + await uploadFiles(page, SAMPLE); + + await expect + .poll(() => dispatched, { timeout: 15_000 }) + .toContain("/api/v1/policies/builder-pipeline-1/run"); +}); + +test("a swept pipeline never runs on editor upload", async ({ page }) => { + const dispatched = await armed( + page, + builderPipeline({ allowed: false, runOn: "upload" }), + ); + + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + await uploadFiles(page, SAMPLE); + + await page.waitForTimeout(5_000); + expect(dispatched).toEqual([]); +}); + +test("an editor pipeline set to run on export does not fire on upload", async ({ + page, +}) => { + const dispatched = await armed( + page, + builderPipeline({ allowed: true, runOn: "export" }), + ); + + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + await uploadFiles(page, SAMPLE); + + await page.waitForTimeout(5_000); + expect(dispatched).toEqual([]); +}); diff --git a/frontend/editor/src/core/types/file.ts b/frontend/editor/src/core/types/file.ts index 98a0094f43..c6ec1898cb 100644 --- a/frontend/editor/src/core/types/file.ts +++ b/frontend/editor/src/core/types/file.ts @@ -16,6 +16,9 @@ export type FileId = string & { readonly [tag]: "FileId" }; export interface ToolOperation { toolId: ToolId; timestamp: number; + /** Overrides the tool's own name in history. Set by a policy run to its pipeline's name, since + * every policy records the same "automate" toolId. */ + label?: string; } /** diff --git a/frontend/editor/src/core/utils/toolOperationLabel.test.ts b/frontend/editor/src/core/utils/toolOperationLabel.test.ts new file mode 100644 index 0000000000..9b21f6e65f --- /dev/null +++ b/frontend/editor/src/core/utils/toolOperationLabel.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import type { TFunction } from "i18next"; +import { toolOperationLabel } from "@app/utils/toolOperationLabel"; +import type { ToolOperation } from "@app/types/file"; + +// Stands in for i18next: echoes the key so the assertions show which lookup ran. +const t = ((key: string, fallback?: string) => + key === "home.automate.title" ? "Automate" : (fallback ?? key)) as TFunction; + +const op = (over: Partial): ToolOperation => + ({ toolId: "automate", timestamp: 0, ...over }) as ToolOperation; + +describe("toolOperationLabel", () => { + it("prefers the operation's own label", () => { + expect(toolOperationLabel(op({ label: "add-page-numbers" }), t)).toBe( + "add-page-numbers", + ); + }); + + // Every policy records the same "automate" toolId, so without a label each automated version + // reads identically no matter which pipeline produced it. + it("falls back to the tool's name when unlabelled", () => { + expect(toolOperationLabel(op({}), t)).toBe("Automate"); + }); + + it("keeps the fallback for an empty label rather than rendering a blank", () => { + expect(toolOperationLabel(op({ label: "" }), t)).toBe("Automate"); + }); +}); diff --git a/frontend/editor/src/core/utils/toolOperationLabel.ts b/frontend/editor/src/core/utils/toolOperationLabel.ts new file mode 100644 index 0000000000..1f29d02390 --- /dev/null +++ b/frontend/editor/src/core/utils/toolOperationLabel.ts @@ -0,0 +1,17 @@ +import type { TFunction } from "i18next"; +import type { ToolOperation } from "@app/types/file"; + +/** + * What produced a version, for the history surfaces. A policy run carries its own label (the + * pipeline's name) because every policy records the same "automate" toolId, which would otherwise + * render every automated version identically. + */ +export function toolOperationLabel( + operation: ToolOperation, + t: TFunction, +): string { + // Truthiness, not nullish: a blank label would otherwise render as an empty history entry. + return ( + operation.label || t(`home.${operation.toolId}.title`, operation.toolId) + ); +} diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index e441fcdac8..2be269c1ac 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -70,6 +70,8 @@ export interface Policy { * output} is used. */ outputIds: string[]; + /** Whether the editor runs this policy per file, and on which moment. */ + editor?: { allowed: boolean; runOn: "upload" | "export" }; teamId?: number | null; } diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index da545b98c4..6963ccc1b1 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -76,6 +76,8 @@ export interface PolicyState { configured: boolean; status: PolicyStatus; sources: string[]; + /** Whether the editor runs this policy per file; stored, not derived from `sources`. */ + runsOnEditor?: boolean; scopeTypes: string[]; reviewerEmail: string; fieldValues: Record; @@ -92,6 +94,7 @@ export interface PolicyState { export interface PolicySetupResult { fieldValues: Record; sources: string[]; + runsOnEditor: boolean; scopeTypes: string[]; reviewerEmail: string; outputMode: "new_file" | "new_version"; @@ -433,6 +436,7 @@ function decoratePolicy( configured: true, status, sources: decoded.sources, + runsOnEditor: decoded.runsOnEditor, scopeTypes: decoded.scopeTypes, reviewerEmail: decoded.reviewerEmail, fieldValues: decoded.fieldValues, @@ -595,6 +599,7 @@ export function buildWireFromSetup( enabled, categoryId: entry.category.id, sources: result.sources, + runsOnEditor: result.runsOnEditor, scopeTypes: result.scopeTypes, reviewerEmail: result.reviewerEmail, fieldValues: result.fieldValues, @@ -625,6 +630,8 @@ export function buildWireFromState( enabled, categoryId: entry.category.id, sources: s.sources, + // Carry the stored value through: pause/resume must not re-derive it. + runsOnEditor: s.runsOnEditor === true, scopeTypes: s.scopeTypes, reviewerEmail: s.reviewerEmail, fieldValues: s.fieldValues, diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx b/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx new file mode 100644 index 0000000000..eb5af9cd21 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx @@ -0,0 +1,140 @@ +// Swept sources are scheduled or triggered server-side; the editor runs client-side as each file +// passes through, so the two get different controls. + +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { FormField, Input, Select } from "@app/ui"; + +export type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS"; +export type EditorRunOn = "upload" | "export"; + +const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"]; + +/** Empty trigger type = manual-only (no automatic trigger). */ +export const MANUAL = ""; +/** Sentinel for manual: Mantine's Select reads "" as no selection. Maps to {@link MANUAL}. */ +export const MANUAL_OPTION = "manual"; + +/** One input row in the builder: a source paired with its own trigger config. */ +export interface WorkingInput { + sourceId: string; + triggerType: string; + scheduleCount: string; + scheduleUnit: ScheduleUnit; +} + +export interface PipelineInputTriggerProps { + input: WorkingInput; + onInputChange: (patch: Partial) => void; + /** Trigger types offered for this row's source (manual first). */ + triggerOptions: { value: string; label: string }[]; + /** The chosen source is the editor, so the pipeline runs in the browser. */ + isEditorInput: boolean; + runOn: EditorRunOn; + onRunOnChange: (runOn: EditorRunOn) => void; +} + +export function PipelineInputTrigger({ + input, + onInputChange, + triggerOptions, + isEditorInput, + runOn, + onRunOnChange, +}: PipelineInputTriggerProps) { + const { t } = useTranslation(); + + if (isEditorInput) { + const label = t("portal.pipelines.builder.runOn", "Runs on"); + return ( + + + {label} + + + + } + > + + onInputChange({ + triggerType: value && value !== MANUAL_OPTION ? value : MANUAL, + }) + } + options={triggerOptions} + /> + + + {input.triggerType === "schedule" && ( +

+ + {t("portal.pipelines.composer.scheduleEvery")} + + onInputChange({ scheduleCount: e.target.value })} + className="portal-builder__schedule-count" + /> + - updateInput({ - triggerType: - value && value !== MANUAL_OPTION ? value : MANUAL, - }) - } - options={triggerOptionsFor(input.sourceId)} - /> - - - {input.triggerType === "schedule" && ( -
- - {t("portal.pipelines.composer.scheduleEvery")} - - - updateInput({ scheduleCount: e.target.value }) - } - className="portal-builder__schedule-count" - /> -