From bc128ec516719304f7f3a2b24b71e34092d76c5b Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Fri, 17 Jul 2026 17:06:33 +0200 Subject: [PATCH] Port trust center features after main rebase Move Emile's commitment CRUD into complianceportal management, wire console and visitor GraphQL, and drop portal magic-link sign-in in favor of OAuth /initiate while keeping documents, NDA/full-name gates, and access-request resume markers. Signed-off-by: Bryan Frimin --- .../compliance-portal/src/_locales/en-US.json | 30 -- .../compliance-portal/src/_locales/fr-FR.json | 30 -- .../src/components/TopBar/TopBar.tsx | 12 +- .../src/components/TopBar/TopBarMobileNav.tsx | 6 +- .../src/components/auth/OIDCButton.tsx | 75 ----- .../src/components/auth/OIDCProviders.tsx | 94 ------ .../src/components/auth/SignInDialog.tsx | 53 --- .../src/components/auth/SignInForm.tsx | 168 ---------- .../src/lib/auth/SignInDialogProvider.tsx | 53 --- .../src/lib/auth/continueUrl.ts | 9 + .../src/lib/auth/signInDialogContext.ts | 43 --- .../src/lib/auth/useResumeAccessRequest.ts | 2 +- .../mailingList/SubscribeDialogProvider.tsx | 8 +- .../src/pages/MainLayout.tsx | 33 +- .../src/pages/auth/AuthLayout.tsx | 3 +- .../pages/auth/MagicLinkAlreadyUsedPage.tsx | 39 --- .../src/pages/auth/MagicLinkExpiredPage.tsx | 39 --- .../src/pages/auth/VerifyMagicLinkPage.tsx | 103 ------ .../src/pages/auth/routes.ts | 15 +- .../_components/DocumentAccessAction.tsx | 2 +- .../pages/documents/_lib/useAccessRequest.ts | 17 +- .../src/pages/nda/NDAPage.tsx | 6 +- pkg/complianceportal/management/actions.go | 14 + .../management/commitment_group_service.go} | 190 ++++++----- .../management/commitment_service.go} | 212 ++++++------ pkg/complianceportal/management/policies.go | 12 +- .../v1/trust_center_resolvers.go | 99 ++++-- .../console/v1/graphql/trust_center.graphql | 246 ++++++++++++++ .../api/console/v1/trust_center_resolvers.go | 308 +++++++++++++++++- 29 files changed, 898 insertions(+), 1023 deletions(-) delete mode 100644 apps/compliance-portal/src/components/auth/OIDCButton.tsx delete mode 100644 apps/compliance-portal/src/components/auth/OIDCProviders.tsx delete mode 100644 apps/compliance-portal/src/components/auth/SignInDialog.tsx delete mode 100644 apps/compliance-portal/src/components/auth/SignInForm.tsx delete mode 100644 apps/compliance-portal/src/lib/auth/SignInDialogProvider.tsx delete mode 100644 apps/compliance-portal/src/lib/auth/signInDialogContext.ts delete mode 100644 apps/compliance-portal/src/pages/auth/MagicLinkAlreadyUsedPage.tsx delete mode 100644 apps/compliance-portal/src/pages/auth/MagicLinkExpiredPage.tsx delete mode 100644 apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx rename pkg/{probo/compliance_portal_commitment_group_service.go => complianceportal/management/commitment_group_service.go} (56%) rename pkg/{probo/compliance_portal_commitment_service.go => complianceportal/management/commitment_service.go} (57%) diff --git a/apps/compliance-portal/src/_locales/en-US.json b/apps/compliance-portal/src/_locales/en-US.json index 9eac2b74e..45d0c49e8 100644 --- a/apps/compliance-portal/src/_locales/en-US.json +++ b/apps/compliance-portal/src/_locales/en-US.json @@ -28,29 +28,9 @@ "zoomIn": "Zoom in" }, "auth": { - "backToPortal": "Back to portal", - "signIn": { - "title": "Sign in to continue", - "description": "Access protected resources or submit a request", - "or": "OR", - "withProvider": "Sign in with {{provider}}", - "emailLabel": "Email Address", - "emailPlaceholder": "you@company.com", - "emailRequired": "Email is required", - "emailInvalid": "Enter a valid email address", - "sendMagicLink": "Send Magic Link", - "resend": "Resend link", - "resendIn": "Resend link in {{seconds}}s", - "magicLinkSent": "Magic link sent!", - "magicLinkSentNote": "Check your email and use the link to sign in." - }, "requestAccess": { "success": "Access requested" }, - "verify": { - "title": "Confirming your email", - "description": "Please wait while we sign you in\u2026" - }, "fullName": { "title": "Add your name", "label": "Full Name", @@ -59,17 +39,7 @@ "tooShort": "Full name is too short", "submit": "Continue" }, - "magicLinkExpired": { - "title": "Link expired", - "description": "This magic link has expired. Please request a new one." - }, - "magicLinkAlreadyUsed": { - "title": "Link already used", - "description": "This magic link has already been used. Please request a new one." - }, "errors": { - "magicLinkFailed": "Couldn't send the magic link. Please try again.", - "verifyFailed": "Couldn't verify the link. Please try again.", "fullNameFailed": "Couldn't save your name. Please try again.", "requestFailed": "Couldn't complete your access request. Please try again." } diff --git a/apps/compliance-portal/src/_locales/fr-FR.json b/apps/compliance-portal/src/_locales/fr-FR.json index 65f8a4f26..a97146490 100644 --- a/apps/compliance-portal/src/_locales/fr-FR.json +++ b/apps/compliance-portal/src/_locales/fr-FR.json @@ -28,29 +28,9 @@ "zoomIn": "Zoom avant" }, "auth": { - "backToPortal": "Retour au portail", - "signIn": { - "title": "Connectez-vous pour continuer", - "description": "Accédez aux ressources protégées ou soumettez une demande", - "or": "OU", - "withProvider": "Se connecter avec {{provider}}", - "emailLabel": "Adresse e-mail", - "emailPlaceholder": "vous@entreprise.com", - "emailRequired": "L'e-mail est requis", - "emailInvalid": "Saisissez une adresse e-mail valide", - "sendMagicLink": "Envoyer le lien magique", - "resend": "Renvoyer le lien", - "resendIn": "Renvoyer le lien dans {{seconds}}s", - "magicLinkSent": "Lien magique envoyé !", - "magicLinkSentNote": "Consultez votre e-mail et utilisez le lien pour vous connecter." - }, "requestAccess": { "success": "Accès demandé" }, - "verify": { - "title": "Confirmation de votre e-mail", - "description": "Veuillez patienter pendant que nous vous connectons\u2026" - }, "fullName": { "title": "Ajoutez votre nom", "label": "Nom complet", @@ -59,17 +39,7 @@ "tooShort": "Le nom complet est trop court", "submit": "Continuer" }, - "magicLinkExpired": { - "title": "Lien expiré", - "description": "Ce lien magique a expiré. Veuillez en demander un nouveau." - }, - "magicLinkAlreadyUsed": { - "title": "Lien déjà utilisé", - "description": "Ce lien magique a déjà été utilisé. Veuillez en demander un nouveau." - }, "errors": { - "magicLinkFailed": "Impossible d'envoyer le lien magique. Veuillez réessayer.", - "verifyFailed": "Impossible de vérifier le lien. Veuillez réessayer.", "fullNameFailed": "Impossible d'enregistrer votre nom. Veuillez réessayer.", "requestFailed": "Impossible de finaliser votre demande d'accès. Veuillez réessayer." } diff --git a/apps/compliance-portal/src/components/TopBar/TopBar.tsx b/apps/compliance-portal/src/components/TopBar/TopBar.tsx index 0e3ceea01..7bd9f1fbb 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBar.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBar.tsx @@ -27,6 +27,8 @@ import { useTranslation } from "react-i18next"; import { graphql, useFragment } from "react-relay"; import { Link as RouterLink, useLocation } from "react-router"; +import { buildRequestAllContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl"; + import type { TopBar_query$key } from "./__generated__/TopBar_query.graphql"; import { TOP_BAR_NAV_ITEMS } from "./navItems"; import { TopBarMobileNav } from "./TopBarMobileNav"; @@ -110,15 +112,7 @@ export function TopBar({ queryKey }: TopBarProps) { highContrast iconStart={} onClick={() => { - const initiateURL = new URL( - "/initiate", - window.location.origin, - ); - initiateURL.searchParams.set( - "continue", - location.pathname + location.search + location.hash, - ); - window.location.href = initiateURL.toString(); + redirectToInitiate(buildRequestAllContinueUrl()); }} > {t("topBar.getAccess")} diff --git a/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx b/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx index da8f4fec2..ab58721df 100644 --- a/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx +++ b/apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx @@ -43,8 +43,7 @@ import { useTranslation } from "react-i18next"; import { graphql, useFragment } from "react-relay"; import { useLocation } from "react-router"; -import { buildRequestAllContinueUrl } from "#/lib/auth/continueUrl"; -import { useSignInDialog } from "#/lib/auth/signInDialogContext"; +import { buildRequestAllContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl"; import { useSignOut } from "#/lib/auth/useSignOut"; import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext"; @@ -72,7 +71,6 @@ function isActive(pathname: string, to: string): boolean { export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) { const { t } = useTranslation(); const { pathname } = useLocation(); - const { openSignIn } = useSignInDialog(); const { openSubscribe, isSubscribed, unsubscribe, isUnsubscribing } = useSubscribeDialog(); const [signOut, isSigningOut] = useSignOut(); const [open, setOpen] = useState(false); @@ -150,7 +148,7 @@ export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) { iconStart={} onClick={() => { close(); - openSignIn({ continueTo: buildRequestAllContinueUrl() }); + redirectToInitiate(buildRequestAllContinueUrl()); }} > {t("topBar.getAccess")} diff --git a/apps/compliance-portal/src/components/auth/OIDCButton.tsx b/apps/compliance-portal/src/components/auth/OIDCButton.tsx deleted file mode 100644 index ccd6e5814..000000000 --- a/apps/compliance-portal/src/components/auth/OIDCButton.tsx +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import { Google } from "@probo/ui/src/Atoms/ThirdParties/Google"; -import { Microsoft } from "@probo/ui/src/Atoms/ThirdParties/Microsoft"; -import { Button } from "@probo/ui/src/v2/Button/Button"; -import type { ComponentProps } from "react"; -import { useTranslation } from "react-i18next"; -import { graphql, useFragment } from "react-relay"; - -import type { OIDCButton_provider$key } from "./__generated__/OIDCButton_provider.graphql"; - -const providerFragment = graphql` - fragment OIDCButton_provider on OIDCProviderInfo { - name - loginURL - } -`; - -const providerIcons: Record) => React.ReactNode> = { - google: Google, - microsoft: Microsoft, -}; - -interface OIDCButtonProps { - providerKey: OIDCButton_provider$key; - // Absolute URL to return to after the provider completes authentication. - continueTo: string; -} - -// Redirects the whole window to the provider's hosted login, carrying the -// `continue` target so the portal resumes the pending flow on return. -export function OIDCButton({ providerKey, continueTo }: OIDCButtonProps) { - const { t } = useTranslation(); - const provider = useFragment(providerFragment, providerKey); - const Icon = providerIcons[provider.name]; - const label = t("auth.signIn.withProvider", { - provider: provider.name.charAt(0).toUpperCase() + provider.name.slice(1), - }); - - return ( - - ); -} diff --git a/apps/compliance-portal/src/components/auth/OIDCProviders.tsx b/apps/compliance-portal/src/components/auth/OIDCProviders.tsx deleted file mode 100644 index f916d4c14..000000000 --- a/apps/compliance-portal/src/components/auth/OIDCProviders.tsx +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import { ButtonSkeleton } from "@probo/ui/src/v2/Button/ButtonSkeleton"; -import { ErrorBoundary } from "@probo/ui/src/v2/ErrorBoundary/ErrorBoundary"; -import { Text } from "@probo/ui/src/v2/typography/Text"; -import { Suspense } from "react"; -import { useTranslation } from "react-i18next"; -import { graphql, useLazyLoadQuery } from "react-relay"; - -import type { OIDCProvidersQuery } from "./__generated__/OIDCProvidersQuery.graphql"; -import { OIDCButton } from "./OIDCButton"; - -const oidcProvidersQuery = graphql` - query OIDCProvidersQuery { - oidcProviders { - ...OIDCButton_provider - } - } -`; - -interface OIDCProvidersProps { - continueTo: string; -} - -function Divider() { - const { t } = useTranslation(); - return ( -
- - {t("auth.signIn.or")} - -
- ); -} - -function OIDCProvidersContent({ continueTo }: OIDCProvidersProps) { - const data = useLazyLoadQuery(oidcProvidersQuery, {}); - - if (data.oidcProviders.length === 0) { - return null; - } - - return ( - <> -
- {data.oidcProviders.map((provider, index) => ( - - ))} -
- - - ); -} - -// SSO buttons for the sign-in dialog. Providers load lazily on open; if the -// query fails or the trust center has none, the section renders nothing so the -// email flow stays usable. -export function OIDCProviders({ continueTo }: OIDCProvidersProps) { - return ( - console.error("Failed to load SSO providers", error)} - > - - - - - )} - > - - - - ); -} diff --git a/apps/compliance-portal/src/components/auth/SignInDialog.tsx b/apps/compliance-portal/src/components/auth/SignInDialog.tsx deleted file mode 100644 index 66c481fa5..000000000 --- a/apps/compliance-portal/src/components/auth/SignInDialog.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import { Dialog } from "@probo/ui/src/v2/Dialog/Dialog"; -import { DialogDescription } from "@probo/ui/src/v2/Dialog/DialogDescription"; -import { DialogHeader } from "@probo/ui/src/v2/Dialog/DialogHeader"; -import { DialogPopup } from "@probo/ui/src/v2/Dialog/DialogPopup"; -import { DialogTitle } from "@probo/ui/src/v2/Dialog/DialogTitle"; -import { useTranslation } from "react-i18next"; - -import { SignInForm } from "./SignInForm"; - -interface SignInDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - // Absolute URL to return to after authentication. - continueTo: string; -} - -// The reusable "Login Dialog" from the design: a modal sign-in gate composed of -// the kit Dialog plus the magic-link / SSO sign-in form. -export function SignInDialog({ open, onOpenChange, continueTo }: SignInDialogProps) { - const { t } = useTranslation(); - - return ( - - - - {t("auth.signIn.title")} - {t("auth.signIn.description")} - - onOpenChange(false)} /> - - - ); -} diff --git a/apps/compliance-portal/src/components/auth/SignInForm.tsx b/apps/compliance-portal/src/components/auth/SignInForm.tsx deleted file mode 100644 index e9347cf07..000000000 --- a/apps/compliance-portal/src/components/auth/SignInForm.tsx +++ /dev/null @@ -1,168 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import { Field } from "@base-ui/react/field"; -import { Form } from "@base-ui/react/form"; -import { Toast } from "@base-ui/react/toast"; -import type { GraphQLError } from "@probo/helpers"; -import { Button } from "@probo/ui/src/v2/Button/Button"; -import { DialogBody } from "@probo/ui/src/v2/Dialog/DialogBody"; -import { DialogFooter } from "@probo/ui/src/v2/Dialog/DialogFooter"; -import { TextField } from "@probo/ui/src/v2/form/TextField"; -import { Text } from "@probo/ui/src/v2/typography/Text"; -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { graphql } from "relay-runtime"; - -import { getSafeContinueUrl } from "#/lib/auth/continueUrl"; -import { useMutation } from "#/lib/relay/useMutation"; - -import type { SignInFormMutation } from "./__generated__/SignInFormMutation.graphql"; -import { OIDCProviders } from "./OIDCProviders"; - -const RESEND_COOLDOWN_SECONDS = 60; - -const sendMagicLinkMutation = graphql` - mutation SignInFormMutation($input: SendMagicLinkInput!) { - sendMagicLink(input: $input) { - success - } - } -`; - -interface SignInFormProps { - // Absolute URL to return to after authentication (carries the request-all - // marker so an access request resumes once signed in). - continueTo: string; - onCancel: () => void; -} - -// Sign-in form used inside the dialog: SSO providers, then a magic-link email -// flow. On success it flips to a "check your email" state with a resend timer. -export function SignInForm({ continueTo, onCancel }: SignInFormProps) { - const { t } = useTranslation(); - const toast = Toast.useToastManager(); - const [magicLinkSent, setMagicLinkSent] = useState(false); - const [secondsLeft, setSecondsLeft] = useState(RESEND_COOLDOWN_SECONDS); - const intervalRef = useRef>(undefined); - - const [sendMagicLink, isSending] = useMutation( - sendMagicLinkMutation, - { errorToast: false }, - ); - - useEffect(() => { - if (!magicLinkSent) { - return; - } - intervalRef.current = setInterval(() => { - setSecondsLeft(seconds => Math.max(seconds - 1, 0)); - }, 1000); - return () => clearInterval(intervalRef.current); - }, [magicLinkSent]); - - const handleSend = (email: string) => { - void sendMagicLink({ - variables: { input: { email, continue: continueTo } }, - onCompleted: (_response, errors) => { - const code = (errors?.[0] as GraphQLError | undefined)?.extensions?.code; - - // Already signed in elsewhere: jump straight to the return URL so any - // pending access request resumes. - if (code === "ALREADY_AUTHENTICATED") { - window.location.href = getSafeContinueUrl(continueTo); - return; - } - - if (errors && errors.length > 0) { - toast.add({ title: t("auth.errors.magicLinkFailed"), type: "error" }); - return; - } - - setSecondsLeft(RESEND_COOLDOWN_SECONDS); - setMagicLinkSent(true); - toast.add({ title: t("auth.signIn.magicLinkSent"), type: "success" }); - }, - onError: () => { - toast.add({ title: t("auth.errors.magicLinkFailed"), type: "error" }); - }, - }).catch(() => {}); - }; - - const resendDisabled = magicLinkSent && secondsLeft > 0; - const submitLabel = magicLinkSent - ? secondsLeft > 0 - ? t("auth.signIn.resendIn", { seconds: secondsLeft }) - : t("auth.signIn.resend") - : t("auth.signIn.sendMagicLink"); - - return ( -
{ - handleSend(String(values.email ?? "")); - }} - > - - - - - - {t("auth.signIn.emailLabel")} - - - - {t("auth.signIn.emailRequired")} - - - {t("auth.signIn.emailInvalid")} - - - - {magicLinkSent && ( - - {t("auth.signIn.magicLinkSentNote")} - - )} - - - - - - -
- ); -} diff --git a/apps/compliance-portal/src/lib/auth/SignInDialogProvider.tsx b/apps/compliance-portal/src/lib/auth/SignInDialogProvider.tsx deleted file mode 100644 index 622473fc1..000000000 --- a/apps/compliance-portal/src/lib/auth/SignInDialogProvider.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import { type ReactNode, useCallback, useMemo, useState } from "react"; - -import { SignInDialog } from "#/components/auth/SignInDialog"; -import { - type OpenSignInOptions, - SignInDialogContextProvider, -} from "#/lib/auth/signInDialogContext"; - -interface SignInDialogProviderProps { - children: ReactNode; -} - -// Owns the single sign-in dialog instance and exposes `openSignIn` so any -// descendant (top bar, resource rows, …) can prompt authentication and pass the -// URL to return to afterwards. -export function SignInDialogProvider({ children }: SignInDialogProviderProps) { - const [open, setOpen] = useState(false); - const [continueTo, setContinueTo] = useState(() => window.location.href); - - const openSignIn = useCallback((options?: OpenSignInOptions) => { - setContinueTo(options?.continueTo ?? window.location.href); - setOpen(true); - }, []); - - const value = useMemo(() => ({ openSignIn }), [openSignIn]); - - return ( - - {children} - - - ); -} diff --git a/apps/compliance-portal/src/lib/auth/continueUrl.ts b/apps/compliance-portal/src/lib/auth/continueUrl.ts index c6aed80ae..1932b15db 100644 --- a/apps/compliance-portal/src/lib/auth/continueUrl.ts +++ b/apps/compliance-portal/src/lib/auth/continueUrl.ts @@ -108,3 +108,12 @@ export function gateRedirectPath(error: unknown, continueUrl: string): string | } return null; } + +// Sends the browser to the OAuth entry point, carrying a validated continue URL +// so the user returns to the portal (and any deferred access request resumes) +// after sign-in. +export function redirectToInitiate(continueTo: string): void { + const initiateURL = new URL("/initiate", window.location.origin); + initiateURL.searchParams.set("continue", getSafeContinueUrl(continueTo)); + window.location.href = initiateURL.toString(); +} diff --git a/apps/compliance-portal/src/lib/auth/signInDialogContext.ts b/apps/compliance-portal/src/lib/auth/signInDialogContext.ts deleted file mode 100644 index bb9860aaf..000000000 --- a/apps/compliance-portal/src/lib/auth/signInDialogContext.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import { createContext, useContext } from "react"; - -export type OpenSignInOptions = { - // Absolute URL the user should return to once authenticated. Defaults to the - // current page. Include the request-all marker to resume an access request. - continueTo?: string; -}; - -export type SignInDialogContextValue = { - openSignIn: (options?: OpenSignInOptions) => void; -}; - -const SignInDialogContext = createContext(null); - -export const SignInDialogContextProvider = SignInDialogContext.Provider; - -export function useSignInDialog(): SignInDialogContextValue { - const context = useContext(SignInDialogContext); - if (context === null) { - throw new Error("useSignInDialog must be used within a SignInDialogProvider"); - } - return context; -} diff --git a/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts b/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts index d0176729d..2a1105bdb 100644 --- a/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts +++ b/apps/compliance-portal/src/lib/auth/useResumeAccessRequest.ts @@ -96,7 +96,7 @@ const requestFileMutation = graphql` } `; -// After a user signs in through the dialog, they land back on the page that +// After a user signs in through OAuth /initiate, they land back on the page that // carried a deferred access marker. This hook fires the matching mutation once // (when authenticated) — request-all from the top bar, or a single // document / report / file requested from a locked row — routes to the diff --git a/apps/compliance-portal/src/lib/mailingList/SubscribeDialogProvider.tsx b/apps/compliance-portal/src/lib/mailingList/SubscribeDialogProvider.tsx index d397c283d..19e0b4754 100644 --- a/apps/compliance-portal/src/lib/mailingList/SubscribeDialogProvider.tsx +++ b/apps/compliance-portal/src/lib/mailingList/SubscribeDialogProvider.tsx @@ -30,8 +30,7 @@ import { graphql, useFragment } from "react-relay"; import { useSearchParams } from "react-router"; import { SubscribeDialog } from "#/components/SubscribeDialog/SubscribeDialog"; -import { buildSubscribeContinueUrl, SUBSCRIBE_PARAM } from "#/lib/auth/continueUrl"; -import { useSignInDialog } from "#/lib/auth/signInDialogContext"; +import { buildSubscribeContinueUrl, redirectToInitiate, SUBSCRIBE_PARAM } from "#/lib/auth/continueUrl"; import { SubscribeDialogContextProvider, } from "#/lib/mailingList/subscribeDialogContext"; @@ -69,7 +68,6 @@ export function SubscribeDialogProvider({ children, }: SubscribeDialogProviderProps) { const data = useFragment(subscribeDialogProviderFragment, queryKey); - const { openSignIn } = useSignInDialog(); const [searchParams, setSearchParams] = useSearchParams(); const [dialogOpen, setDialogOpen] = useState(false); const [unsubscribeFromMailingList, isUnsubscribing] = useUnsubscribeFromMailingList(); @@ -80,11 +78,11 @@ export function SubscribeDialogProvider({ const openSubscribe = useCallback(() => { if (viewer == null) { - openSignIn({ continueTo: buildSubscribeContinueUrl() }); + redirectToInitiate(buildSubscribeContinueUrl()); return; } setDialogOpen(true); - }, [openSignIn, viewer]); + }, [viewer]); const unsubscribe = useCallback(async () => { try { diff --git a/apps/compliance-portal/src/pages/MainLayout.tsx b/apps/compliance-portal/src/pages/MainLayout.tsx index a2cc1aa16..e4fb531ed 100644 --- a/apps/compliance-portal/src/pages/MainLayout.tsx +++ b/apps/compliance-portal/src/pages/MainLayout.tsx @@ -25,7 +25,6 @@ import { Outlet, useMatch } from "react-router"; import { PoweredBy } from "#/components/PoweredBy/PoweredBy"; import { TopBar } from "#/components/TopBar/TopBar"; -import { SignInDialogProvider } from "#/lib/auth/SignInDialogProvider"; import { useResumeAccessRequest } from "#/lib/auth/useResumeAccessRequest"; import { SubscribeDialogProvider } from "#/lib/mailingList/SubscribeDialogProvider"; @@ -57,28 +56,26 @@ export function MainLayout({ queryRef }: MainLayoutProps) { // stage; every other page uses normal document flow so the footer sits after // content (and at the bottom of short pages via flex-1 main). return ( - - + +
+
- -
- -
- {isDocumentViewer ? null : } +
- - + {isDocumentViewer ? null : } +
+
); } diff --git a/apps/compliance-portal/src/pages/auth/AuthLayout.tsx b/apps/compliance-portal/src/pages/auth/AuthLayout.tsx index 7d55fedac..6171c7a1e 100644 --- a/apps/compliance-portal/src/pages/auth/AuthLayout.tsx +++ b/apps/compliance-portal/src/pages/auth/AuthLayout.tsx @@ -21,8 +21,7 @@ import { Card } from "@probo/ui/src/v2/Card/Card"; import { Outlet } from "react-router"; -// Minimal centered chrome for the standalone auth steps that a magic-link email -// or a full-name gate lands on (these cannot live inside the portal shell). +// Minimal centered chrome for standalone auth steps (e.g. the full-name gate). export default function AuthLayout() { return (
diff --git a/apps/compliance-portal/src/pages/auth/MagicLinkAlreadyUsedPage.tsx b/apps/compliance-portal/src/pages/auth/MagicLinkAlreadyUsedPage.tsx deleted file mode 100644 index 1bb46be38..000000000 --- a/apps/compliance-portal/src/pages/auth/MagicLinkAlreadyUsedPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import { Link } from "@probo/ui/src/v2/Button/Link"; -import { Heading } from "@probo/ui/src/v2/typography/Heading"; -import { Text } from "@probo/ui/src/v2/typography/Text"; -import { useTranslation } from "react-i18next"; - -// Shown when a magic link was already consumed; the user restarts sign-in. -export default function MagicLinkAlreadyUsedPage() { - const { t } = useTranslation(); - - return ( -
- {t("auth.magicLinkAlreadyUsed.title")} - {t("auth.magicLinkAlreadyUsed.description")} - - {t("auth.backToPortal")} - -
- ); -} diff --git a/apps/compliance-portal/src/pages/auth/MagicLinkExpiredPage.tsx b/apps/compliance-portal/src/pages/auth/MagicLinkExpiredPage.tsx deleted file mode 100644 index 34c8d7b24..000000000 --- a/apps/compliance-portal/src/pages/auth/MagicLinkExpiredPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import { Link } from "@probo/ui/src/v2/Button/Link"; -import { Heading } from "@probo/ui/src/v2/typography/Heading"; -import { Text } from "@probo/ui/src/v2/typography/Text"; -import { useTranslation } from "react-i18next"; - -// Shown when a magic link has expired; the user restarts sign-in from home. -export default function MagicLinkExpiredPage() { - const { t } = useTranslation(); - - return ( -
- {t("auth.magicLinkExpired.title")} - {t("auth.magicLinkExpired.description")} - - {t("auth.backToPortal")} - -
- ); -} diff --git a/apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx b/apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx deleted file mode 100644 index 803d6d94a..000000000 --- a/apps/compliance-portal/src/pages/auth/VerifyMagicLinkPage.tsx +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -import { Toast } from "@base-ui/react/toast"; -import type { GraphQLError } from "@probo/helpers"; -import { Heading } from "@probo/ui/src/v2/typography/Heading"; -import { Text } from "@probo/ui/src/v2/typography/Text"; -import { useEffect, useRef } from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate, useSearchParams } from "react-router"; -import { graphql } from "relay-runtime"; - -import { getSafeContinueUrl } from "#/lib/auth/continueUrl"; -import { useMutation } from "#/lib/relay/useMutation"; - -import type { VerifyMagicLinkPageMutation } from "./__generated__/VerifyMagicLinkPageMutation.graphql"; - -const verifyMagicLinkMutation = graphql` - mutation VerifyMagicLinkPageMutation($input: VerifyMagicLinkInput!) { - verifyMagicLink(input: $input) { - continue - } - } -`; - -// Landing page for the magic-link email. It verifies the token on mount and -// forwards to the (validated) continue URL, where any pending access request -// resumes. -export default function VerifyMagicLinkPage() { - const { t } = useTranslation(); - const toast = Toast.useToastManager(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const submittedRef = useRef(false); - - const [verifyMagicLink] = useMutation( - verifyMagicLinkMutation, - { errorToast: false }, - ); - - useEffect(() => { - const token = searchParams.get("token"); - if (!token || submittedRef.current) { - return; - } - submittedRef.current = true; - - void verifyMagicLink({ - variables: { input: { token: token.trim() } }, - onCompleted: (response, errors) => { - const code = (errors?.[0] as GraphQLError | undefined)?.extensions?.code; - - if (code === "ALREADY_AUTHENTICATED") { - // Already signed in: honor a `continue` on the URL if present so a - // deferred access request still resumes, instead of always going home. - window.location.href = getSafeContinueUrl(searchParams.get("continue")); - return; - } - if (code === "TOKEN_EXPIRED") { - void navigate("/magic-link-expired"); - return; - } - if (code === "TOKEN_ALREADY_USED") { - void navigate("/magic-link-already-used"); - return; - } - if (errors && errors.length > 0) { - toast.add({ title: t("auth.errors.verifyFailed"), type: "error" }); - return; - } - - window.location.href = getSafeContinueUrl(response.verifyMagicLink?.continue); - }, - onError: () => { - toast.add({ title: t("auth.errors.verifyFailed"), type: "error" }); - }, - }).catch(() => {}); - }, [navigate, searchParams, t, toast, verifyMagicLink]); - - return ( -
- {t("auth.verify.title")} - {t("auth.verify.description")} -
- ); -} diff --git a/apps/compliance-portal/src/pages/auth/routes.ts b/apps/compliance-portal/src/pages/auth/routes.ts index 79dc993ec..b69490fa1 100644 --- a/apps/compliance-portal/src/pages/auth/routes.ts +++ b/apps/compliance-portal/src/pages/auth/routes.ts @@ -23,29 +23,16 @@ import type { AppRoute } from "@probo/routes"; import { AuthLayoutSkeleton } from "./AuthLayoutSkeleton"; -// Standalone auth steps that a magic-link email or a full-name gate lands on. -// These sit outside the portal shell, under a minimal centered layout. +// Standalone auth steps (e.g. the full-name gate) outside the portal shell. export const authRoutes = [ { Fallback: AuthLayoutSkeleton, Component: lazy(() => import("#/pages/auth/AuthLayout")), children: [ - { - path: "verify-magic-link", - Component: lazy(() => import("#/pages/auth/VerifyMagicLinkPage")), - }, { path: "full-name", Component: lazy(() => import("#/pages/auth/FullNamePage")), }, - { - path: "magic-link-expired", - Component: lazy(() => import("#/pages/auth/MagicLinkExpiredPage")), - }, - { - path: "magic-link-already-used", - Component: lazy(() => import("#/pages/auth/MagicLinkAlreadyUsedPage")), - }, ], }, ] satisfies AppRoute[]; diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx index f936d2c6e..cca364c5c 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx @@ -62,7 +62,7 @@ function StatusIcon({ // Trailing access control for a document entry: a "View" link to the viewer when // authorized, a pending label when access was requested, otherwise a "Get -// Access" action that requests access (prompting sign-in first when needed). +// Access" action that requests access (redirecting to OAuth /initiate when needed). export function DocumentAccessAction({ isAuthorized, requested, diff --git a/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts b/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts index e2b6d420a..0cdc239de 100644 --- a/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts +++ b/apps/compliance-portal/src/pages/documents/_lib/useAccessRequest.ts @@ -29,11 +29,11 @@ import { graphql } from "relay-runtime"; import { buildRequestAccessContinueUrl, gateRedirectPath, + redirectToInitiate, REQUEST_DOCUMENT_PARAM, REQUEST_FILE_PARAM, REQUEST_REPORT_PARAM, } from "#/lib/auth/continueUrl"; -import { useSignInDialog } from "#/lib/auth/signInDialogContext"; import { useMutation } from "#/lib/relay/useMutation"; import type { useAccessRequestDocumentMutation } from "./__generated__/useAccessRequestDocumentMutation.graphql"; @@ -95,11 +95,10 @@ const fileMutation = graphql` // Shared success / error handling for a single access request. The auth, // full-name, and NDA gates are thrown by the fetch layer, so they surface in -// `onError`: unauthenticated opens the sign-in dialog, while full-name and NDA -// deep-link to their gate page — all deferring the request via the continue URL -// so it resumes once the gate is cleared. Everything else is a generic toast. +// `onError`: unauthenticated redirects to OAuth /initiate, while full-name and +// NDA deep-link to their gate page — all deferring the request via the continue +// URL so it resumes once the gate is cleared. Everything else is a generic toast. function useAccessRequestHandlers(param: string, id: string) { - const { openSignIn } = useSignInDialog(); const navigate = useNavigate(); const toast = Toast.useToastManager(); const { t } = useTranslation(); @@ -116,10 +115,10 @@ function useAccessRequestHandlers(param: string, id: string) { onError: (error: Error) => { const continueUrl = buildRequestAccessContinueUrl(param, id); - // Not signed in: open the dialog, deferring this request until the user - // lands back authenticated (see useResumeAccessRequest). + // Not signed in: start OAuth, deferring this request until the user lands + // back authenticated (see useResumeAccessRequest). if (error instanceof UnAuthenticatedError) { - openSignIn({ continueTo: continueUrl }); + redirectToInitiate(continueUrl); return; } // Full-name / NDA gate: deep-link to the gate page, preserving the @@ -132,7 +131,7 @@ function useAccessRequestHandlers(param: string, id: string) { toast.add({ title: t("auth.errors.requestFailed"), type: "error" }); }, }), - [openSignIn, navigate, toast, t, param, id], + [navigate, toast, t, param, id], ); } diff --git a/apps/compliance-portal/src/pages/nda/NDAPage.tsx b/apps/compliance-portal/src/pages/nda/NDAPage.tsx index bb00bf051..a24513797 100644 --- a/apps/compliance-portal/src/pages/nda/NDAPage.tsx +++ b/apps/compliance-portal/src/pages/nda/NDAPage.tsx @@ -62,9 +62,7 @@ export const ndaPageQuery = graphql` id } currentTrustCenter @required(action: THROW) { - organization { - name - } + title nonDisclosureAgreement { fileUrl } @@ -238,7 +236,7 @@ export function NDAPage({ queryRef }: NDAPageProps) { {t("title")} - {t("subtitle", { name: trustCenter.organization.name })} + {t("subtitle", { name: trustCenter.title })} {signature.consentText != null && ( diff --git a/pkg/complianceportal/management/actions.go b/pkg/complianceportal/management/actions.go index 7ab4394d8..ff3342dc5 100644 --- a/pkg/complianceportal/management/actions.go +++ b/pkg/complianceportal/management/actions.go @@ -41,6 +41,20 @@ const ( ActionCompliancePortalReferenceUpdate = "compliance-portal:portal-reference:update" ActionCompliancePortalReferenceDelete = "compliance-portal:portal-reference:delete" + // Compliance portal commitment group actions. + ActionCompliancePortalCommitmentGroupList = "compliance-portal:commitment-group:list" + ActionCompliancePortalCommitmentGroupCreate = "compliance-portal:commitment-group:create" + ActionCompliancePortalCommitmentGroupUpdate = "compliance-portal:commitment-group:update" + ActionCompliancePortalCommitmentGroupUpdateRank = "compliance-portal:commitment-group:update-rank" + ActionCompliancePortalCommitmentGroupDelete = "compliance-portal:commitment-group:delete" + + // Compliance portal commitment actions. + ActionCompliancePortalCommitmentList = "compliance-portal:commitment:list" + ActionCompliancePortalCommitmentCreate = "compliance-portal:commitment:create" + ActionCompliancePortalCommitmentUpdate = "compliance-portal:commitment:update" + ActionCompliancePortalCommitmentUpdateRank = "compliance-portal:commitment:update-rank" + ActionCompliancePortalCommitmentDelete = "compliance-portal:commitment:delete" + // Compliance portal file actions. ActionCompliancePortalFileGet = "compliance-portal:portal-file:get" ActionCompliancePortalFileList = "compliance-portal:portal-file:list" diff --git a/pkg/probo/compliance_portal_commitment_group_service.go b/pkg/complianceportal/management/commitment_group_service.go similarity index 56% rename from pkg/probo/compliance_portal_commitment_group_service.go rename to pkg/complianceportal/management/commitment_group_service.go index 0049e2b82..febc99f5b 100644 --- a/pkg/probo/compliance_portal_commitment_group_service.go +++ b/pkg/complianceportal/management/commitment_group_service.go @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package probo +package management import ( "context" @@ -33,10 +33,6 @@ import ( ) type ( - CompliancePortalCommitmentGroupService struct { - svc *Service - } - CreateCompliancePortalCommitmentGroupRequest struct { TrustCenterID gid.GID Title string @@ -71,7 +67,7 @@ func (r *UpdateCompliancePortalCommitmentGroupRequest) Validate() error { return v.Error() } -func (s CompliancePortalCommitmentGroupService) ListForTrustCenterID( +func (s *Service) ListCommitmentGroups( ctx context.Context, scope coredata.Scoper, trustCenterID gid.GID, @@ -79,14 +75,17 @@ func (s CompliancePortalCommitmentGroupService) ListForTrustCenterID( ) (*page.Page[*coredata.CompliancePortalCommitmentGroup, coredata.CompliancePortalCommitmentGroupOrderField], error) { var groups coredata.CompliancePortalCommitmentGroups - err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { - err := groups.LoadByTrustCenterID(ctx, conn, scope, trustCenterID, cursor) - if err != nil { - return fmt.Errorf("cannot load compliance portal commitment groups: %w", err) - } + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + err := groups.LoadByTrustCenterID(ctx, conn, scope, trustCenterID, cursor) + if err != nil { + return fmt.Errorf("cannot load compliance portal commitment groups: %w", err) + } - return nil - }) + return nil + }, + ) if err != nil { return nil, err } @@ -94,23 +93,26 @@ func (s CompliancePortalCommitmentGroupService) ListForTrustCenterID( return page.NewPage(groups, cursor), nil } -func (s CompliancePortalCommitmentGroupService) CountForTrustCenterID( +func (s *Service) CountCommitmentGroups( ctx context.Context, scope coredata.Scoper, trustCenterID gid.GID, ) (int, error) { var count int - err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) (err error) { - groups := coredata.CompliancePortalCommitmentGroups{} + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + groups := coredata.CompliancePortalCommitmentGroups{} - count, err = groups.CountByTrustCenterID(ctx, conn, scope, trustCenterID) - if err != nil { - return fmt.Errorf("cannot count compliance portal commitment groups: %w", err) - } + count, err = groups.CountByTrustCenterID(ctx, conn, scope, trustCenterID) + if err != nil { + return fmt.Errorf("cannot count compliance portal commitment groups: %w", err) + } - return nil - }) + return nil + }, + ) if err != nil { return 0, err } @@ -118,21 +120,24 @@ func (s CompliancePortalCommitmentGroupService) CountForTrustCenterID( return count, nil } -func (s CompliancePortalCommitmentGroupService) Get( +func (s *Service) GetCommitmentGroup( ctx context.Context, scope coredata.Scoper, groupID gid.GID, ) (*coredata.CompliancePortalCommitmentGroup, error) { var group coredata.CompliancePortalCommitmentGroup - err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { - err := group.LoadByID(ctx, conn, scope, groupID) - if err != nil { - return fmt.Errorf("cannot load compliance portal commitment group: %w", err) - } + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + err := group.LoadByID(ctx, conn, scope, groupID) + if err != nil { + return fmt.Errorf("cannot load compliance portal commitment group: %w", err) + } - return nil - }) + return nil + }, + ) if err != nil { return nil, err } @@ -140,7 +145,7 @@ func (s CompliancePortalCommitmentGroupService) Get( return &group, nil } -func (s CompliancePortalCommitmentGroupService) Create( +func (s *Service) CreateCommitmentGroup( ctx context.Context, scope coredata.Scoper, req *CreateCompliancePortalCommitmentGroupRequest, @@ -155,28 +160,31 @@ func (s CompliancePortalCommitmentGroupService) Create( var group *coredata.CompliancePortalCommitmentGroup - err := s.svc.pg.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - trustCenter := &coredata.TrustCenter{} - if err := trustCenter.LoadByID(ctx, tx, scope, req.TrustCenterID); err != nil { - return fmt.Errorf("cannot load trust center: %w", err) - } + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + trustCenter := &coredata.TrustCenter{} + if err := trustCenter.LoadByID(ctx, tx, scope, req.TrustCenterID); err != nil { + return fmt.Errorf("cannot load trust center: %w", err) + } - group = &coredata.CompliancePortalCommitmentGroup{ - ID: groupID, - OrganizationID: trustCenter.OrganizationID, - TrustCenterID: req.TrustCenterID, - Title: req.Title, - Description: req.Description, - CreatedAt: now, - UpdatedAt: now, - } + group = &coredata.CompliancePortalCommitmentGroup{ + ID: groupID, + OrganizationID: trustCenter.OrganizationID, + TrustCenterID: req.TrustCenterID, + Title: req.Title, + Description: req.Description, + CreatedAt: now, + UpdatedAt: now, + } - if err := group.Insert(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot insert compliance portal commitment group: %w", err) - } + if err := group.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert compliance portal commitment group: %w", err) + } - return nil - }) + return nil + }, + ) if err != nil { return nil, err } @@ -184,7 +192,7 @@ func (s CompliancePortalCommitmentGroupService) Create( return group, nil } -func (s CompliancePortalCommitmentGroupService) Update( +func (s *Service) UpdateCommitmentGroup( ctx context.Context, scope coredata.Scoper, req *UpdateCompliancePortalCommitmentGroupRequest, @@ -197,36 +205,39 @@ func (s CompliancePortalCommitmentGroupService) Update( var group *coredata.CompliancePortalCommitmentGroup - err := s.svc.pg.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - group = &coredata.CompliancePortalCommitmentGroup{} + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + group = &coredata.CompliancePortalCommitmentGroup{} - if err := group.LoadByID(ctx, tx, scope, req.ID); err != nil { - return fmt.Errorf("cannot load compliance portal commitment group: %w", err) - } - - if req.Title != nil { - group.Title = *req.Title - } - - if req.Description != nil { - group.Description = *req.Description - } - - group.UpdatedAt = now - - if req.Rank != nil { - group.Rank = *req.Rank - if err := group.UpdateRank(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot update rank: %w", err) + if err := group.LoadByID(ctx, tx, scope, req.ID); err != nil { + return fmt.Errorf("cannot load compliance portal commitment group: %w", err) } - } - if err := group.Update(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot update compliance portal commitment group: %w", err) - } + if req.Title != nil { + group.Title = *req.Title + } - return nil - }) + if req.Description != nil { + group.Description = *req.Description + } + + group.UpdatedAt = now + + if req.Rank != nil { + group.Rank = *req.Rank + if err := group.UpdateRank(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update rank: %w", err) + } + } + + if err := group.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update compliance portal commitment group: %w", err) + } + + return nil + }, + ) if err != nil { return nil, err } @@ -234,24 +245,27 @@ func (s CompliancePortalCommitmentGroupService) Update( return group, nil } -func (s CompliancePortalCommitmentGroupService) Delete( +func (s *Service) DeleteCommitmentGroup( ctx context.Context, scope coredata.Scoper, groupID gid.GID, ) error { - err := s.svc.pg.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - group := &coredata.CompliancePortalCommitmentGroup{} + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + group := &coredata.CompliancePortalCommitmentGroup{} - if err := group.LoadByID(ctx, tx, scope, groupID); err != nil { - return fmt.Errorf("cannot load compliance portal commitment group: %w", err) - } + if err := group.LoadByID(ctx, tx, scope, groupID); err != nil { + return fmt.Errorf("cannot load compliance portal commitment group: %w", err) + } - if err := group.Delete(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot delete compliance portal commitment group: %w", err) - } + if err := group.Delete(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot delete compliance portal commitment group: %w", err) + } - return nil - }) + return nil + }, + ) return err } diff --git a/pkg/probo/compliance_portal_commitment_service.go b/pkg/complianceportal/management/commitment_service.go similarity index 57% rename from pkg/probo/compliance_portal_commitment_service.go rename to pkg/complianceportal/management/commitment_service.go index 06fcccd9e..4c3e1f5b7 100644 --- a/pkg/probo/compliance_portal_commitment_service.go +++ b/pkg/complianceportal/management/commitment_service.go @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package probo +package management import ( "context" @@ -33,10 +33,6 @@ import ( ) type ( - CompliancePortalCommitmentService struct { - svc *Service - } - CreateCompliancePortalCommitmentRequest struct { GroupID gid.GID Icon coredata.CompliancePortalCommitmentIcon @@ -83,7 +79,7 @@ func (r *UpdateCompliancePortalCommitmentRequest) Validate() error { return v.Error() } -func (s CompliancePortalCommitmentService) ListForGroupID( +func (s *Service) ListCommitments( ctx context.Context, scope coredata.Scoper, groupID gid.GID, @@ -91,14 +87,17 @@ func (s CompliancePortalCommitmentService) ListForGroupID( ) (*page.Page[*coredata.CompliancePortalCommitment, coredata.CompliancePortalCommitmentOrderField], error) { var commitments coredata.CompliancePortalCommitments - err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { - err := commitments.LoadByGroupID(ctx, conn, scope, groupID, cursor) - if err != nil { - return fmt.Errorf("cannot load compliance portal commitments: %w", err) - } + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + err := commitments.LoadByGroupID(ctx, conn, scope, groupID, cursor) + if err != nil { + return fmt.Errorf("cannot load compliance portal commitments: %w", err) + } - return nil - }) + return nil + }, + ) if err != nil { return nil, err } @@ -106,23 +105,26 @@ func (s CompliancePortalCommitmentService) ListForGroupID( return page.NewPage(commitments, cursor), nil } -func (s CompliancePortalCommitmentService) CountForGroupID( +func (s *Service) CountCommitments( ctx context.Context, scope coredata.Scoper, groupID gid.GID, ) (int, error) { var count int - err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) (err error) { - commitments := coredata.CompliancePortalCommitments{} + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) (err error) { + commitments := coredata.CompliancePortalCommitments{} - count, err = commitments.CountByGroupID(ctx, conn, scope, groupID) - if err != nil { - return fmt.Errorf("cannot count compliance portal commitments: %w", err) - } + count, err = commitments.CountByGroupID(ctx, conn, scope, groupID) + if err != nil { + return fmt.Errorf("cannot count compliance portal commitments: %w", err) + } - return nil - }) + return nil + }, + ) if err != nil { return 0, err } @@ -130,21 +132,24 @@ func (s CompliancePortalCommitmentService) CountForGroupID( return count, nil } -func (s CompliancePortalCommitmentService) Get( +func (s *Service) GetCommitment( ctx context.Context, scope coredata.Scoper, commitmentID gid.GID, ) (*coredata.CompliancePortalCommitment, error) { var commitment coredata.CompliancePortalCommitment - err := s.svc.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error { - err := commitment.LoadByID(ctx, conn, scope, commitmentID) - if err != nil { - return fmt.Errorf("cannot load compliance portal commitment: %w", err) - } + err := s.pg.WithConn( + ctx, + func(ctx context.Context, conn pg.Querier) error { + err := commitment.LoadByID(ctx, conn, scope, commitmentID) + if err != nil { + return fmt.Errorf("cannot load compliance portal commitment: %w", err) + } - return nil - }) + return nil + }, + ) if err != nil { return nil, err } @@ -152,7 +157,7 @@ func (s CompliancePortalCommitmentService) Get( return &commitment, nil } -func (s CompliancePortalCommitmentService) Create( +func (s *Service) CreateCommitment( ctx context.Context, scope coredata.Scoper, req *CreateCompliancePortalCommitmentRequest, @@ -167,31 +172,34 @@ func (s CompliancePortalCommitmentService) Create( var commitment *coredata.CompliancePortalCommitment - err := s.svc.pg.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - group := &coredata.CompliancePortalCommitmentGroup{} - if err := group.LoadByID(ctx, tx, scope, req.GroupID); err != nil { - return fmt.Errorf("cannot load compliance portal commitment group: %w", err) - } + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + group := &coredata.CompliancePortalCommitmentGroup{} + if err := group.LoadByID(ctx, tx, scope, req.GroupID); err != nil { + return fmt.Errorf("cannot load compliance portal commitment group: %w", err) + } - commitment = &coredata.CompliancePortalCommitment{ - ID: commitmentID, - OrganizationID: group.OrganizationID, - TrustCenterID: group.TrustCenterID, - GroupID: req.GroupID, - Icon: req.Icon, - Eyebrow: req.Eyebrow, - Title: req.Title, - Description: req.Description, - CreatedAt: now, - UpdatedAt: now, - } + commitment = &coredata.CompliancePortalCommitment{ + ID: commitmentID, + OrganizationID: group.OrganizationID, + TrustCenterID: group.TrustCenterID, + GroupID: req.GroupID, + Icon: req.Icon, + Eyebrow: req.Eyebrow, + Title: req.Title, + Description: req.Description, + CreatedAt: now, + UpdatedAt: now, + } - if err := commitment.Insert(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot insert compliance portal commitment: %w", err) - } + if err := commitment.Insert(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot insert compliance portal commitment: %w", err) + } - return nil - }) + return nil + }, + ) if err != nil { return nil, err } @@ -199,7 +207,7 @@ func (s CompliancePortalCommitmentService) Create( return commitment, nil } -func (s CompliancePortalCommitmentService) Update( +func (s *Service) UpdateCommitment( ctx context.Context, scope coredata.Scoper, req *UpdateCompliancePortalCommitmentRequest, @@ -212,44 +220,47 @@ func (s CompliancePortalCommitmentService) Update( var commitment *coredata.CompliancePortalCommitment - err := s.svc.pg.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - commitment = &coredata.CompliancePortalCommitment{} + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + commitment = &coredata.CompliancePortalCommitment{} - if err := commitment.LoadByID(ctx, tx, scope, req.ID); err != nil { - return fmt.Errorf("cannot load compliance portal commitment: %w", err) - } - - if req.Icon != nil { - commitment.Icon = *req.Icon - } - - if req.Eyebrow != nil { - commitment.Eyebrow = *req.Eyebrow - } - - if req.Title != nil { - commitment.Title = *req.Title - } - - if req.Description != nil { - commitment.Description = *req.Description - } - - commitment.UpdatedAt = now - - if req.Rank != nil { - commitment.Rank = *req.Rank - if err := commitment.UpdateRank(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot update rank: %w", err) + if err := commitment.LoadByID(ctx, tx, scope, req.ID); err != nil { + return fmt.Errorf("cannot load compliance portal commitment: %w", err) } - } - if err := commitment.Update(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot update compliance portal commitment: %w", err) - } + if req.Icon != nil { + commitment.Icon = *req.Icon + } - return nil - }) + if req.Eyebrow != nil { + commitment.Eyebrow = *req.Eyebrow + } + + if req.Title != nil { + commitment.Title = *req.Title + } + + if req.Description != nil { + commitment.Description = *req.Description + } + + commitment.UpdatedAt = now + + if req.Rank != nil { + commitment.Rank = *req.Rank + if err := commitment.UpdateRank(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update rank: %w", err) + } + } + + if err := commitment.Update(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot update compliance portal commitment: %w", err) + } + + return nil + }, + ) if err != nil { return nil, err } @@ -257,24 +268,27 @@ func (s CompliancePortalCommitmentService) Update( return commitment, nil } -func (s CompliancePortalCommitmentService) Delete( +func (s *Service) DeleteCommitment( ctx context.Context, scope coredata.Scoper, commitmentID gid.GID, ) error { - err := s.svc.pg.WithTx(ctx, func(ctx context.Context, tx pg.Tx) error { - commitment := &coredata.CompliancePortalCommitment{} + err := s.pg.WithTx( + ctx, + func(ctx context.Context, tx pg.Tx) error { + commitment := &coredata.CompliancePortalCommitment{} - if err := commitment.LoadByID(ctx, tx, scope, commitmentID); err != nil { - return fmt.Errorf("cannot load compliance portal commitment: %w", err) - } + if err := commitment.LoadByID(ctx, tx, scope, commitmentID); err != nil { + return fmt.Errorf("cannot load compliance portal commitment: %w", err) + } - if err := commitment.Delete(ctx, tx, scope); err != nil { - return fmt.Errorf("cannot delete compliance portal commitment: %w", err) - } + if err := commitment.Delete(ctx, tx, scope); err != nil { + return fmt.Errorf("cannot delete compliance portal commitment: %w", err) + } - return nil - }) + return nil + }, + ) return err } diff --git a/pkg/complianceportal/management/policies.go b/pkg/complianceportal/management/policies.go index 2c3908ec6..5bb40bb65 100644 --- a/pkg/complianceportal/management/policies.go +++ b/pkg/complianceportal/management/policies.go @@ -21,14 +21,6 @@ import ( var organizationCondition = policy.Equals("principal.organization_id", "resource.organization_id") -// FullAccessPolicy grants organization owners and admins complete access to -// every compliance portal capability, including custom domains, portal -// configuration, access grants, files, references, frameworks, external URLs -// and mailing lists. -// -// The managed probopage subdomain is a system-owned resource and can never be -// deleted, so an explicit deny (which takes precedence over any allow) blocks -// deletion of managed domains for every role. var FullAccessPolicy = policy.NewPolicy( "compliance-portal:full-access", "Compliance Portal Full Access", @@ -40,8 +32,6 @@ var FullAccessPolicy = policy.NewPolicy( When(policy.Equals("resource.managed", "true")), ).WithDescription("Full compliance portal access for organization owners and admins") -// ViewerPolicy grants organization viewers read-only access to the compliance -// portal. var ViewerPolicy = policy.NewPolicy( "compliance-portal:viewer", "Compliance Portal Viewer", @@ -52,11 +42,11 @@ var ViewerPolicy = policy.NewPolicy( ActionCompliancePortalDocumentAccessList, ActionCompliancePortalFileGet, ActionCompliancePortalFileList, ActionCompliancePortalFileGetFileUrl, ActionCompliancePortalReferenceList, ActionCompliancePortalReferenceGetLogoUrl, + ActionCompliancePortalCommitmentGroupList, ActionCompliancePortalCommitmentList, ActionComplianceFrameworkList, ).WithSID("compliance-portal-read-access").When(organizationCondition), ).WithDescription("Read-only compliance portal access for organization viewers") -// PolicySet returns the PolicySet for the compliance portal service. func PolicySet() *iam.PolicySet { return iam.NewPolicySet(). AddRolePolicy("OWNER", FullAccessPolicy). diff --git a/pkg/server/api/complianceportal/v1/trust_center_resolvers.go b/pkg/server/api/complianceportal/v1/trust_center_resolvers.go index 0e54fabfa..e0880469b 100644 --- a/pkg/server/api/complianceportal/v1/trust_center_resolvers.go +++ b/pkg/server/api/complianceportal/v1/trust_center_resolvers.go @@ -180,6 +180,25 @@ func (r *complianceFrameworkResolver) Framework(ctx context.Context, obj *types. return types.NewFramework(framework), nil } +// Commitments is the resolver for the commitments field. +func (r *compliancePortalCommitmentGroupResolver) Commitments(ctx context.Context, obj *types.CompliancePortalCommitmentGroup, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.CompliancePortalCommitmentConnection, error) { + scope := coredata.NewScopeFromObjectID(obj.ID) + trustService := r.trust + pageOrderBy := page.OrderBy[coredata.CompliancePortalCommitmentOrderField]{ + Field: coredata.CompliancePortalCommitmentOrderFieldRank, + Direction: page.OrderDirectionAsc, + } + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + commitmentPage, err := trustService.ListCommitmentsForGroupID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list public compliance portal commitments", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewCompliancePortalCommitmentConnection(commitmentPage), nil +} + // Alias is the resolver for the alias field. func (r *documentResolver) Alias(ctx context.Context, obj *types.Document) (*string, error) { return r.ResourceAliasResolver(ctx, obj.ID) @@ -750,7 +769,7 @@ func (r *trustCenterResolver) ViewerSubscription(ctx context.Context, obj *types } // Documents is the resolver for the documents field. -func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.DocumentConnection, error) { +func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, filter *types.TrustCenterVisibilityFilter) (*types.DocumentConnection, error) { trustCenter := complianceportal.CompliancePageFromContext(ctx) scope := coredata.NewScopeFromObjectID(obj.ID) trustService := r.trust @@ -760,7 +779,12 @@ func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCen } cursor := types.NewCursor(first, after, last, before, pageOrderBy) - documentPage, err := trustService.ListDocumentsForOrganizationID(ctx, scope, trustCenter.OrganizationID, cursor) + documentFilter := coredata.NewDocumentTrustCenterFilter() + if filter != nil && filter.Visibility != nil { + documentFilter = documentFilter.WithTrustCenterVisibilities(*filter.Visibility) + } + + documentPage, err := trustService.ListDocumentsForOrganizationID(ctx, scope, trustCenter.OrganizationID, cursor, documentFilter) if err != nil { r.logger.ErrorCtx(ctx, "cannot list public documents", log.Error(err)) return nil, gqlutils.Internal(ctx) @@ -770,7 +794,7 @@ func (r *trustCenterResolver) Documents(ctx context.Context, obj *types.TrustCen } // Audits is the resolver for the audits field. -func (r *trustCenterResolver) Audits(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.AuditConnection, error) { +func (r *trustCenterResolver) Audits(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, filter *types.TrustCenterVisibilityFilter) (*types.AuditConnection, error) { trustCenter := complianceportal.CompliancePageFromContext(ctx) scope := coredata.NewScopeFromObjectID(obj.ID) trustService := r.trust @@ -780,7 +804,12 @@ func (r *trustCenterResolver) Audits(ctx context.Context, obj *types.TrustCenter } cursor := types.NewCursor(first, after, last, before, pageOrderBy) - auditPage, err := trustService.ListAuditsForOrganizationID(ctx, scope, trustCenter.OrganizationID, cursor) + auditFilter := coredata.NewAuditTrustCenterFilter() + if filter != nil && filter.Visibility != nil { + auditFilter = auditFilter.WithTrustCenterVisibilities(*filter.Visibility) + } + + auditPage, err := trustService.ListAuditsForOrganizationID(ctx, scope, trustCenter.OrganizationID, cursor, auditFilter) if err != nil { r.logger.ErrorCtx(ctx, "cannot list public audits", log.Error(err)) return nil, gqlutils.Internal(ctx) @@ -878,8 +907,27 @@ func (r *trustCenterResolver) References(ctx context.Context, obj *types.TrustCe return types.NewTrustCenterReferenceConnection(referencePage), nil } +// CommitmentGroups is the resolver for the commitmentGroups field. +func (r *trustCenterResolver) CommitmentGroups(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.CompliancePortalCommitmentGroupConnection, error) { + scope := coredata.NewScopeFromObjectID(obj.ID) + trustService := r.trust + pageOrderBy := page.OrderBy[coredata.CompliancePortalCommitmentGroupOrderField]{ + Field: coredata.CompliancePortalCommitmentGroupOrderFieldRank, + Direction: page.OrderDirectionAsc, + } + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + groupPage, err := trustService.ListCommitmentGroupsForPortalID(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list public compliance portal commitment groups", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewCompliancePortalCommitmentGroupConnection(groupPage), nil +} + // TrustCenterFiles is the resolver for the trustCenterFiles field. -func (r *trustCenterResolver) TrustCenterFiles(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.TrustCenterFileConnection, error) { +func (r *trustCenterResolver) TrustCenterFiles(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, filter *types.TrustCenterVisibilityFilter) (*types.TrustCenterFileConnection, error) { trustCenter := complianceportal.CompliancePageFromContext(ctx) scope := coredata.NewScopeFromObjectID(obj.ID) trustService := r.trust @@ -889,14 +937,19 @@ func (r *trustCenterResolver) TrustCenterFiles(ctx context.Context, obj *types.T } cursor := types.NewCursor(first, after, last, before, pageOrderBy) - filter := coredata.NewTrustCenterFileFilter( - coredata.WithTrustCenterFileVisibilities( - coredata.TrustCenterVisibilityPublic, - coredata.TrustCenterVisibilityPrivate, - ), + visibilities := []coredata.TrustCenterVisibility{ + coredata.TrustCenterVisibilityPublic, + coredata.TrustCenterVisibilityPrivate, + } + if filter != nil && filter.Visibility != nil { + visibilities = []coredata.TrustCenterVisibility{*filter.Visibility} + } + + fileFilter := coredata.NewTrustCenterFileFilter( + coredata.WithTrustCenterFileVisibilities(visibilities...), ) - trustCenterFilePage, err := trustService.ListPortalFilesForOrganizationID(ctx, scope, trustCenter.OrganizationID, cursor, filter) + trustCenterFilePage, err := trustService.ListPortalFilesForOrganizationID(ctx, scope, trustCenter.OrganizationID, cursor, fileFilter) if err != nil { r.logger.ErrorCtx(ctx, "cannot list public trust center files", log.Error(err)) return nil, gqlutils.Internal(ctx) @@ -1087,6 +1140,11 @@ func (r *Resolver) ComplianceFramework() schema.ComplianceFrameworkResolver { return &complianceFrameworkResolver{r} } +// CompliancePortalCommitmentGroup returns schema.CompliancePortalCommitmentGroupResolver implementation. +func (r *Resolver) CompliancePortalCommitmentGroup() schema.CompliancePortalCommitmentGroupResolver { + return &compliancePortalCommitmentGroupResolver{r} +} + // Document returns schema.DocumentResolver implementation. func (r *Resolver) Document() schema.DocumentResolver { return &documentResolver{r} } @@ -1112,13 +1170,14 @@ func (r *Resolver) TrustCenterReference() schema.TrustCenterReferenceResolver { } type ( - auditResolver struct{ *Resolver } - auditReportResolver struct{ *Resolver } - complianceFrameworkResolver struct{ *Resolver } - documentResolver struct{ *Resolver } - frameworkResolver struct{ *Resolver } - subprocessorConnectionResolver struct{ *Resolver } - trustCenterResolver struct{ *Resolver } - trustCenterFileResolver struct{ *Resolver } - trustCenterReferenceResolver struct{ *Resolver } + auditResolver struct{ *Resolver } + auditReportResolver struct{ *Resolver } + complianceFrameworkResolver struct{ *Resolver } + compliancePortalCommitmentGroupResolver struct{ *Resolver } + documentResolver struct{ *Resolver } + frameworkResolver struct{ *Resolver } + subprocessorConnectionResolver struct{ *Resolver } + trustCenterResolver struct{ *Resolver } + trustCenterFileResolver struct{ *Resolver } + trustCenterReferenceResolver struct{ *Resolver } ) diff --git a/pkg/server/api/console/v1/graphql/trust_center.graphql b/pkg/server/api/console/v1/graphql/trust_center.graphql index e1635c39f..f3b51be40 100644 --- a/pkg/server/api/console/v1/graphql/trust_center.graphql +++ b/pkg/server/api/console/v1/graphql/trust_center.graphql @@ -106,6 +106,88 @@ enum TrustCenterReferenceOrderField ) } +enum CompliancePortalCommitmentGroupOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentGroupOrderField" + ) { + RANK + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentGroupOrderFieldRank" + ) + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentGroupOrderFieldCreatedAt" + ) + UPDATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentGroupOrderFieldUpdatedAt" + ) +} + +enum CompliancePortalCommitmentOrderField + @goModel( + model: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentOrderField" + ) { + RANK + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentOrderFieldRank" + ) + CREATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentOrderFieldCreatedAt" + ) + UPDATED_AT + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentOrderFieldUpdatedAt" + ) +} + +enum CompliancePortalCommitmentIcon + @goModel( + model: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIcon" + ) { + LOCK_KEY + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconLockKey") + EYE_SLASH + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconEyeSlash") + FINGERPRINT + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconFingerprint") + SHIELD_WARNING + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconShieldWarning") + SHIELD_CHECK + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconShieldCheck") + SIREN + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconSiren") + KEY + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconKey") + LOCK + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconLock") + CLOUD + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconCloud") + DATABASE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconDatabase") + GLOBE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconGlobe") + EYE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconEye") + USERS + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconUsers") + CERTIFICATE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconCertificate") + GAVEL + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconGavel") + HEARTBEAT + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconHeartbeat") + BELL + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconBell") + BUG + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconBug") + CODE + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconCode") + SERVER + @goEnum(value: "go.probo.inc/probo/pkg/coredata.CompliancePortalCommitmentIconServer") +} + enum ComplianceCustomLinkOrderField @goModel( model: "go.probo.inc/probo/pkg/coredata.ComplianceCustomLinkOrderField" @@ -218,6 +300,22 @@ input TrustCenterReferenceOrder field: TrustCenterReferenceOrderField! } +input CompliancePortalCommitmentGroupOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CompliancePortalCommitmentGroupOrderBy" + ) { + direction: OrderDirection! + field: CompliancePortalCommitmentGroupOrderField! +} + +input CompliancePortalCommitmentOrder + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CompliancePortalCommitmentOrderBy" + ) { + direction: OrderDirection! + field: CompliancePortalCommitmentOrderField! +} + input TrustCenterFileOrder @goModel( model: "go.probo.inc/probo/pkg/server/api/console/v1/types.TrustCenterFileOrderBy" @@ -277,6 +375,14 @@ type TrustCenter implements Node orderBy: TrustCenterReferenceOrder ): TrustCenterReferenceConnection! @goField(forceResolver: true) + commitmentGroups( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: CompliancePortalCommitmentGroupOrder + ): CompliancePortalCommitmentGroupConnection! @goField(forceResolver: true) + complianceFrameworks( first: Int after: CursorKey @@ -400,6 +506,66 @@ type TrustCenterReferenceEdge { node: TrustCenterReference! } +type CompliancePortalCommitmentGroup implements Node { + id: ID! + title: String! + description: String! + rank: Int! + createdAt: Datetime! + updatedAt: Datetime! + + commitments( + first: Int + after: CursorKey + last: Int + before: CursorKey + orderBy: CompliancePortalCommitmentOrder + ): CompliancePortalCommitmentConnection! @goField(forceResolver: true) + + permission(action: String!): Boolean! @goField(forceResolver: true) +} + +type CompliancePortalCommitmentGroupConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CompliancePortalCommitmentGroupConnection" + ) { + totalCount: Int! @goField(forceResolver: true) + edges: [CompliancePortalCommitmentGroupEdge!]! + pageInfo: PageInfo! +} + +type CompliancePortalCommitmentGroupEdge { + cursor: CursorKey! + node: CompliancePortalCommitmentGroup! +} + +type CompliancePortalCommitment implements Node { + id: ID! + icon: CompliancePortalCommitmentIcon! + eyebrow: String! + title: String! + description: String! + rank: Int! + createdAt: Datetime! + updatedAt: Datetime! + + permission(action: String!): Boolean! @goField(forceResolver: true) +} + +type CompliancePortalCommitmentConnection + @goModel( + model: "go.probo.inc/probo/pkg/server/api/console/v1/types.CompliancePortalCommitmentConnection" + ) { + totalCount: Int! @goField(forceResolver: true) + edges: [CompliancePortalCommitmentEdge!]! + pageInfo: PageInfo! +} + +type CompliancePortalCommitmentEdge { + cursor: CursorKey! + node: CompliancePortalCommitment! +} + type ComplianceFramework implements Node @goModel( model: "go.probo.inc/probo/pkg/server/api/console/v1/types.ComplianceFramework" @@ -526,6 +692,24 @@ extend type Mutation { deleteTrustCenterReference( input: DeleteTrustCenterReferenceInput! ): DeleteTrustCenterReferencePayload! + createCompliancePortalCommitmentGroup( + input: CreateCompliancePortalCommitmentGroupInput! + ): CreateCompliancePortalCommitmentGroupPayload! + updateCompliancePortalCommitmentGroup( + input: UpdateCompliancePortalCommitmentGroupInput! + ): UpdateCompliancePortalCommitmentGroupPayload! + deleteCompliancePortalCommitmentGroup( + input: DeleteCompliancePortalCommitmentGroupInput! + ): DeleteCompliancePortalCommitmentGroupPayload! + createCompliancePortalCommitment( + input: CreateCompliancePortalCommitmentInput! + ): CreateCompliancePortalCommitmentPayload! + updateCompliancePortalCommitment( + input: UpdateCompliancePortalCommitmentInput! + ): UpdateCompliancePortalCommitmentPayload! + deleteCompliancePortalCommitment( + input: DeleteCompliancePortalCommitmentInput! + ): DeleteCompliancePortalCommitmentPayload! createComplianceFramework( input: CreateComplianceFrameworkInput! ): CreateComplianceFrameworkPayload! @@ -630,6 +814,44 @@ input DeleteTrustCenterReferenceInput { id: ID! } +input CreateCompliancePortalCommitmentGroupInput { + trustCenterId: ID! + title: String! + description: String! +} + +input UpdateCompliancePortalCommitmentGroupInput { + id: ID! + title: String + description: String + rank: Int +} + +input DeleteCompliancePortalCommitmentGroupInput { + id: ID! +} + +input CreateCompliancePortalCommitmentInput { + groupId: ID! + icon: CompliancePortalCommitmentIcon! + eyebrow: String! + title: String! + description: String! +} + +input UpdateCompliancePortalCommitmentInput { + id: ID! + icon: CompliancePortalCommitmentIcon + eyebrow: String + title: String + description: String + rank: Int +} + +input DeleteCompliancePortalCommitmentInput { + id: ID! +} + input CreateComplianceFrameworkInput { trustCenterId: ID! frameworkId: ID! @@ -729,6 +951,30 @@ type DeleteTrustCenterReferencePayload { deletedTrustCenterReferenceId: ID! } +type CreateCompliancePortalCommitmentGroupPayload { + compliancePortalCommitmentGroupEdge: CompliancePortalCommitmentGroupEdge! +} + +type UpdateCompliancePortalCommitmentGroupPayload { + compliancePortalCommitmentGroup: CompliancePortalCommitmentGroup! +} + +type DeleteCompliancePortalCommitmentGroupPayload { + deletedCompliancePortalCommitmentGroupId: ID! +} + +type CreateCompliancePortalCommitmentPayload { + compliancePortalCommitmentEdge: CompliancePortalCommitmentEdge! +} + +type UpdateCompliancePortalCommitmentPayload { + compliancePortalCommitment: CompliancePortalCommitment! +} + +type DeleteCompliancePortalCommitmentPayload { + deletedCompliancePortalCommitmentId: ID! +} + type CreateComplianceFrameworkPayload { complianceFrameworkEdge: ComplianceFrameworkEdge! } diff --git a/pkg/server/api/console/v1/trust_center_resolvers.go b/pkg/server/api/console/v1/trust_center_resolvers.go index a68f66fc7..8a9e67568 100644 --- a/pkg/server/api/console/v1/trust_center_resolvers.go +++ b/pkg/server/api/console/v1/trust_center_resolvers.go @@ -52,6 +52,78 @@ func (r *complianceFrameworkResolver) Framework(ctx context.Context, obj *types. return types.NewFramework(framework), nil } +// Permission is the resolver for the permission field. +func (r *compliancePortalCommitmentResolver) Permission(ctx context.Context, obj *types.CompliancePortalCommitment, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// TotalCount is the resolver for the totalCount field. +func (r *compliancePortalCommitmentConnectionResolver) TotalCount(ctx context.Context, obj *types.CompliancePortalCommitmentConnection) (int, error) { + scope, err := r.authorize(ctx, obj.ParentID, management.ActionCompliancePortalCommitmentList) + if err != nil { + return 0, err + } + + count, err := r.management.CountCommitments(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count compliance portal commitments", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + + return count, nil +} + +// Commitments is the resolver for the commitments field. +func (r *compliancePortalCommitmentGroupResolver) Commitments(ctx context.Context, obj *types.CompliancePortalCommitmentGroup, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.OrderBy[coredata.CompliancePortalCommitmentOrderField]) (*types.CompliancePortalCommitmentConnection, error) { + scope, err := r.authorize(ctx, obj.ID, management.ActionCompliancePortalCommitmentList) + if err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.CompliancePortalCommitmentOrderField]{ + Field: coredata.CompliancePortalCommitmentOrderFieldRank, + Direction: page.OrderDirectionAsc, + } + + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.CompliancePortalCommitmentOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + result, err := r.management.ListCommitments(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list compliance portal commitments", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewCompliancePortalCommitmentConnection(result, obj.ID), nil +} + +// Permission is the resolver for the permission field. +func (r *compliancePortalCommitmentGroupResolver) Permission(ctx context.Context, obj *types.CompliancePortalCommitmentGroup, action string) (bool, error) { + return r.Resolver.Permission(ctx, obj, action) +} + +// TotalCount is the resolver for the totalCount field. +func (r *compliancePortalCommitmentGroupConnectionResolver) TotalCount(ctx context.Context, obj *types.CompliancePortalCommitmentGroupConnection) (int, error) { + scope, err := r.authorize(ctx, obj.ParentID, management.ActionCompliancePortalCommitmentGroupList) + if err != nil { + return 0, err + } + + count, err := r.management.CountCommitmentGroups(ctx, scope, obj.ParentID) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count compliance portal commitment groups", log.Error(err)) + return 0, gqlutils.Internal(ctx) + } + + return count, nil +} + // Permission is the resolver for the permission field. func (r *customDomainResolver) Permission(ctx context.Context, obj *types.CustomDomain, action string) (bool, error) { return r.Resolver.Permission(ctx, obj, action) @@ -370,6 +442,166 @@ func (r *mutationResolver) DeleteTrustCenterReference(ctx context.Context, input }, nil } +// CreateCompliancePortalCommitmentGroup is the resolver for the createCompliancePortalCommitmentGroup field. +func (r *mutationResolver) CreateCompliancePortalCommitmentGroup(ctx context.Context, input types.CreateCompliancePortalCommitmentGroupInput) (*types.CreateCompliancePortalCommitmentGroupPayload, error) { + scope, err := r.authorize(ctx, input.TrustCenterID, management.ActionCompliancePortalCommitmentGroupCreate) + if err != nil { + return nil, err + } + + group, err := r.management.CreateCommitmentGroup( + ctx, scope, + &management.CreateCompliancePortalCommitmentGroupRequest{ + TrustCenterID: input.TrustCenterID, + Title: input.Title, + Description: input.Description, + }, + ) + if err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + + r.logger.ErrorCtx(ctx, "cannot create compliance portal commitment group", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.CreateCompliancePortalCommitmentGroupPayload{ + CompliancePortalCommitmentGroupEdge: types.NewCompliancePortalCommitmentGroupEdge(group, coredata.CompliancePortalCommitmentGroupOrderFieldRank), + }, nil +} + +// UpdateCompliancePortalCommitmentGroup is the resolver for the updateCompliancePortalCommitmentGroup field. +func (r *mutationResolver) UpdateCompliancePortalCommitmentGroup(ctx context.Context, input types.UpdateCompliancePortalCommitmentGroupInput) (*types.UpdateCompliancePortalCommitmentGroupPayload, error) { + scope, err := r.authorize(ctx, input.ID, management.ActionCompliancePortalCommitmentGroupUpdate) + if err != nil { + return nil, err + } + + group, err := r.management.UpdateCommitmentGroup( + ctx, scope, + &management.UpdateCompliancePortalCommitmentGroupRequest{ + ID: input.ID, + Title: input.Title, + Description: input.Description, + Rank: input.Rank, + }, + ) + if err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + + r.logger.ErrorCtx(ctx, "cannot update compliance portal commitment group", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.UpdateCompliancePortalCommitmentGroupPayload{ + CompliancePortalCommitmentGroup: types.NewCompliancePortalCommitmentGroup(group), + }, nil +} + +// DeleteCompliancePortalCommitmentGroup is the resolver for the deleteCompliancePortalCommitmentGroup field. +func (r *mutationResolver) DeleteCompliancePortalCommitmentGroup(ctx context.Context, input types.DeleteCompliancePortalCommitmentGroupInput) (*types.DeleteCompliancePortalCommitmentGroupPayload, error) { + scope, err := r.authorize(ctx, input.ID, management.ActionCompliancePortalCommitmentGroupDelete) + if err != nil { + return nil, err + } + + if err := r.management.DeleteCommitmentGroup(ctx, scope, input.ID); err != nil { + r.logger.ErrorCtx(ctx, "cannot delete compliance portal commitment group", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.DeleteCompliancePortalCommitmentGroupPayload{ + DeletedCompliancePortalCommitmentGroupID: input.ID, + }, nil +} + +// CreateCompliancePortalCommitment is the resolver for the createCompliancePortalCommitment field. +func (r *mutationResolver) CreateCompliancePortalCommitment(ctx context.Context, input types.CreateCompliancePortalCommitmentInput) (*types.CreateCompliancePortalCommitmentPayload, error) { + scope, err := r.authorize(ctx, input.GroupID, management.ActionCompliancePortalCommitmentCreate) + if err != nil { + return nil, err + } + + commitment, err := r.management.CreateCommitment( + ctx, scope, + &management.CreateCompliancePortalCommitmentRequest{ + GroupID: input.GroupID, + Icon: input.Icon, + Eyebrow: input.Eyebrow, + Title: input.Title, + Description: input.Description, + }, + ) + if err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + + r.logger.ErrorCtx(ctx, "cannot create compliance portal commitment", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.CreateCompliancePortalCommitmentPayload{ + CompliancePortalCommitmentEdge: types.NewCompliancePortalCommitmentEdge(commitment, coredata.CompliancePortalCommitmentOrderFieldRank), + }, nil +} + +// UpdateCompliancePortalCommitment is the resolver for the updateCompliancePortalCommitment field. +func (r *mutationResolver) UpdateCompliancePortalCommitment(ctx context.Context, input types.UpdateCompliancePortalCommitmentInput) (*types.UpdateCompliancePortalCommitmentPayload, error) { + scope, err := r.authorize(ctx, input.ID, management.ActionCompliancePortalCommitmentUpdate) + if err != nil { + return nil, err + } + + commitment, err := r.management.UpdateCommitment( + ctx, scope, + &management.UpdateCompliancePortalCommitmentRequest{ + ID: input.ID, + Icon: input.Icon, + Eyebrow: input.Eyebrow, + Title: input.Title, + Description: input.Description, + Rank: input.Rank, + }, + ) + if err != nil { + if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok { + return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors) + } + + r.logger.ErrorCtx(ctx, "cannot update compliance portal commitment", log.Error(err)) + + return nil, gqlutils.Internal(ctx) + } + + return &types.UpdateCompliancePortalCommitmentPayload{ + CompliancePortalCommitment: types.NewCompliancePortalCommitment(commitment), + }, nil +} + +// DeleteCompliancePortalCommitment is the resolver for the deleteCompliancePortalCommitment field. +func (r *mutationResolver) DeleteCompliancePortalCommitment(ctx context.Context, input types.DeleteCompliancePortalCommitmentInput) (*types.DeleteCompliancePortalCommitmentPayload, error) { + scope, err := r.authorize(ctx, input.ID, management.ActionCompliancePortalCommitmentDelete) + if err != nil { + return nil, err + } + + if err := r.management.DeleteCommitment(ctx, scope, input.ID); err != nil { + r.logger.ErrorCtx(ctx, "cannot delete compliance portal commitment", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.DeleteCompliancePortalCommitmentPayload{ + DeletedCompliancePortalCommitmentID: input.ID, + }, nil +} + // CreateComplianceFramework is the resolver for the createComplianceFramework field. func (r *mutationResolver) CreateComplianceFramework(ctx context.Context, input types.CreateComplianceFrameworkInput) (*types.CreateComplianceFrameworkPayload, error) { scope, err := r.authorize(ctx, input.TrustCenterID, management.ActionComplianceFrameworkCreate) @@ -821,6 +1053,36 @@ func (r *trustCenterResolver) References(ctx context.Context, obj *types.TrustCe return types.NewTrustCenterReferenceConnection(result, obj.ID), nil } +// CommitmentGroups is the resolver for the commitmentGroups field. +func (r *trustCenterResolver) CommitmentGroups(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.OrderBy[coredata.CompliancePortalCommitmentGroupOrderField]) (*types.CompliancePortalCommitmentGroupConnection, error) { + scope, err := r.authorize(ctx, obj.ID, management.ActionCompliancePortalCommitmentGroupList) + if err != nil { + return nil, err + } + + pageOrderBy := page.OrderBy[coredata.CompliancePortalCommitmentGroupOrderField]{ + Field: coredata.CompliancePortalCommitmentGroupOrderFieldRank, + Direction: page.OrderDirectionAsc, + } + + if orderBy != nil { + pageOrderBy = page.OrderBy[coredata.CompliancePortalCommitmentGroupOrderField]{ + Field: orderBy.Field, + Direction: orderBy.Direction, + } + } + + cursor := types.NewCursor(first, after, last, before, pageOrderBy) + + result, err := r.management.ListCommitmentGroups(ctx, scope, obj.ID, cursor) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot list compliance portal commitment groups", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return types.NewCompliancePortalCommitmentGroupConnection(result, obj.ID), nil +} + // ComplianceFrameworks is the resolver for the complianceFrameworks field. func (r *trustCenterResolver) ComplianceFrameworks(ctx context.Context, obj *types.TrustCenter, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.OrderBy[coredata.ComplianceFrameworkOrderField]) (*types.ComplianceFrameworkConnection, error) { scope, err := r.authorize(ctx, obj.ID, management.ActionComplianceFrameworkList) @@ -1296,6 +1558,26 @@ func (r *Resolver) ComplianceFramework() schema.ComplianceFrameworkResolver { return &complianceFrameworkResolver{r} } +// CompliancePortalCommitment returns schema.CompliancePortalCommitmentResolver implementation. +func (r *Resolver) CompliancePortalCommitment() schema.CompliancePortalCommitmentResolver { + return &compliancePortalCommitmentResolver{r} +} + +// CompliancePortalCommitmentConnection returns schema.CompliancePortalCommitmentConnectionResolver implementation. +func (r *Resolver) CompliancePortalCommitmentConnection() schema.CompliancePortalCommitmentConnectionResolver { + return &compliancePortalCommitmentConnectionResolver{r} +} + +// CompliancePortalCommitmentGroup returns schema.CompliancePortalCommitmentGroupResolver implementation. +func (r *Resolver) CompliancePortalCommitmentGroup() schema.CompliancePortalCommitmentGroupResolver { + return &compliancePortalCommitmentGroupResolver{r} +} + +// CompliancePortalCommitmentGroupConnection returns schema.CompliancePortalCommitmentGroupConnectionResolver implementation. +func (r *Resolver) CompliancePortalCommitmentGroupConnection() schema.CompliancePortalCommitmentGroupConnectionResolver { + return &compliancePortalCommitmentGroupConnectionResolver{r} +} + // CustomDomain returns schema.CustomDomainResolver implementation. func (r *Resolver) CustomDomain() schema.CustomDomainResolver { return &customDomainResolver{r} } @@ -1338,15 +1620,19 @@ func (r *Resolver) TrustCenterReferenceConnection() schema.TrustCenterReferenceC } type ( - complianceCustomLinkResolver struct{ *Resolver } - complianceFrameworkResolver struct{ *Resolver } - customDomainResolver struct{ *Resolver } - trustCenterResolver struct{ *Resolver } - trustCenterAccessResolver struct{ *Resolver } - trustCenterDocumentAccessResolver struct{ *Resolver } - trustCenterDocumentAccessConnectionResolver struct{ *Resolver } - trustCenterFileResolver struct{ *Resolver } - trustCenterFileConnectionResolver struct{ *Resolver } - trustCenterReferenceResolver struct{ *Resolver } - trustCenterReferenceConnectionResolver struct{ *Resolver } + complianceCustomLinkResolver struct{ *Resolver } + complianceFrameworkResolver struct{ *Resolver } + compliancePortalCommitmentResolver struct{ *Resolver } + compliancePortalCommitmentConnectionResolver struct{ *Resolver } + compliancePortalCommitmentGroupResolver struct{ *Resolver } + compliancePortalCommitmentGroupConnectionResolver struct{ *Resolver } + customDomainResolver struct{ *Resolver } + trustCenterResolver struct{ *Resolver } + trustCenterAccessResolver struct{ *Resolver } + trustCenterDocumentAccessResolver struct{ *Resolver } + trustCenterDocumentAccessConnectionResolver struct{ *Resolver } + trustCenterFileResolver struct{ *Resolver } + trustCenterFileConnectionResolver struct{ *Resolver } + trustCenterReferenceResolver struct{ *Resolver } + trustCenterReferenceConnectionResolver struct{ *Resolver } )