diff --git a/apps/console/src/components/compliancePage/CompliancePageReferenceDialog.tsx b/apps/console/src/components/compliancePage/CompliancePageReferenceDialog.tsx
index 73044fe67..d814f68b8 100644
--- a/apps/console/src/components/compliancePage/CompliancePageReferenceDialog.tsx
+++ b/apps/console/src/components/compliancePage/CompliancePageReferenceDialog.tsx
@@ -35,11 +35,11 @@ import { forwardRef, type ReactNode, useImperativeHandle, useState } from "react
import { z } from "zod";
import type { CompliancePageReferenceListItemFragment$data } from "#/__generated__/core/CompliancePageReferenceListItemFragment.graphql";
-import {
- useCreateTrustCenterReferenceMutation,
- useUpdateTrustCenterReferenceMutation,
-} from "#/hooks/graph/TrustCenterReferenceGraph";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
+import {
+ useCreateCompliancePageReferenceMutation,
+ useUpdateCompliancePageReferenceMutation,
+} from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
const referenceSchema = z.object({
name: z.string().min(1, "Name is required"),
@@ -65,8 +65,8 @@ export const CompliancePageReferenceDialog = forwardRef(null);
const [uploadedFile, setUploadedFile] = useState(null);
- const [createReference, isCreating] = useCreateTrustCenterReferenceMutation();
- const [updateReference, isUpdating] = useUpdateTrustCenterReferenceMutation();
+ const [createReference, isCreating] = useCreateCompliancePageReferenceMutation();
+ const [updateReference, isUpdating] = useUpdateCompliancePageReferenceMutation();
const { register, handleSubmit, formState: { errors }, reset } = useFormWithSchema(
referenceSchema,
diff --git a/apps/console/src/components/compliancePage/DeleteCompliancePageReferenceDialog.tsx b/apps/console/src/components/compliancePage/DeleteCompliancePageReferenceDialog.tsx
index f9545460e..a929246c1 100644
--- a/apps/console/src/components/compliancePage/DeleteCompliancePageReferenceDialog.tsx
+++ b/apps/console/src/components/compliancePage/DeleteCompliancePageReferenceDialog.tsx
@@ -30,9 +30,9 @@ import {
useDialogRef,
} from "@probo/ui";
-import type { TrustCenterReferenceGraphDeleteMutation } from "#/__generated__/core/TrustCenterReferenceGraphDeleteMutation.graphql";
-import { deleteTrustCenterReferenceMutation } from "#/hooks/graph/TrustCenterReferenceGraph";
+import type { compliancePageReferenceMutationsDeleteMutation } from "#/__generated__/core/compliancePageReferenceMutationsDeleteMutation.graphql";
import { useMutation } from "#/lib/relay/useMutation";
+import { deleteCompliancePageReferenceMutation } from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
type Props = {
children: React.ReactNode;
@@ -52,8 +52,8 @@ export function DeleteCompliancePageReferenceDialog({
const { __ } = useTranslate();
const ref = useDialogRef();
- const [mutate, isDeleting] = useMutation(
- deleteTrustCenterReferenceMutation,
+ const [mutate, isDeleting] = useMutation(
+ deleteCompliancePageReferenceMutation,
{
successMessage: __("Reference deleted successfully"),
errorToast: __("Failed to delete reference"),
diff --git a/apps/console/src/hooks/graph/TrustCenterReferenceGraph.ts b/apps/console/src/hooks/graph/TrustCenterReferenceGraph.ts
deleted file mode 100644
index 518d12ce4..000000000
--- a/apps/console/src/hooks/graph/TrustCenterReferenceGraph.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-// Copyright (c) 2025-2026 Probo Inc .
-//
-// Permission to use, copy, modify, and/or distribute this software for any
-// purpose with or without fee is hereby granted, provided that the above
-// copyright notice and this permission notice appear in all copies.
-//
-// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-// PERFORMANCE OF THIS SOFTWARE.
-
-import { graphql } from "react-relay";
-
-import type { TrustCenterReferenceGraphCreateMutation } from "#/__generated__/core/TrustCenterReferenceGraphCreateMutation.graphql";
-import type { TrustCenterReferenceGraphDeleteMutation } from "#/__generated__/core/TrustCenterReferenceGraphDeleteMutation.graphql";
-import type { TrustCenterReferenceGraphUpdateMutation } from "#/__generated__/core/TrustCenterReferenceGraphUpdateMutation.graphql";
-import type { TrustCenterReferenceGraphUpdateRankMutation } from "#/__generated__/core/TrustCenterReferenceGraphUpdateRankMutation.graphql";
-import { useMutation } from "#/lib/relay/useMutation";
-
-export const createTrustCenterReferenceMutation = graphql`
- mutation TrustCenterReferenceGraphCreateMutation(
- $input: CreateTrustCenterReferenceInput!
- $connections: [ID!]!
- ) {
- createTrustCenterReference(input: $input) {
- trustCenterReferenceEdge @appendEdge(connections: $connections) {
- cursor
- node {
- id
- name
- description
- websiteUrl
- logo {
- downloadUrl
- }
- rank
- createdAt
- updatedAt
- canUpdate: permission(action: "compliance-portal:portal-reference:update")
- canDelete: permission(action: "compliance-portal:portal-reference:delete")
- }
- }
- }
- }
-`;
-
-export const updateTrustCenterReferenceMutation = graphql`
- mutation TrustCenterReferenceGraphUpdateMutation(
- $input: UpdateTrustCenterReferenceInput!
- ) {
- updateTrustCenterReference(input: $input) {
- trustCenterReference {
- id
- name
- description
- websiteUrl
- logo {
- downloadUrl
- }
- rank
- createdAt
- updatedAt
- canUpdate: permission(action: "compliance-portal:portal-reference:update")
- canDelete: permission(action: "compliance-portal:portal-reference:delete")
- }
- }
- }
-`;
-
-export const deleteTrustCenterReferenceMutation = graphql`
- mutation TrustCenterReferenceGraphDeleteMutation(
- $input: DeleteTrustCenterReferenceInput!
- $connections: [ID!]!
- ) {
- deleteTrustCenterReference(input: $input) {
- deletedTrustCenterReferenceId @deleteEdge(connections: $connections)
- }
- }
-`;
-
-export function useCreateTrustCenterReferenceMutation() {
- return useMutation(
- createTrustCenterReferenceMutation,
- {
- successMessage: "Reference created successfully",
- errorToast: "Failed to create reference",
- },
- );
-}
-
-export function useUpdateTrustCenterReferenceMutation() {
- return useMutation(
- updateTrustCenterReferenceMutation,
- {
- successMessage: "Reference updated successfully",
- errorToast: "Failed to update reference",
- },
- );
-}
-
-export const updateTrustCenterReferenceRankMutation = graphql`
- mutation TrustCenterReferenceGraphUpdateRankMutation(
- $input: UpdateTrustCenterReferenceInput!
- ) {
- updateTrustCenterReference(input: $input) {
- trustCenterReference {
- id
- rank
- }
- }
- }
-`;
-
-export function useUpdateTrustCenterReferenceRankMutation() {
- return useMutation(
- updateTrustCenterReferenceRankMutation,
- {
- successMessage: "Order updated successfully",
- errorToast: "Failed to update order",
- },
- );
-}
-
-export function useDeleteTrustCenterReferenceMutation() {
- return useMutation(
- deleteTrustCenterReferenceMutation,
- {
- successMessage: "Reference deleted successfully",
- errorToast: "Failed to delete reference",
- },
- );
-}
diff --git a/apps/console/src/pages/iam/auth/MagicLinkAlreadyUsedPage.tsx b/apps/console/src/pages/iam/auth/MagicLinkAlreadyUsedPage.tsx
index 9dd57c8e5..10f81b405 100644
--- a/apps/console/src/pages/iam/auth/MagicLinkAlreadyUsedPage.tsx
+++ b/apps/console/src/pages/iam/auth/MagicLinkAlreadyUsedPage.tsx
@@ -1,25 +1,29 @@
// Copyright (c) 2026 Probo Inc .
//
-// Permission to use, copy, modify, and/or distribute this software for any
-// purpose with or without fee is hereby granted, provided that the above
-// copyright notice and this permission notice appear in all copies.
+// 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 SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-// PERFORMANCE OF THIS SOFTWARE.
+// 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 { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button } from "@probo/ui";
-import { useNavigate } from "react-router";
export default function MagicLinkAlreadyUsedPage() {
const { __ } = useTranslate();
- const navigate = useNavigate();
usePageTitle(__("Link Already Used"));
@@ -33,10 +37,7 @@ export default function MagicLinkAlreadyUsedPage() {
)}
- void navigate("/auth/login")}
- >
+
{__("Sign in")}
diff --git a/apps/console/src/pages/iam/auth/MagicLinkExpiredPage.tsx b/apps/console/src/pages/iam/auth/MagicLinkExpiredPage.tsx
index 9d5b37cef..1920a8d0e 100644
--- a/apps/console/src/pages/iam/auth/MagicLinkExpiredPage.tsx
+++ b/apps/console/src/pages/iam/auth/MagicLinkExpiredPage.tsx
@@ -1,25 +1,29 @@
// Copyright (c) 2026 Probo Inc .
//
-// Permission to use, copy, modify, and/or distribute this software for any
-// purpose with or without fee is hereby granted, provided that the above
-// copyright notice and this permission notice appear in all copies.
+// 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 SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-// PERFORMANCE OF THIS SOFTWARE.
+// 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 { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button } from "@probo/ui";
-import { useNavigate } from "react-router";
export default function MagicLinkExpiredPage() {
const { __ } = useTranslate();
- const navigate = useNavigate();
usePageTitle(__("Link Expired"));
@@ -33,10 +37,7 @@ export default function MagicLinkExpiredPage() {
)}
- void navigate("/auth/login")}
- >
+
{__("Sign in")}
diff --git a/apps/console/src/pages/iam/auth/sign-in/SignInPage.tsx b/apps/console/src/pages/iam/auth/sign-in/SignInPage.tsx
index 80dbceaab..99430d2ef 100644
--- a/apps/console/src/pages/iam/auth/sign-in/SignInPage.tsx
+++ b/apps/console/src/pages/iam/auth/sign-in/SignInPage.tsx
@@ -42,9 +42,7 @@ export const signInPageQuery = graphql`
oauthClientBranding(clientId: $clientId) {
name
clientURL
- logo {
- downloadUrl
- }
+ logoUrl
}
}
`;
@@ -85,7 +83,7 @@ export default function SignInPage(props: Props) {
<>
diff --git a/apps/console/src/pages/iam/auth/sign-in/_components/MagicLinkForm.tsx b/apps/console/src/pages/iam/auth/sign-in/_components/MagicLinkForm.tsx
index fe678ffbc..a9beedba5 100644
--- a/apps/console/src/pages/iam/auth/sign-in/_components/MagicLinkForm.tsx
+++ b/apps/console/src/pages/iam/auth/sign-in/_components/MagicLinkForm.tsx
@@ -1,16 +1,22 @@
// Copyright (c) 2026 Probo Inc .
//
-// Permission to use, copy, modify, and/or distribute this software for any
-// purpose with or without fee is hereby granted, provided that the above
-// copyright notice and this permission notice appear in all copies.
+// 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 SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-// PERFORMANCE OF THIS SOFTWARE.
+// 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 { useTranslate } from "@probo/i18n";
import { Button, Field, useToast } from "@probo/ui";
@@ -67,12 +73,22 @@ export function MagicLinkForm() {
body.set("email", email);
body.set("continue", postAuthRedirectUrl);
- const response = await fetch("/api/connect/v1/magic-link/send", {
- method: "POST",
- headers: { "content-type": "application/x-www-form-urlencoded" },
- credentials: "include",
- body,
- });
+ let response: Response;
+ try {
+ response = await fetch("/api/connect/v1/magic-link/send", {
+ method: "POST",
+ headers: { "content-type": "application/x-www-form-urlencoded" },
+ credentials: "include",
+ body,
+ });
+ } catch {
+ toast({
+ title: __("Error"),
+ description: __("Cannot send magic link"),
+ variant: "error",
+ });
+ return;
+ }
if (!response.ok) {
toast({
diff --git a/apps/console/src/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations.ts b/apps/console/src/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations.ts
new file mode 100644
index 000000000..176b94935
--- /dev/null
+++ b/apps/console/src/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations.ts
@@ -0,0 +1,141 @@
+// Copyright (c) 2025-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 { graphql } from "react-relay";
+
+import type { compliancePageReferenceMutationsCreateMutation } from "#/__generated__/core/compliancePageReferenceMutationsCreateMutation.graphql";
+import type { compliancePageReferenceMutationsDeleteMutation } from "#/__generated__/core/compliancePageReferenceMutationsDeleteMutation.graphql";
+import type { compliancePageReferenceMutationsUpdateMutation } from "#/__generated__/core/compliancePageReferenceMutationsUpdateMutation.graphql";
+import type { compliancePageReferenceMutationsUpdateRankMutation } from "#/__generated__/core/compliancePageReferenceMutationsUpdateRankMutation.graphql";
+import { useMutation } from "#/lib/relay/useMutation";
+
+export const createCompliancePageReferenceMutation = graphql`
+ mutation compliancePageReferenceMutationsCreateMutation(
+ $input: CreateTrustCenterReferenceInput!
+ $connections: [ID!]!
+ ) {
+ createTrustCenterReference(input: $input) {
+ trustCenterReferenceEdge @appendEdge(connections: $connections) {
+ cursor
+ node {
+ id
+ name
+ description
+ websiteUrl
+ logo {
+ downloadUrl
+ }
+ rank
+ createdAt
+ updatedAt
+ canUpdate: permission(action: "compliance-portal:portal-reference:update")
+ canDelete: permission(action: "compliance-portal:portal-reference:delete")
+ }
+ }
+ }
+ }
+`;
+
+export const updateCompliancePageReferenceMutation = graphql`
+ mutation compliancePageReferenceMutationsUpdateMutation(
+ $input: UpdateTrustCenterReferenceInput!
+ ) {
+ updateTrustCenterReference(input: $input) {
+ trustCenterReference {
+ id
+ name
+ description
+ websiteUrl
+ logo {
+ downloadUrl
+ }
+ rank
+ createdAt
+ updatedAt
+ canUpdate: permission(action: "compliance-portal:portal-reference:update")
+ canDelete: permission(action: "compliance-portal:portal-reference:delete")
+ }
+ }
+ }
+`;
+
+export const deleteCompliancePageReferenceMutation = graphql`
+ mutation compliancePageReferenceMutationsDeleteMutation(
+ $input: DeleteTrustCenterReferenceInput!
+ $connections: [ID!]!
+ ) {
+ deleteTrustCenterReference(input: $input) {
+ deletedTrustCenterReferenceId @deleteEdge(connections: $connections)
+ }
+ }
+`;
+
+export function useCreateCompliancePageReferenceMutation() {
+ return useMutation(
+ createCompliancePageReferenceMutation,
+ {
+ successMessage: "Reference created successfully",
+ errorToast: "Failed to create reference",
+ },
+ );
+}
+
+export function useUpdateCompliancePageReferenceMutation() {
+ return useMutation(
+ updateCompliancePageReferenceMutation,
+ {
+ successMessage: "Reference updated successfully",
+ errorToast: "Failed to update reference",
+ },
+ );
+}
+
+export const updateCompliancePageReferenceRankMutation = graphql`
+ mutation compliancePageReferenceMutationsUpdateRankMutation(
+ $input: UpdateTrustCenterReferenceInput!
+ ) {
+ updateTrustCenterReference(input: $input) {
+ trustCenterReference {
+ id
+ rank
+ }
+ }
+ }
+`;
+
+export function useUpdateCompliancePageReferenceRankMutation() {
+ return useMutation(
+ updateCompliancePageReferenceRankMutation,
+ {
+ successMessage: "Order updated successfully",
+ errorToast: "Failed to update order",
+ },
+ );
+}
+
+export function useDeleteCompliancePageReferenceMutation() {
+ return useMutation(
+ deleteCompliancePageReferenceMutation,
+ {
+ successMessage: "Reference deleted successfully",
+ errorToast: "Failed to delete reference",
+ },
+ );
+}
diff --git a/apps/console/src/pages/organizations/compliance-page/references/_components/CompliancePageReferenceList.tsx b/apps/console/src/pages/organizations/compliance-page/references/_components/CompliancePageReferenceList.tsx
index 1146b0fbf..ea4e7a6a4 100644
--- a/apps/console/src/pages/organizations/compliance-page/references/_components/CompliancePageReferenceList.tsx
+++ b/apps/console/src/pages/organizations/compliance-page/references/_components/CompliancePageReferenceList.tsx
@@ -27,7 +27,7 @@ import { graphql } from "relay-runtime";
import type { CompliancePageReferenceListFragment$key } from "#/__generated__/core/CompliancePageReferenceListFragment.graphql";
import type { CompliancePageReferenceListItemFragment$data } from "#/__generated__/core/CompliancePageReferenceListItemFragment.graphql";
import type { CompliancePageReferenceListQuery } from "#/__generated__/core/CompliancePageReferenceListQuery.graphql";
-import { useUpdateTrustCenterReferenceRankMutation } from "#/hooks/graph/TrustCenterReferenceGraph";
+import { useUpdateCompliancePageReferenceRankMutation } from "#/pages/organizations/compliance-page/_lib/compliancePageReferenceMutations";
import { CompliancePageReferenceListItem } from "./CompliancePageReferenceListItem";
@@ -65,7 +65,7 @@ export function CompliancePageReferenceList(props: {
CompliancePageReferenceListQuery,
CompliancePageReferenceListFragment$key
>(fragment, fragmentRef);
- const [updateRank] = useUpdateTrustCenterReferenceRankMutation();
+ const [updateRank] = useUpdateCompliancePageReferenceRankMutation();
const [draggedIndex, setDraggedIndex] = useState(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
diff --git a/apps/trust/src/queries/TrustGraph.ts b/apps/trust/src/queries/TrustGraph.ts
index 3c5670af1..a55fd7226 100644
--- a/apps/trust/src/queries/TrustGraph.ts
+++ b/apps/trust/src/queries/TrustGraph.ts
@@ -94,7 +94,6 @@ export const currentTrustDocumentsQuery = graphql`
query TrustGraphCurrentDocumentsQuery {
currentTrustCenter {
id
- title
documents(first: 50) {
edges {
node {
@@ -121,7 +120,6 @@ export const currentTrustSubprocessorsQuery = graphql`
query TrustGraphCurrentSubprocessorsQuery {
currentTrustCenter {
id
- title
subprocessors(first: 50) {
edges {
node {
diff --git a/e2e/internal/testutil/graphql.go b/e2e/internal/testutil/graphql.go
index 61f196969..763e4c3ea 100644
--- a/e2e/internal/testutil/graphql.go
+++ b/e2e/internal/testutil/graphql.go
@@ -39,8 +39,9 @@ import (
// trustCenterHTTPSAddr is the loopback address of the dedicated trust-center
// HTTPS listener started by the e2e probod (see generateConfig). Compliance
-// pages are served here exclusively, routed by TLS SNI / Host header.
-const trustCenterHTTPSAddr = "127.0.0.1:443"
+// pages are served here exclusively, routed by TLS SNI / Host header. Uses a
+// non-privileged port so the e2e suite doesn't require root/CAP_NET_BIND_SERVICE.
+const trustCenterHTTPSAddr = "127.0.0.1:8443"
type GraphQLRequest struct {
Query string `json:"query"`
diff --git a/e2e/internal/testutil/testutil.go b/e2e/internal/testutil/testutil.go
index f241626b5..fc26231cd 100644
--- a/e2e/internal/testutil/testutil.go
+++ b/e2e/internal/testutil/testutil.go
@@ -298,7 +298,7 @@ func generateConfig() (string, error) {
// yields {slug}.probopage.localhost subdomains for pages without a
// customer custom domain.
"PROBOD_TRUST_CENTER_HTTP_ADDR": ":10080",
- "PROBOD_TRUST_CENTER_HTTPS_ADDR": ":443",
+ "PROBOD_TRUST_CENTER_HTTPS_ADDR": ":8443",
"PROBOD_TRUST_CENTER_BASE_DOMAIN": "probopage.localhost",
// Keep certificate provisioning snappy so trust-center e2e flows do not
diff --git a/pkg/certmanager/cache_store.go b/pkg/certmanager/cache_store.go
index a47fa2632..619392e18 100644
--- a/pkg/certmanager/cache_store.go
+++ b/pkg/certmanager/cache_store.go
@@ -59,8 +59,14 @@ func (w *CacheStore) WarmCache(ctx context.Context) error {
err := w.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
+ var domains coredata.CustomDomains
+ keepCertificateIDs, err := domains.LoadReferencedCertificateIDs(ctx, conn)
+ if err != nil {
+ return fmt.Errorf("cannot load referenced certificate ids: %w", err)
+ }
+
var caches coredata.CachedCertificates
- if err := caches.DeleteUnreferenced(ctx, conn); err != nil {
+ if err := caches.DeleteWhereCertificateIDNotIn(ctx, conn, keepCertificateIDs); err != nil {
return fmt.Errorf("cannot delete unreferenced certificate cache: %w", err)
}
@@ -116,19 +122,6 @@ func (w *CacheStore) warmCertificate(ctx context.Context, conn pg.Querier, certi
return fmt.Errorf("cannot parse certificate: %w", err)
}
- if len(loadedCertificate.SSLCertificatePEM) == 0 {
- return fmt.Errorf("certificate has no certificate PEM")
- }
-
- privateKeyPEM, err := loadedCertificate.DecryptPrivateKey(w.encryptionKey)
- if err != nil {
- return fmt.Errorf("cannot decrypt private key: %w", err)
- }
-
- if len(privateKeyPEM) == 0 {
- return fmt.Errorf("certificate has no private key PEM")
- }
-
if loadedCertificate.SSLExpiresAt == nil {
return fmt.Errorf("certificate has no expiry date")
}
@@ -137,18 +130,9 @@ func (w *CacheStore) warmCertificate(ctx context.Context, conn pg.Querier, certi
return fmt.Errorf("certificate has expired")
}
- cache := &coredata.CachedCertificate{
- Domain: loadedCertificate.Hostname,
- CertificatePEM: string(loadedCertificate.SSLCertificatePEM),
- PrivateKeyPEM: string(privateKeyPEM),
- CertificateChain: loadedCertificate.SSLCertificateChain,
- ExpiresAt: *loadedCertificate.SSLExpiresAt,
- CachedAt: time.Now(),
- CertificateID: loadedCertificate.ID,
- }
-
- if err := cache.Upsert(ctx, conn); err != nil {
- return fmt.Errorf("cannot upsert cache entry: %w", err)
+ var cache coredata.CachedCertificate
+ if err := cache.RefreshFromCertificate(ctx, conn, &loadedCertificate, w.encryptionKey); err != nil {
+ return fmt.Errorf("cannot refresh certificate cache: %w", err)
}
return nil
diff --git a/pkg/certmanager/renew_worker.go b/pkg/certmanager/renew_worker.go
index 77efb5eaa..673f3a601 100644
--- a/pkg/certmanager/renew_worker.go
+++ b/pkg/certmanager/renew_worker.go
@@ -97,6 +97,12 @@ func (h *renewHandler) Process(ctx context.Context, certificate coredata.Certifi
func(ctx context.Context, tx pg.Tx) error {
fullCertificate := &coredata.Certificate{}
if err := fullCertificate.LoadByIDForUpdateSkipLocked(ctx, tx, coredata.NewNoScope(), certificate.ID); err != nil {
+ // Another provision/renewal cycle may already hold the row
+ // (SKIP LOCKED) or the certificate may have been deleted.
+ if errors.Is(err, coredata.ErrResourceNotFound) {
+ return nil
+ }
+
return fmt.Errorf("cannot load certificate for renewal: %w", err)
}
diff --git a/pkg/certmanager/selector.go b/pkg/certmanager/selector.go
index 379d84805..cf713c280 100644
--- a/pkg/certmanager/selector.go
+++ b/pkg/certmanager/selector.go
@@ -26,7 +26,6 @@ import (
"errors"
"fmt"
"sync"
- "time"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
@@ -68,7 +67,14 @@ func (s *Selector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
if cached, ok := s.cache.Load(domain); ok {
if cert, ok := cached.(*tls.Certificate); ok {
- return cert, nil
+ if err := s.checkRoutable(domain); err == nil {
+ return cert, nil
+ }
+
+ // The domain was deleted or is no longer routable since the
+ // cache entry was stored; evict it and fall through to a fresh
+ // database load below.
+ s.cache.Delete(domain)
}
}
@@ -82,6 +88,21 @@ func (s *Selector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
return cert, nil
}
+// checkRoutable reports whether domain is still a routable custom domain
+// with an active certificate. It is used to revalidate memory-cache hits so
+// certificates for deleted or de-provisioned domains stop being served
+// without waiting for process restart.
+func (s *Selector) checkRoutable(domain string) error {
+ ctx := context.Background()
+
+ return s.pg.WithConn(
+ ctx,
+ func(ctx context.Context, conn pg.Querier) error {
+ return requireRoutableDomain(ctx, conn, domain)
+ },
+ )
+}
+
func (s *Selector) loadFromDatabase(domain string) (*tls.Certificate, error) {
ctx := context.Background()
@@ -153,32 +174,20 @@ func (s *Selector) rebuildCacheEntry(ctx context.Context, conn pg.Querier, domai
return fmt.Errorf("certificate has no encrypted private key data")
}
- privateKeyPEM, err := certificate.DecryptPrivateKey(s.encryptionKey)
- if err != nil {
- return fmt.Errorf("cannot decrypt private key: %w", err)
+ if certificate.SSLExpiresAt == nil {
+ return fmt.Errorf("certificate has no expiry")
}
s.cache.Store(domain, certificate.SSLCertificate)
- cache := &coredata.CachedCertificate{
- Domain: certificate.Hostname,
- CertificatePEM: string(certificate.SSLCertificatePEM),
- PrivateKeyPEM: string(privateKeyPEM),
- CertificateChain: certificate.SSLCertificateChain,
- ExpiresAt: *certificate.SSLExpiresAt,
- CachedAt: time.Now(),
- CertificateID: certificate.ID,
- }
-
- if err := cache.Upsert(ctx, conn); err != nil {
+ var cache coredata.CachedCertificate
+ if err := cache.RefreshFromCertificate(ctx, conn, &certificate, s.encryptionKey); err != nil {
return fmt.Errorf("cannot insert cache entry: %w", err)
}
return nil
}
-// requireRoutableDomain ensures the SNI hostname still maps to a custom domain
-// row. Orphaned certificates left after domain deletion must not be served.
func requireRoutableDomain(ctx context.Context, conn pg.Querier, domain string) error {
var customDomain coredata.CustomDomain
if err := customDomain.LoadByDomain(ctx, conn, coredata.NewNoScope(), domain); err != nil {
diff --git a/pkg/cmd/trust-center/update/update.go b/pkg/cmd/trust-center/update/update.go
index 8d88d18cb..c56224630 100644
--- a/pkg/cmd/trust-center/update/update.go
+++ b/pkg/cmd/trust-center/update/update.go
@@ -182,7 +182,11 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
}
if cmd.Flags().Changed("email") {
- input["email"] = flagEmail
+ if flagEmail == "" {
+ input["email"] = nil
+ } else {
+ input["email"] = flagEmail
+ }
}
if cmd.Flags().Changed("headquarter-address") {
diff --git a/pkg/complianceportal/management/domain.go b/pkg/complianceportal/management/domain.go
index 40d7180a0..5d7867f5d 100644
--- a/pkg/complianceportal/management/domain.go
+++ b/pkg/complianceportal/management/domain.go
@@ -65,7 +65,7 @@ func (s *Service) PublicURLForCompliancePage(
switch {
case compliancePage.CustomDomainID != nil && byID[*compliancePage.CustomDomainID] != nil && active[*compliancePage.CustomDomainID]:
host = byID[*compliancePage.CustomDomainID].Domain
- case compliancePage.DefaultDomainID != nil && byID[*compliancePage.DefaultDomainID] != nil:
+ case compliancePage.DefaultDomainID != nil && byID[*compliancePage.DefaultDomainID] != nil && active[*compliancePage.DefaultDomainID]:
host = byID[*compliancePage.DefaultDomainID].Domain
}
diff --git a/pkg/complianceportal/management/oauth2_scopes.go b/pkg/complianceportal/management/oauth2_scopes.go
index 7e041fa0e..f693be9a8 100644
--- a/pkg/complianceportal/management/oauth2_scopes.go
+++ b/pkg/complianceportal/management/oauth2_scopes.go
@@ -1,16 +1,22 @@
// Copyright (c) 2026 Probo Inc .
//
-// Permission to use, copy, modify, and/or distribute this software for any
-// purpose with or without fee is hereby granted, provided that the above
-// copyright notice and this permission notice appear in all copies.
+// 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 SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-// PERFORMANCE OF THIS SOFTWARE.
+// 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.
package management
@@ -18,16 +24,11 @@ import (
"go.probo.inc/probo/pkg/coredata"
)
-// The compliance-page scope string values are part of the external OAuth2
-// contract and are kept stable even though the feature is named "compliance
-// portal" on the Go side.
const (
ScopeV1CompliancePortalRead coredata.OAuth2Scope = "v1:compliance-page:read"
ScopeV1CompliancePortal coredata.OAuth2Scope = "v1:compliance-page"
)
-// OAuth2ScopeMappings maps the compliance portal OAuth2 scopes to the actions
-// they grant.
var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ScopeV1CompliancePortalRead: {
ActionCompliancePortalGet,
@@ -45,6 +46,8 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ActionComplianceFrameworkList,
ActionComplianceCustomLinkList,
ActionCustomDomainGet,
+ ActionCompliancePortalCommitmentGroupList,
+ ActionCompliancePortalCommitmentList,
},
ScopeV1CompliancePortal: {
ActionCompliancePortalGet,
@@ -62,6 +65,8 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ActionComplianceFrameworkList,
ActionComplianceCustomLinkList,
ActionCustomDomainGet,
+ ActionCompliancePortalCommitmentGroupList,
+ ActionCompliancePortalCommitmentList,
ActionCompliancePortalUpdate,
ActionCompliancePortalNonDisclosureAgreementUpload,
ActionCompliancePortalNonDisclosureAgreementDelete,
@@ -89,5 +94,13 @@ var OAuth2ScopeMappings = map[coredata.OAuth2Scope][]string{
ActionComplianceCustomLinkDelete,
ActionCustomDomainCreate,
ActionCustomDomainDelete,
+ ActionCompliancePortalCommitmentGroupCreate,
+ ActionCompliancePortalCommitmentGroupUpdate,
+ ActionCompliancePortalCommitmentGroupUpdateRank,
+ ActionCompliancePortalCommitmentGroupDelete,
+ ActionCompliancePortalCommitmentCreate,
+ ActionCompliancePortalCommitmentUpdate,
+ ActionCompliancePortalCommitmentUpdateRank,
+ ActionCompliancePortalCommitmentDelete,
},
}
diff --git a/pkg/complianceportal/management/policies.go b/pkg/complianceportal/management/policies.go
index 5bb40bb65..c572f7e80 100644
--- a/pkg/complianceportal/management/policies.go
+++ b/pkg/complianceportal/management/policies.go
@@ -44,6 +44,7 @@ var ViewerPolicy = policy.NewPolicy(
ActionCompliancePortalReferenceList, ActionCompliancePortalReferenceGetLogoUrl,
ActionCompliancePortalCommitmentGroupList, ActionCompliancePortalCommitmentList,
ActionComplianceFrameworkList,
+ ActionComplianceCustomLinkList,
).WithSID("compliance-portal-read-access").When(organizationCondition),
).WithDescription("Read-only compliance portal access for organization viewers")
diff --git a/pkg/complianceportal/visitor/brand.go b/pkg/complianceportal/visitor/brand.go
index fca799724..e638c3712 100644
--- a/pkg/complianceportal/visitor/brand.go
+++ b/pkg/complianceportal/visitor/brand.go
@@ -14,33 +14,15 @@
package visitor
-import (
- "fmt"
- "net/url"
-)
-
const (
BrandLogoPath = "/brand/logo"
BrandDarkLogoPath = "/brand/dark-logo"
)
func BrandLogoURL(portalBaseURL string) (string, error) {
- return brandAssetURL(portalBaseURL, BrandLogoPath)
+ return portalEndpointURL(portalBaseURL, BrandLogoPath)
}
func BrandDarkLogoURL(portalBaseURL string) (string, error) {
- return brandAssetURL(portalBaseURL, BrandDarkLogoPath)
-}
-
-func brandAssetURL(portalBaseURL string, path string) (string, error) {
- parsed, err := url.Parse(portalBaseURL)
- if err != nil {
- return "", fmt.Errorf("cannot parse portal base URL: %w", err)
- }
-
- parsed.Path = path
- parsed.RawQuery = ""
- parsed.Fragment = ""
-
- return parsed.String(), nil
+ return portalEndpointURL(portalBaseURL, BrandDarkLogoPath)
}
diff --git a/pkg/complianceportal/visitor/cimd.go b/pkg/complianceportal/visitor/cimd.go
index 6f07fbc7f..add7d8969 100644
--- a/pkg/complianceportal/visitor/cimd.go
+++ b/pkg/complianceportal/visitor/cimd.go
@@ -29,38 +29,26 @@ const (
)
func CIMDClientIDURL(portalBaseURL string) (string, error) {
- parsed, err := url.Parse(portalBaseURL)
- if err != nil {
- return "", err
- }
-
- parsed.Path = CIMDMetadataPath
- parsed.RawQuery = ""
- parsed.Fragment = ""
-
- return parsed.String(), nil
+ return portalEndpointURL(portalBaseURL, CIMDMetadataPath)
}
func OAuthCallbackURL(portalBaseURL string) (string, error) {
- parsed, err := url.Parse(portalBaseURL)
- if err != nil {
- return "", err
- }
-
- parsed.Path = OAuthCallbackPath
- parsed.RawQuery = ""
- parsed.Fragment = ""
-
- return parsed.String(), nil
+ return portalEndpointURL(portalBaseURL, OAuthCallbackPath)
}
func PortalRootURL(rawURL string) (string, error) {
- parsed, err := url.Parse(rawURL)
+ return portalEndpointURL(rawURL, "")
+}
+
+// portalEndpointURL replaces the path on a portal base URL and clears
+// query/fragment. Shared by CIMD, OAuth callback, and brand asset URLs.
+func portalEndpointURL(portalBaseURL string, path string) (string, error) {
+ parsed, err := url.Parse(portalBaseURL)
if err != nil {
return "", fmt.Errorf("cannot parse portal URL: %w", err)
}
- parsed.Path = ""
+ parsed.Path = path
parsed.RawQuery = ""
parsed.Fragment = ""
diff --git a/pkg/complianceportal/visitor/compliance_portal_commitment_group_service.go b/pkg/complianceportal/visitor/compliance_portal_commitment_group_service.go
index 31078028f..a2d6d880a 100644
--- a/pkg/complianceportal/visitor/compliance_portal_commitment_group_service.go
+++ b/pkg/complianceportal/visitor/compliance_portal_commitment_group_service.go
@@ -55,28 +55,3 @@ func (s *Service) ListCommitmentGroupsForPortalID(
return page.NewPage(groups, cursor), nil
}
-
-func (s *Service) GetCommitmentGroup(
- ctx context.Context,
- scope coredata.Scoper,
- groupID gid.GID,
-) (*coredata.CompliancePortalCommitmentGroup, error) {
- group := &coredata.CompliancePortalCommitmentGroup{}
-
- 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
- },
- )
- if err != nil {
- return nil, err
- }
-
- return group, nil
-}
diff --git a/pkg/complianceportal/visitor/service.go b/pkg/complianceportal/visitor/service.go
index e6fc22dfe..df228f0cb 100644
--- a/pkg/complianceportal/visitor/service.go
+++ b/pkg/complianceportal/visitor/service.go
@@ -24,6 +24,7 @@ import (
"context"
"errors"
"fmt"
+ "net/url"
"time"
"github.com/aws/aws-sdk-go-v2/service/s3"
@@ -153,6 +154,38 @@ func (s *Service) GetPortalEffectiveCanonicalHost(ctx context.Context, complianc
return host, nil
}
+// GetPortalCanonicalBaseURL rewrites currentBaseURL to the compliance page's
+// canonical host, if one is set. OAuth client_id and redirect_uri values must
+// always be derived from the canonical base URL: the SNI middleware only
+// redirects secondary domains to the canonical host for non-well-known
+// paths, so a client_id fetched from /.well-known/oauth-client-metadata on a
+// secondary domain must already advertise the canonical redirect_uri to stay
+// consistent with what /callback uses at token exchange time. When no
+// canonical host can be determined, currentBaseURL is returned unchanged.
+func (s *Service) GetPortalCanonicalBaseURL(
+ ctx context.Context,
+ compliancePageID gid.GID,
+ currentBaseURL string,
+) (string, error) {
+ canonicalHost, err := s.GetPortalEffectiveCanonicalHost(ctx, compliancePageID)
+ if err != nil {
+ return "", fmt.Errorf("cannot resolve canonical host: %w", err)
+ }
+
+ if canonicalHost == "" {
+ return currentBaseURL, nil
+ }
+
+ parsed, err := url.Parse(currentBaseURL)
+ if err != nil {
+ return "", fmt.Errorf("cannot parse portal base URL: %w", err)
+ }
+
+ parsed.Host = canonicalHost
+
+ return parsed.String(), nil
+}
+
func (s *Service) GetPortalByDomainName(ctx context.Context, domain string) (*coredata.TrustCenter, error) {
compliancePage := &coredata.TrustCenter{}
diff --git a/pkg/coredata/cached_certificate.go b/pkg/coredata/cached_certificate.go
index 609d6a062..5046aaa27 100644
--- a/pkg/coredata/cached_certificate.go
+++ b/pkg/coredata/cached_certificate.go
@@ -170,22 +170,25 @@ WHERE
return nil
}
-// DeleteUnreferenced removes cache entries whose certificate is no longer
-// referenced by any custom domain, so deleted domains cannot keep a usable
-// TLS cache entry.
-func (cc *CachedCertificates) DeleteUnreferenced(ctx context.Context, conn pg.Querier) error {
+// DeleteWhereCertificateIDNotIn removes cache rows whose certificate is not
+// among the provided IDs. An empty keep set deletes every cache row.
+func (cc *CachedCertificates) DeleteWhereCertificateIDNotIn(
+ ctx context.Context,
+ conn pg.Querier,
+ keepCertificateIDs []gid.GID,
+) error {
q := `
DELETE FROM
cached_certificates
WHERE
- NOT EXISTS (
- SELECT 1
- FROM custom_domains
- WHERE custom_domains.certificate_id = cached_certificates.certificate_id
- )
+ NOT (certificate_id = ANY(@keep_certificate_ids::text[]))
`
- _, err := conn.Exec(ctx, q, pgx.NamedArgs{})
+ _, err := conn.Exec(
+ ctx,
+ q,
+ pgx.NamedArgs{"keep_certificate_ids": keepCertificateIDs},
+ )
if err != nil {
return fmt.Errorf("cannot delete unreferenced certificate cache: %w", err)
}
diff --git a/pkg/coredata/custom_domain.go b/pkg/coredata/custom_domain.go
index e835aafad..694fa5234 100644
--- a/pkg/coredata/custom_domain.go
+++ b/pkg/coredata/custom_domain.go
@@ -377,3 +377,32 @@ WHERE
return nil
}
+
+// LoadReferencedCertificateIDs returns certificate IDs currently linked from
+// any custom domain. Used by the certificate cache warmer to drop orphaned
+// cache rows without joining across entity tables.
+func (domains *CustomDomains) LoadReferencedCertificateIDs(
+ ctx context.Context,
+ conn pg.Querier,
+) ([]gid.GID, error) {
+ q := `
+SELECT DISTINCT
+ certificate_id
+FROM
+ custom_domains
+WHERE
+ certificate_id IS NOT NULL
+`
+
+ rows, err := conn.Query(ctx, q, pgx.NamedArgs{})
+ if err != nil {
+ return nil, fmt.Errorf("cannot query referenced certificate ids: %w", err)
+ }
+
+ certificateIDs, err := pgx.CollectRows(rows, pgx.RowTo[gid.GID])
+ if err != nil {
+ return nil, fmt.Errorf("cannot collect referenced certificate ids: %w", err)
+ }
+
+ return certificateIDs, nil
+}
diff --git a/pkg/coredata/migrations/20260706T132923Z.sql b/pkg/coredata/migrations/20260706T132923Z.sql
index 54f1239ea..522575b9e 100644
--- a/pkg/coredata/migrations/20260706T132923Z.sql
+++ b/pkg/coredata/migrations/20260706T132923Z.sql
@@ -24,6 +24,7 @@ INSERT INTO trust_centers (
tenant_id,
active,
slug,
+ search_engine_indexing,
created_at,
updated_at
)
@@ -41,6 +42,7 @@ SELECT
'\s+', '-', 'g'
)
),
+ 'NOT_INDEXABLE',
NOW(),
NOW()
FROM organizations o
diff --git a/pkg/coredata/migrations/20260709T090905Z.sql b/pkg/coredata/migrations/20260709T090905Z.sql
index da2ef60dd..2fef87264 100644
--- a/pkg/coredata/migrations/20260709T090905Z.sql
+++ b/pkg/coredata/migrations/20260709T090905Z.sql
@@ -25,7 +25,7 @@ CREATE TABLE certificates (
ssl_certificate_chain TEXT,
status custom_domain_ssl_status NOT NULL,
ssl_expires_at TIMESTAMP WITH TIME ZONE,
- ssl_retry_count INTEGER NOT NULL DEFAULT 0,
+ ssl_retry_count INTEGER NOT NULL,
ssl_last_attempt_at TIMESTAMP WITH TIME ZONE,
http_challenge_token TEXT,
http_challenge_key_auth TEXT,
@@ -100,6 +100,12 @@ SET certificate_id = cd.certificate_id
FROM custom_domains cd
WHERE cd.id = cc.custom_domain_id;
+-- Entries that could not be repointed (stale custom_domain_id, or a domain
+-- whose certificate was never migrated) are unusable cache rows; drop them
+-- rather than leaving certificate_id NULL for callers that always expect it.
+DELETE FROM cached_certificates WHERE certificate_id IS NULL;
+
+ALTER TABLE cached_certificates ALTER COLUMN certificate_id SET NOT NULL;
ALTER TABLE cached_certificates DROP COLUMN custom_domain_id;
-- Drop the certificate lifecycle columns now living on certificates.
diff --git a/pkg/coredata/migrations/20260710T121004Z.sql b/pkg/coredata/migrations/20260710T121004Z.sql
index 832f6ba4b..baac6c604 100644
--- a/pkg/coredata/migrations/20260710T121004Z.sql
+++ b/pkg/coredata/migrations/20260710T121004Z.sql
@@ -29,6 +29,19 @@ WITH pending_pages AS (
FROM trust_centers tc
WHERE tc.default_domain_id IS NULL
AND NULLIF(current_setting('probo.trust_center_base_domain', true), '') IS NOT NULL
+ -- Skip hostnames that already exist: minting a certificate or custom
+ -- domain for them would violate their unique constraints and abort
+ -- the whole migration.
+ AND NOT EXISTS (
+ SELECT 1
+ FROM certificates c
+ WHERE c.hostname = (tc.slug || '.' || current_setting('probo.trust_center_base_domain', true))::citext
+ )
+ AND NOT EXISTS (
+ SELECT 1
+ FROM custom_domains cd
+ WHERE cd.domain = (tc.slug || '.' || current_setting('probo.trust_center_base_domain', true))::citext
+ )
),
minted_certificates AS (
INSERT INTO certificates (
diff --git a/pkg/coredata/migrations/20260717T121103Z.sql b/pkg/coredata/migrations/20260717T121103Z.sql
index 5eab3dfa3..341d1d4f9 100644
--- a/pkg/coredata/migrations/20260717T121103Z.sql
+++ b/pkg/coredata/migrations/20260717T121103Z.sql
@@ -17,5 +17,5 @@
UPDATE trust_centers
SET
- slug = slug || '-' || encode(gen_random_bytes(4), 'hex'),
+ slug = slug || '-' || encode(gen_random_bytes(16), 'hex'),
updated_at = clock_timestamp();
diff --git a/pkg/crypto/jose/jose.go b/pkg/crypto/jose/jose.go
index 23df634b8..e87e0a557 100644
--- a/pkg/crypto/jose/jose.go
+++ b/pkg/crypto/jose/jose.go
@@ -32,6 +32,11 @@ import (
"strings"
)
+// minRSAModulusBits is the smallest RSA modulus size accepted for RS256
+// verification. NIST SP 800-131A and industry guidance both treat moduli
+// below 2048 bits as too weak for continued use.
+const minRSAModulusBits = 2048
+
type (
// JWK represents a JSON Web Key (RFC 7517).
JWK struct {
@@ -126,8 +131,13 @@ func RSAPublicKeyFromJWK(jwk JWK) (*rsa.PublicKey, error) {
return nil, fmt.Errorf("cannot convert jwk to rsa public key: invalid rsa exponent")
}
+ n := new(big.Int).SetBytes(nBytes)
+ if n.BitLen() < minRSAModulusBits {
+ return nil, fmt.Errorf("cannot convert jwk to rsa public key: modulus is %d bits, minimum is %d", n.BitLen(), minRSAModulusBits)
+ }
+
return &rsa.PublicKey{
- N: new(big.Int).SetBytes(nBytes),
+ N: n,
E: int(e.Int64()),
}, nil
}
@@ -159,6 +169,15 @@ func VerifyJWT(raw string, pubKey *rsa.PublicKey) ([]byte, error) {
return nil, fmt.Errorf("cannot decode jwt header: %w", err)
}
+ var headerFields map[string]json.RawMessage
+ if err := json.Unmarshal(headerJSON, &headerFields); err != nil {
+ return nil, fmt.Errorf("cannot parse jwt header: %w", err)
+ }
+
+ if _, ok := headerFields["crit"]; ok {
+ return nil, fmt.Errorf("cannot verify jwt: unsupported critical header parameter")
+ }
+
var header JWTHeader
if err := json.Unmarshal(headerJSON, &header); err != nil {
return nil, fmt.Errorf("cannot parse jwt header: %w", err)
diff --git a/pkg/filemanager/s3_test.go b/pkg/filemanager/s3_test.go
index 8497c8f38..f7c1771c1 100644
--- a/pkg/filemanager/s3_test.go
+++ b/pkg/filemanager/s3_test.go
@@ -33,6 +33,7 @@ import (
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
)
@@ -54,7 +55,7 @@ func newTestS3Service(t *testing.T, handler http.HandlerFunc) *filemanager.Servi
},
)
- return filemanager.NewService(nil, nil, s3Client)
+ return filemanager.NewService(nil, nil, s3Client, log.NewLogger(log.WithOutput(io.Discard)))
}
func TestOpenFile_StreamsBody(t *testing.T) {
diff --git a/pkg/filemanager/serve_public.go b/pkg/filemanager/serve_public.go
index 3b6e93604..9feb7108c 100644
--- a/pkg/filemanager/serve_public.go
+++ b/pkg/filemanager/serve_public.go
@@ -28,6 +28,7 @@ import (
"net/http"
"strconv"
+ "go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
@@ -62,7 +63,7 @@ func (s *Service) ServePublicFile(
obj, err := s.OpenFile(ctx, file, conds)
if err != nil {
- return err
+ return fmt.Errorf("cannot open public file: %w", err)
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
@@ -99,7 +100,12 @@ func (s *Service) ServePublicFile(
}
if _, err := io.Copy(w, obj.Body); err != nil {
- return err
+ // The response status and headers are already written at this point,
+ // so returning the error would make the caller render a JSON 500
+ // body into an already-started (and possibly partial) response.
+ // Log it and stop instead.
+ s.logger.ErrorCtx(ctx, "cannot stream public file", log.Error(err), log.String("file_id", fileID.String()))
+ return nil
}
return nil
diff --git a/pkg/filemanager/service.go b/pkg/filemanager/service.go
index bf5dc15ea..12fbe0a73 100644
--- a/pkg/filemanager/service.go
+++ b/pkg/filemanager/service.go
@@ -22,6 +22,7 @@ package filemanager
import (
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
+ "go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/baseurl"
)
@@ -30,16 +31,19 @@ type Service struct {
pg *pg.Client
baseURL *baseurl.BaseURL
s3Client *awss3.Client
+ logger *log.Logger
}
func NewService(
pgClient *pg.Client,
baseURL *baseurl.BaseURL,
s3Client *awss3.Client,
+ logger *log.Logger,
) *Service {
return &Service{
pg: pgClient,
baseURL: baseURL,
s3Client: s3Client,
+ logger: logger,
}
}
diff --git a/pkg/filemanager/url_test.go b/pkg/filemanager/url_test.go
index d2d10d78b..66aa36641 100644
--- a/pkg/filemanager/url_test.go
+++ b/pkg/filemanager/url_test.go
@@ -22,6 +22,7 @@ package filemanager_test
import (
"context"
+ "io"
"net/url"
"testing"
"time"
@@ -31,6 +32,7 @@ import (
awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/filemanager"
@@ -45,7 +47,7 @@ func TestGenerateFileURL_PublicFile(t *testing.T) {
t.Fatalf("cannot parse base URL: %v", err)
}
- svc := filemanager.NewService(nil, base, nil)
+ svc := filemanager.NewService(nil, base, nil, log.NewLogger(log.WithOutput(io.Discard)))
file := &coredata.File{
ID: gid.New(gid.NilTenant, coredata.FileEntityType),
Visibility: coredata.FileVisibilityPublic,
@@ -66,7 +68,7 @@ func TestGenerateFileURL_PrivateFile(t *testing.T) {
t.Fatalf("cannot parse base URL: %v", err)
}
- svc := filemanager.NewService(nil, base, nil)
+ svc := filemanager.NewService(nil, base, nil, log.NewLogger(log.WithOutput(io.Discard)))
file := &coredata.File{
ID: gid.New(gid.NilTenant, coredata.FileEntityType),
Visibility: coredata.FileVisibilityPrivate,
@@ -88,7 +90,7 @@ func TestGeneratePresignedURL_EscapesContentDispositionFilename(t *testing.T) {
Credentials: credentials.NewStaticCredentialsProvider("access-key", "secret-key", ""),
},
)
- svc := filemanager.NewService(nil, nil, s3Client)
+ svc := filemanager.NewService(nil, nil, s3Client, log.NewLogger(log.WithOutput(io.Discard)))
file := &coredata.File{
BucketName: "uploads",
FileKey: "tenant/file",
diff --git a/pkg/iam/auth_service.go b/pkg/iam/auth_service.go
index ee3945a20..15542bab5 100644
--- a/pkg/iam/auth_service.go
+++ b/pkg/iam/auth_service.go
@@ -580,6 +580,19 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
return fmt.Errorf("cannot generate magic link token: %w", err)
}
+ senderName := magicLinkDefaultSenderName
+
+ if req.OAuth2ClientIDRaw != nil && *req.OAuth2ClientIDRaw != "" {
+ branding, err := s.OAuth2ServerService.ClientBranding(ctx, *req.OAuth2ClientIDRaw)
+ if err != nil {
+ return fmt.Errorf("cannot load oauth2 client branding: %w", err)
+ }
+
+ if branding != nil {
+ senderName = branding.Name
+ }
+ }
+
return s.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
@@ -596,7 +609,6 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
fullName := req.Email.Username()
identity := &coredata.Identity{}
- senderName := magicLinkDefaultSenderName
if err := identity.LoadByEmail(ctx, tx, req.Email); err == nil {
if identity.FullName != "" {
@@ -608,17 +620,6 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
}
}
- if req.OAuth2ClientIDRaw != nil && *req.OAuth2ClientIDRaw != "" {
- branding, err := s.OAuth2ServerService.ClientBranding(ctx, *req.OAuth2ClientIDRaw)
- if err != nil {
- return fmt.Errorf("cannot load oauth2 client branding: %w", err)
- }
-
- if branding != nil {
- senderName = branding.Name
- }
- }
-
emailPresenterCfg := emails.DefaultPresenterConfig(s.baseURL)
if req.MagicLinkBaseURL != nil {
diff --git a/pkg/iam/oauth2/id_token.go b/pkg/iam/oauth2/id_token.go
index 8ad58db5d..66a29f2e3 100644
--- a/pkg/iam/oauth2/id_token.go
+++ b/pkg/iam/oauth2/id_token.go
@@ -26,7 +26,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
- "strings"
"time"
"go.probo.inc/probo/pkg/coredata"
@@ -117,25 +116,6 @@ func NewIDTokenClaims(
return claims
}
-func ParseIDTokenClaims(raw string) (*IDTokenClaims, error) {
- parts := strings.Split(raw, ".")
- if len(parts) != 3 {
- return nil, fmt.Errorf("cannot parse id token: invalid format")
- }
-
- payload, err := base64.RawURLEncoding.DecodeString(parts[1])
- if err != nil {
- return nil, fmt.Errorf("cannot parse id token payload: %w", err)
- }
-
- var claims IDTokenClaims
- if err := json.Unmarshal(payload, &claims); err != nil {
- return nil, fmt.Errorf("cannot decode id token claims: %w", err)
- }
-
- return &claims, nil
-}
-
func ParseIDTokenIdentity(
raw string,
jwks *jose.JWKS,
diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go
index f11d7d0d0..122faacd6 100644
--- a/pkg/iam/organization_service.go
+++ b/pkg/iam/organization_service.go
@@ -756,28 +756,34 @@ func (s *OrganizationService) CreateOrganization(
return fmt.Errorf("cannot insert mailing list: %w", err)
}
- defaultDomainHostname := trustCenter.Slug + "." + s.trustCenterBaseDomain
+ // Self-managed installs without a configured base domain don't get
+ // a default managed domain: there is no suffix to mint a
+ // "{slug}." hostname from, so the compliance page stays without
+ // a domain until the organization adds a custom one.
+ if s.trustCenterBaseDomain != "" {
+ defaultDomainHostname := trustCenter.Slug + "." + s.trustCenterBaseDomain
- defaultDomain := coredata.NewCustomDomain(
- tenantID,
- organization.ID,
- defaultDomainHostname,
- true,
- )
+ defaultDomain := coredata.NewCustomDomain(
+ tenantID,
+ organization.ID,
+ defaultDomainHostname,
+ true,
+ )
- certificate, err := s.certManager.EnsureCertificate(ctx, tx, scope, defaultDomainHostname)
- if err != nil {
- return fmt.Errorf("cannot ensure certificate for default custom domain: %w", err)
+ certificate, err := s.certManager.EnsureCertificate(ctx, tx, scope, defaultDomainHostname)
+ if err != nil {
+ return fmt.Errorf("cannot ensure certificate for default custom domain: %w", err)
+ }
+
+ defaultDomain.CertificateID = &certificate.ID
+
+ if err := defaultDomain.Insert(ctx, tx, scope); err != nil {
+ return fmt.Errorf("cannot insert default custom domain: %w", err)
+ }
+
+ trustCenter.DefaultDomainID = &defaultDomain.ID
}
- defaultDomain.CertificateID = &certificate.ID
-
- if err := defaultDomain.Insert(ctx, tx, scope); err != nil {
- return fmt.Errorf("cannot insert default custom domain: %w", err)
- }
-
- trustCenter.DefaultDomainID = &defaultDomain.ID
-
if err := trustCenter.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert trust center: %w", err)
}
diff --git a/pkg/iam/service.go b/pkg/iam/service.go
index b3cfc7f76..00d8afdeb 100644
--- a/pkg/iam/service.go
+++ b/pkg/iam/service.go
@@ -62,7 +62,6 @@ type (
magicLinkTokenValidity time.Duration
sessionDuration time.Duration
bucket string
- encryptionKey cipher.EncryptionKey
trustCenterBaseDomain string
certManager *certmanager.Service
certificate *x509.Certificate
@@ -150,6 +149,10 @@ func NewService(
return nil, fmt.Errorf("oauth2 scope registry is required")
}
+ if cfg.CertManager == nil {
+ return nil, fmt.Errorf("cert manager is required")
+ }
+
svc := &Service{
pg: pgClient,
fm: fm,
@@ -163,7 +166,6 @@ func NewService(
magicLinkTokenValidity: cfg.MagicLinkTokenValidity,
sessionDuration: cfg.SessionDuration,
bucket: cfg.Bucket,
- encryptionKey: cfg.EncryptionKey,
trustCenterBaseDomain: cfg.TrustCenterBaseDomain,
certManager: cfg.CertManager,
certificate: cfg.Certificate,
diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go
index 7fa7482f6..1c6bb41cc 100644
--- a/pkg/probod/probod.go
+++ b/pkg/probod/probod.go
@@ -378,7 +378,7 @@ func (impl *Implm) Run(
return err
}
- fileManagerService := filemanager.NewService(pgClient, baseURL, s3Client)
+ fileManagerService := filemanager.NewService(pgClient, baseURL, s3Client, l)
commonThirdPartyEnrichmentCfg, err := impl.buildCommonThirdPartyEnrichmentConfig(l, tp, r, fileManagerService)
if err != nil {
diff --git a/pkg/server/api/complianceportal/v1/auth_resolvers.go b/pkg/server/api/complianceportal/v1/auth_resolvers.go
index c657be49b..67d521d7c 100644
--- a/pkg/server/api/complianceportal/v1/auth_resolvers.go
+++ b/pkg/server/api/complianceportal/v1/auth_resolvers.go
@@ -16,6 +16,7 @@ import (
"go.probo.inc/probo/pkg/server/api/complianceportal"
"go.probo.inc/probo/pkg/server/api/complianceportal/v1/types"
"go.probo.inc/probo/pkg/server/gqlutils"
+ "go.probo.inc/probo/pkg/validator"
)
// UpdateFullName is the resolver for the updateFullName field.
@@ -25,43 +26,66 @@ func (r *mutationResolver) UpdateFullName(ctx context.Context, input types.Updat
return nil, gqlutils.Unauthenticatedf(ctx, "authentication is required to request access")
}
- identity, err := r.iam.AccountService.UpdateIdentity(
- ctx,
- identity.ID,
- &iam.UpdateIdentityRequest{
- FullName: input.FullName,
- },
- )
- if err != nil {
- r.logger.ErrorCtx(ctx, "cannot update identity", log.Error(err))
- return nil, gqlutils.Internal(ctx)
- }
-
compliancePage := complianceportal.CompliancePageFromContext(ctx)
profile, err := r.iam.OrganizationService.GetProfileForIdentityAndOrganization(ctx, identity.ID, compliancePage.OrganizationID)
if err != nil {
- // External trust-center visitors have no organization profile; updating
- // the identity's full name above is all that is needed for them.
- if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); ok {
- return &types.UpdateFullNamePayload{Success: true}, nil
+ // External trust-center visitors have no organization profile; only
+ // their identity needs updating.
+ if _, ok := errors.AsType[*iam.ErrProfileNotFound](err); !ok {
+ r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err))
+ return nil, gqlutils.Internal(ctx)
}
- r.logger.ErrorCtx(ctx, "cannot get profile", log.Error(err))
-
- return nil, gqlutils.Internal(ctx)
+ profile = nil
}
- if profile.Source == coredata.ProfileSourceManual {
- if _, err := r.iam.OrganizationService.UpdateUser(ctx, &iam.UpdateUserRequest{
+ // The identity and profile full names are validated by different rules.
+ // Validate the profile update up front so it cannot fail after the
+ // identity has already been mutated, keeping the two in sync.
+ var updateUserRequest *iam.UpdateUserRequest
+ if profile != nil && profile.Source == coredata.ProfileSourceManual {
+ updateUserRequest = &iam.UpdateUserRequest{
ID: profile.ID,
- FullName: identity.FullName,
+ FullName: input.FullName,
AdditionalEmailAddresses: profile.AdditionalEmailAddresses,
Kind: profile.Kind,
Position: profile.Position,
ContractStartDate: &profile.ContractStartDate,
ContractEndDate: &profile.ContractEndDate,
- }); err != nil {
+ }
+
+ if err := updateUserRequest.Validate(); err != nil {
+ if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
+ return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
+ }
+
+ r.logger.ErrorCtx(ctx, "cannot validate profile update", log.Error(err))
+ return nil, gqlutils.Internal(ctx)
+ }
+ }
+
+ if _, err := r.iam.AccountService.UpdateIdentity(
+ ctx,
+ identity.ID,
+ &iam.UpdateIdentityRequest{
+ FullName: input.FullName,
+ },
+ ); err != nil {
+ if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
+ return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
+ }
+
+ r.logger.ErrorCtx(ctx, "cannot update identity", log.Error(err))
+ return nil, gqlutils.Internal(ctx)
+ }
+
+ if updateUserRequest != nil {
+ if _, err := r.iam.OrganizationService.UpdateUser(ctx, updateUserRequest); err != nil {
+ if validationErrors, ok := errors.AsType[validator.ValidationErrors](err); ok {
+ return nil, gqlutils.InvalidValidationErrors(ctx, validationErrors)
+ }
+
r.logger.ErrorCtx(ctx, "cannot update profile", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
diff --git a/pkg/server/api/complianceportal/v1/mux.go b/pkg/server/api/complianceportal/v1/mux.go
index 19b00c192..edfde13f8 100644
--- a/pkg/server/api/complianceportal/v1/mux.go
+++ b/pkg/server/api/complianceportal/v1/mux.go
@@ -104,7 +104,7 @@ func NewMux(cfg MuxConfig) (http.Handler, error) {
func(r chi.Router) {
r.Use(complianceportal.NewCompliancePagePresenceMiddleware())
- r.Method(http.MethodGet, complianceportal.CIMDMetadataPath, NewOAuthClientMetadataHandler())
+ r.Method(http.MethodGet, complianceportal.CIMDMetadataPath, NewOAuthClientMetadataHandler(cfg.Visitor))
r.Method(http.MethodGet, complianceportal.BrandLogoPath, NewBrandLogoHandler(cfg.Logger, cfg.File))
r.Method(http.MethodGet, complianceportal.BrandDarkLogoPath, NewBrandDarkLogoHandler(cfg.Logger, cfg.File))
r.Method(http.MethodGet, complianceportal.OAuthInitiatePath, oauthInitiateHandler)
diff --git a/pkg/server/api/complianceportal/v1/oauth_callback_handler.go b/pkg/server/api/complianceportal/v1/oauth_callback_handler.go
index fa6ab1ce3..145bd799a 100644
--- a/pkg/server/api/complianceportal/v1/oauth_callback_handler.go
+++ b/pkg/server/api/complianceportal/v1/oauth_callback_handler.go
@@ -100,7 +100,15 @@ func (h *OAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
- clientID, err := complianceportal.CIMDClientIDURL(*portalBaseURL)
+ canonicalBaseURL, err := h.visitor.GetPortalCanonicalBaseURL(ctx, portal.ID, *portalBaseURL)
+ if err != nil {
+ h.logger.ErrorCtx(ctx, "cannot resolve canonical portal base URL", log.Error(err))
+ httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
+
+ return
+ }
+
+ clientID, err := complianceportal.CIMDClientIDURL(canonicalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
@@ -108,7 +116,7 @@ func (h *OAuthCallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
- redirectURI, err := complianceportal.OAuthCallbackURL(*portalBaseURL)
+ redirectURI, err := complianceportal.OAuthCallbackURL(canonicalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
diff --git a/pkg/server/api/complianceportal/v1/oauth_client_metadata_handler.go b/pkg/server/api/complianceportal/v1/oauth_client_metadata_handler.go
index 1e7cdb1f0..b1cfbc2c9 100644
--- a/pkg/server/api/complianceportal/v1/oauth_client_metadata_handler.go
+++ b/pkg/server/api/complianceportal/v1/oauth_client_metadata_handler.go
@@ -23,22 +23,32 @@ import (
"go.probo.inc/probo/pkg/server/api/complianceportal"
)
-type oauthClientMetadataHandler struct{}
+type oauthClientMetadataHandler struct {
+ visitor *visitor.Service
+}
-func NewOAuthClientMetadataHandler() http.Handler {
- return &oauthClientMetadataHandler{}
+func NewOAuthClientMetadataHandler(visitorSvc *visitor.Service) http.Handler {
+ return &oauthClientMetadataHandler{visitor: visitorSvc}
}
func (h *oauthClientMetadataHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- compliancePage := complianceportal.CompliancePageFromContext(r.Context())
- baseURL := complianceportal.CompliancePageBaseURLFromContext(r.Context())
+ ctx := r.Context()
+
+ compliancePage := complianceportal.CompliancePageFromContext(ctx)
+ baseURL := complianceportal.CompliancePageBaseURLFromContext(ctx)
if compliancePage == nil || baseURL == nil {
httpserver.RenderError(w, http.StatusNotFound, errNotFound)
return
}
- doc, err := visitor.BuildClientMetadataDocument(compliancePage, *baseURL)
+ canonicalBaseURL, err := h.visitor.GetPortalCanonicalBaseURL(ctx, compliancePage.ID, *baseURL)
+ if err != nil {
+ httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
+ return
+ }
+
+ doc, err := visitor.BuildClientMetadataDocument(compliancePage, canonicalBaseURL)
if err != nil {
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
return
diff --git a/pkg/server/api/complianceportal/v1/oauth_initiate_handler.go b/pkg/server/api/complianceportal/v1/oauth_initiate_handler.go
index 963c5cadb..4ba6ef984 100644
--- a/pkg/server/api/complianceportal/v1/oauth_initiate_handler.go
+++ b/pkg/server/api/complianceportal/v1/oauth_initiate_handler.go
@@ -58,8 +58,9 @@ func NewOAuthInitiateHandler(
func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
+ compliancePage := complianceportal.CompliancePageFromContext(ctx)
portalBaseURL := complianceportal.CompliancePageBaseURLFromContext(ctx)
- if portalBaseURL == nil {
+ if compliancePage == nil || portalBaseURL == nil {
httpserver.RenderError(w, http.StatusNotFound, errNotFound)
return
}
@@ -75,7 +76,15 @@ func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
- clientID, err := complianceportal.CIMDClientIDURL(*portalBaseURL)
+ canonicalBaseURL, err := h.visitor.GetPortalCanonicalBaseURL(ctx, compliancePage.ID, *portalBaseURL)
+ if err != nil {
+ h.logger.ErrorCtx(ctx, "cannot resolve canonical portal base URL", log.Error(err))
+ httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
+
+ return
+ }
+
+ clientID, err := complianceportal.CIMDClientIDURL(canonicalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot build cimd client_id", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
@@ -83,7 +92,7 @@ func (h *OAuthInitiateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
- redirectURI, err := complianceportal.OAuthCallbackURL(*portalBaseURL)
+ redirectURI, err := complianceportal.OAuthCallbackURL(canonicalBaseURL)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot build oauth redirect_uri", log.Error(err))
httpserver.RenderError(w, http.StatusInternalServerError, errInternal)
diff --git a/pkg/server/api/connect/v1/graphql/base.graphql b/pkg/server/api/connect/v1/graphql/base.graphql
index b808e69b4..73d92e757 100644
--- a/pkg/server/api/connect/v1/graphql/base.graphql
+++ b/pkg/server/api/connect/v1/graphql/base.graphql
@@ -45,7 +45,7 @@ type Query {
type OAuthClientBranding {
name: String!
- logo: File
+ logoUrl: String
clientURL: String
}
diff --git a/pkg/server/api/connect/v1/graphql_handler.go b/pkg/server/api/connect/v1/graphql_handler.go
index a556d9685..e7140cf73 100644
--- a/pkg/server/api/connect/v1/graphql_handler.go
+++ b/pkg/server/api/connect/v1/graphql_handler.go
@@ -25,7 +25,6 @@ import (
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/baseurl"
- "go.probo.inc/probo/pkg/complianceportal/visitor"
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/securecookie"
@@ -39,7 +38,6 @@ import (
func NewGraphQLHandler(
svc *iam.Service,
- trustSvc *visitor.Service,
logger *log.Logger,
fileManagerSvc *filemanager.Service,
baseURL *baseurl.BaseURL,
@@ -52,7 +50,6 @@ func NewGraphQLHandler(
batchAuthorize: authz.NewBatchAuthorizeFunc(svc, logger),
logger: logger,
iam: svc,
- trust: trustSvc,
scopeRegistry: svc.OAuth2ScopeRegistry,
fileManager: fileManagerSvc,
baseURL: baseURL,
diff --git a/pkg/server/api/connect/v1/oauth_client_branding.go b/pkg/server/api/connect/v1/oauth_client_branding.go
index 213f534c5..d6cb55cbc 100644
--- a/pkg/server/api/connect/v1/oauth_client_branding.go
+++ b/pkg/server/api/connect/v1/oauth_client_branding.go
@@ -47,9 +47,7 @@ func oauthClientBrandingFromIAM(
}
if branding.LogoURL != nil {
- result.Logo = &types.File{
- DownloadURL: *branding.LogoURL,
- }
+ result.LogoURL = branding.LogoURL
}
return result, nil
diff --git a/pkg/server/api/connect/v1/resolver.go b/pkg/server/api/connect/v1/resolver.go
index 3f2e2e22c..e3e69c8ec 100644
--- a/pkg/server/api/connect/v1/resolver.go
+++ b/pkg/server/api/connect/v1/resolver.go
@@ -68,7 +68,6 @@ type (
batchAuthorize authz.BatchAuthorizeFunc
logger *log.Logger
iam *iam.Service
- trust *visitor.Service
scopeRegistry *oauth2scope.Registry
fileManager *filemanager.Service
baseURL *baseurl.BaseURL
@@ -93,7 +92,7 @@ func NewMux(
apiKeyMiddleware := authn.NewAPIKeyMiddleware(svc, tokenSecret)
oauth2Middleware := authn.NewOAuth2AccessTokenMiddleware(svc)
identityPresenceMiddleware := authn.NewIdentityPresenceMiddleware(baseURL)
- graphqlHandler := NewGraphQLHandler(svc, trustSvc, logger, fileManagerSvc, baseURL, cookieConfig, graphqlLimits)
+ graphqlHandler := NewGraphQLHandler(svc, logger, fileManagerSvc, baseURL, cookieConfig, graphqlLimits)
samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger)
scimHandler := NewSCIMHandler(svc, logger.Named("scim"))
diff --git a/pkg/server/response_headers.go b/pkg/server/response_headers.go
index a9a78c6ea..d4b0ec724 100644
--- a/pkg/server/response_headers.go
+++ b/pkg/server/response_headers.go
@@ -1,16 +1,22 @@
// Copyright (c) 2026 Probo Inc .
//
-// Permission to use, copy, modify, and/or distribute this software for any
-// purpose with or without fee is hereby granted, provided that the above
-// copyright notice and this permission notice appear in all copies.
+// 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 SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-// PERFORMANCE OF THIS SOFTWARE.
+// 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.
package server
diff --git a/pkg/slug/slug.go b/pkg/slug/slug.go
index 6fc09fd7a..1394235dc 100644
--- a/pkg/slug/slug.go
+++ b/pkg/slug/slug.go
@@ -46,6 +46,8 @@ func Make(s string) string {
}
func MakeWithEntropy(s string) string {
+ const maxDNSLabel = 63
+
base := Make(s)
suffix := rand.MustHexString(4)
@@ -53,5 +55,18 @@ func MakeWithEntropy(s string) string {
return suffix
}
+ // DNS labels are capped at 63 octets. Keep the entropy suffix and
+ // truncate the name-derived prefix so hostnames stay provisionable.
+ maxBase := maxDNSLabel - 1 - len(suffix)
+ if maxBase < 1 {
+ return suffix
+ }
+ if len(base) > maxBase {
+ base = strings.Trim(base[:maxBase], "-")
+ if base == "" {
+ return suffix
+ }
+ }
+
return base + "-" + suffix
}
diff --git a/pkg/slug/slug_test.go b/pkg/slug/slug_test.go
index dbe9ca95d..b7c8eb308 100644
--- a/pkg/slug/slug_test.go
+++ b/pkg/slug/slug_test.go
@@ -21,6 +21,7 @@
package slug
import (
+ "strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -91,4 +92,15 @@ func TestMakeWithEntropy(t *testing.T) {
assert.NotEqual(t, first, second, "MakeWithEntropy should produce distinct slugs")
},
)
+
+ t.Run(
+ "long names stay within dns label length",
+ func(t *testing.T) {
+ t.Parallel()
+
+ got := MakeWithEntropy(strings.Repeat("Very Long Organization Name ", 10))
+ assert.LessOrEqual(t, len(got), 63)
+ assert.Regexp(t, `^[a-z0-9-]+-[0-9a-f]{8}$`, got)
+ },
+ )
}