@@ -199,3 +199,4 @@ compose/pebble/certs/rootCA.pem:
|
|||||||
localhost 127.0.0.1 ::1 pebble
|
localhost 127.0.0.1 ::1 pebble
|
||||||
$(CP) "$$($(MKCERT) -CAROOT)/rootCA.pem" compose/pebble/certs/rootCA.pem
|
$(CP) "$$($(MKCERT) -CAROOT)/rootCA.pem" compose/pebble/certs/rootCA.pem
|
||||||
$(CP) "$$($(MKCERT) -CAROOT)/rootCA-key.pem" compose/pebble/certs/rootCA-key.pem
|
$(CP) "$$($(MKCERT) -CAROOT)/rootCA-key.pem" compose/pebble/certs/rootCA-key.pem
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useLocation, useRouteError } from "react-router";
|
|||||||
import { IconPageCross } from "@probo/ui";
|
import { IconPageCross } from "@probo/ui";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { AuthenticationRequiredError } from "/providers/RelayProviders";
|
||||||
|
|
||||||
const classNames = {
|
const classNames = {
|
||||||
wrapper: "py-10 text-center space-y-2 ",
|
wrapper: "py-10 text-center space-y-2 ",
|
||||||
@@ -32,6 +33,27 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
|
|||||||
}
|
}
|
||||||
}, [location, resetErrorBoundary]);
|
}, [location, resetErrorBoundary]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error instanceof AuthenticationRequiredError) {
|
||||||
|
window.location.href = error.redirectUrl;
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
if (error instanceof AuthenticationRequiredError) {
|
||||||
|
return (
|
||||||
|
<div className={classNames.wrapper}>
|
||||||
|
<h1 className={classNames.title}>
|
||||||
|
{__("Additional authentication required")}
|
||||||
|
</h1>
|
||||||
|
<p className={classNames.description}>
|
||||||
|
{error.requiresSaml
|
||||||
|
? __("Redirecting to SAML authentication...")
|
||||||
|
: __("Redirecting to login...")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!error || (error && error.toString().includes("PAGE_NOT_FOUND"))) {
|
if (!error || (error && error.toString().includes("PAGE_NOT_FOUND"))) {
|
||||||
return (
|
return (
|
||||||
<div className={classNames.wrapper}>
|
<div className={classNames.wrapper}>
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ export const organizationViewQuery = graphql`
|
|||||||
id
|
id
|
||||||
name
|
name
|
||||||
...SettingsPageFragment
|
...SettingsPageFragment
|
||||||
...SettingsPageMembershipsFragment
|
|
||||||
...SettingsPageInvitationsFragment
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
205
apps/console/src/hooks/graph/SAMLConfigurationGraph.ts
Normal file
205
apps/console/src/hooks/graph/SAMLConfigurationGraph.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { graphql } from "relay-runtime";
|
||||||
|
import { useMutationWithToasts } from "../useMutationWithToasts";
|
||||||
|
import type { SAMLConfigurationGraphCreateMutation } from "./__generated__/SAMLConfigurationGraphCreateMutation.graphql";
|
||||||
|
import type { SAMLConfigurationGraphUpdateMutation } from "./__generated__/SAMLConfigurationGraphUpdateMutation.graphql";
|
||||||
|
import type { SAMLConfigurationGraphDeleteMutation } from "./__generated__/SAMLConfigurationGraphDeleteMutation.graphql";
|
||||||
|
import type { SAMLConfigurationGraphEnableMutation } from "./__generated__/SAMLConfigurationGraphEnableMutation.graphql";
|
||||||
|
import type { SAMLConfigurationGraphDisableMutation } from "./__generated__/SAMLConfigurationGraphDisableMutation.graphql";
|
||||||
|
import type { SAMLConfigurationGraphInitiateDomainVerificationMutation } from "./__generated__/SAMLConfigurationGraphInitiateDomainVerificationMutation.graphql";
|
||||||
|
import type { SAMLConfigurationGraphVerifyDomainMutation } from "./__generated__/SAMLConfigurationGraphVerifyDomainMutation.graphql";
|
||||||
|
|
||||||
|
const createSAMLConfigurationMutation = graphql`
|
||||||
|
mutation SAMLConfigurationGraphCreateMutation(
|
||||||
|
$input: CreateSAMLConfigurationInput!
|
||||||
|
) {
|
||||||
|
createSAMLConfiguration(input: $input) {
|
||||||
|
samlConfiguration {
|
||||||
|
id
|
||||||
|
enabled
|
||||||
|
emailDomain
|
||||||
|
enforcementPolicy
|
||||||
|
spEntityId
|
||||||
|
spAcsUrl
|
||||||
|
spMetadataUrl
|
||||||
|
testLoginUrl
|
||||||
|
idpEntityId
|
||||||
|
idpSsoUrl
|
||||||
|
idpCertificate
|
||||||
|
idpMetadataUrl
|
||||||
|
attributeEmail
|
||||||
|
attributeFirstname
|
||||||
|
attributeLastname
|
||||||
|
attributeRole
|
||||||
|
defaultRole
|
||||||
|
autoSignupEnabled
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const updateSAMLConfigurationMutation = graphql`
|
||||||
|
mutation SAMLConfigurationGraphUpdateMutation(
|
||||||
|
$input: UpdateSAMLConfigurationInput!
|
||||||
|
) {
|
||||||
|
updateSAMLConfiguration(input: $input) {
|
||||||
|
samlConfiguration {
|
||||||
|
id
|
||||||
|
enabled
|
||||||
|
emailDomain
|
||||||
|
enforcementPolicy
|
||||||
|
spEntityId
|
||||||
|
spAcsUrl
|
||||||
|
spMetadataUrl
|
||||||
|
testLoginUrl
|
||||||
|
idpEntityId
|
||||||
|
idpSsoUrl
|
||||||
|
idpCertificate
|
||||||
|
idpMetadataUrl
|
||||||
|
attributeEmail
|
||||||
|
attributeFirstname
|
||||||
|
attributeLastname
|
||||||
|
attributeRole
|
||||||
|
defaultRole
|
||||||
|
autoSignupEnabled
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const deleteSAMLConfigurationMutation = graphql`
|
||||||
|
mutation SAMLConfigurationGraphDeleteMutation(
|
||||||
|
$input: DeleteSAMLConfigurationInput!
|
||||||
|
) {
|
||||||
|
deleteSAMLConfiguration(input: $input) {
|
||||||
|
deletedSAMLConfigurationId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const enableSAMLMutation = graphql`
|
||||||
|
mutation SAMLConfigurationGraphEnableMutation($input: EnableSAMLInput!) {
|
||||||
|
enableSAML(input: $input) {
|
||||||
|
samlConfiguration {
|
||||||
|
id
|
||||||
|
enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const disableSAMLMutation = graphql`
|
||||||
|
mutation SAMLConfigurationGraphDisableMutation($input: DisableSAMLInput!) {
|
||||||
|
disableSAML(input: $input) {
|
||||||
|
samlConfiguration {
|
||||||
|
id
|
||||||
|
enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const initiateDomainVerificationMutation = graphql`
|
||||||
|
mutation SAMLConfigurationGraphInitiateDomainVerificationMutation(
|
||||||
|
$input: InitiateDomainVerificationInput!
|
||||||
|
) {
|
||||||
|
initiateDomainVerification(input: $input) {
|
||||||
|
samlConfiguration {
|
||||||
|
id
|
||||||
|
emailDomain
|
||||||
|
domainVerified
|
||||||
|
domainVerificationToken
|
||||||
|
domainVerifiedAt
|
||||||
|
}
|
||||||
|
dnsRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const verifyDomainMutation = graphql`
|
||||||
|
mutation SAMLConfigurationGraphVerifyDomainMutation(
|
||||||
|
$input: VerifyDomainInput!
|
||||||
|
) {
|
||||||
|
verifyDomain(input: $input) {
|
||||||
|
samlConfiguration {
|
||||||
|
id
|
||||||
|
domainVerified
|
||||||
|
domainVerifiedAt
|
||||||
|
}
|
||||||
|
verified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function useCreateSAMLConfigurationMutation() {
|
||||||
|
return useMutationWithToasts<SAMLConfigurationGraphCreateMutation>(
|
||||||
|
createSAMLConfigurationMutation,
|
||||||
|
{
|
||||||
|
successMessage: "SAML configuration created successfully.",
|
||||||
|
errorMessage: "Failed to create SAML configuration. Please try again.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSAMLConfigurationMutation() {
|
||||||
|
return useMutationWithToasts<SAMLConfigurationGraphUpdateMutation>(
|
||||||
|
updateSAMLConfigurationMutation,
|
||||||
|
{
|
||||||
|
successMessage: "SAML configuration updated successfully.",
|
||||||
|
errorMessage: "Failed to update SAML configuration. Please try again.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteSAMLConfigurationMutation() {
|
||||||
|
return useMutationWithToasts<SAMLConfigurationGraphDeleteMutation>(
|
||||||
|
deleteSAMLConfigurationMutation,
|
||||||
|
{
|
||||||
|
successMessage: "SAML configuration deleted successfully.",
|
||||||
|
errorMessage: "Failed to delete SAML configuration. Please try again.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEnableSAMLMutation() {
|
||||||
|
return useMutationWithToasts<SAMLConfigurationGraphEnableMutation>(
|
||||||
|
enableSAMLMutation,
|
||||||
|
{
|
||||||
|
successMessage: "SAML enabled successfully.",
|
||||||
|
errorMessage: "Failed to enable SAML. Please try again.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDisableSAMLMutation() {
|
||||||
|
return useMutationWithToasts<SAMLConfigurationGraphDisableMutation>(
|
||||||
|
disableSAMLMutation,
|
||||||
|
{
|
||||||
|
successMessage: "SAML disabled successfully.",
|
||||||
|
errorMessage: "Failed to disable SAML. Please try again.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInitiateDomainVerificationMutation() {
|
||||||
|
return useMutationWithToasts<SAMLConfigurationGraphInitiateDomainVerificationMutation>(
|
||||||
|
initiateDomainVerificationMutation,
|
||||||
|
{
|
||||||
|
successMessage: "Domain verification initiated. Please add the DNS record.",
|
||||||
|
errorMessage: "Failed to initiate domain verification. Please try again.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVerifyDomainMutation() {
|
||||||
|
return useMutationWithToasts<SAMLConfigurationGraphVerifyDomainMutation>(
|
||||||
|
verifyDomainMutation,
|
||||||
|
{
|
||||||
|
successMessage: "Domain verified successfully!",
|
||||||
|
errorMessage: "Domain verification failed. Please ensure the DNS record is properly configured.",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<e961390ac138b066af89b5b02169d66e>>
|
* @generated SignedSource<<220f85396d6d4c5c04f730d07aaa42e1>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +17,7 @@ export type OrganizationGraph_ViewQuery$data = {
|
|||||||
readonly node: {
|
readonly node: {
|
||||||
readonly id?: string;
|
readonly id?: string;
|
||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageFragment" | "SettingsPageInvitationsFragment" | "SettingsPageMembershipsFragment">;
|
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageFragment">;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type OrganizationGraph_ViewQuery = {
|
export type OrganizationGraph_ViewQuery = {
|
||||||
@@ -82,52 +82,50 @@ v7 = {
|
|||||||
"name": "updatedAt",
|
"name": "updatedAt",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v8 = {
|
v8 = [
|
||||||
"kind": "Literal",
|
{
|
||||||
"name": "first",
|
"kind": "Literal",
|
||||||
"value": 20
|
"name": "first",
|
||||||
},
|
"value": 20
|
||||||
v9 = {
|
},
|
||||||
"kind": "Literal",
|
{
|
||||||
"name": "orderBy",
|
"kind": "Literal",
|
||||||
"value": {
|
"name": "orderBy",
|
||||||
"direction": "ASC",
|
"value": {
|
||||||
"field": "CREATED_AT"
|
"direction": "ASC",
|
||||||
|
"field": "CREATED_AT"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
v10 = [
|
|
||||||
(v8/*: any*/),
|
|
||||||
(v9/*: any*/)
|
|
||||||
],
|
],
|
||||||
v11 = {
|
v9 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "totalCount",
|
"name": "totalCount",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v12 = {
|
v10 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "fullName",
|
"name": "fullName",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v13 = {
|
v11 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "role",
|
"name": "role",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v14 = {
|
v12 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "cursor",
|
"name": "cursor",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v15 = {
|
v13 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"concreteType": "PageInfo",
|
"concreteType": "PageInfo",
|
||||||
@@ -166,7 +164,7 @@ v15 = {
|
|||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v16 = {
|
v14 = {
|
||||||
"kind": "ClientExtension",
|
"kind": "ClientExtension",
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
@@ -178,19 +176,8 @@ v16 = {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
v17 = [
|
v15 = [
|
||||||
{
|
"orderBy"
|
||||||
"kind": "Literal",
|
|
||||||
"name": "filter",
|
|
||||||
"value": {
|
|
||||||
"statuses": [
|
|
||||||
"PENDING",
|
|
||||||
"EXPIRED"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(v8/*: any*/),
|
|
||||||
(v9/*: any*/)
|
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
"fragment": {
|
"fragment": {
|
||||||
@@ -216,16 +203,6 @@ return {
|
|||||||
"args": null,
|
"args": null,
|
||||||
"kind": "FragmentSpread",
|
"kind": "FragmentSpread",
|
||||||
"name": "SettingsPageFragment"
|
"name": "SettingsPageFragment"
|
||||||
},
|
|
||||||
{
|
|
||||||
"args": null,
|
|
||||||
"kind": "FragmentSpread",
|
|
||||||
"name": "SettingsPageMembershipsFragment"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"args": null,
|
|
||||||
"kind": "FragmentSpread",
|
|
||||||
"name": "SettingsPageInvitationsFragment"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"type": "Organization",
|
"type": "Organization",
|
||||||
@@ -294,6 +271,146 @@ return {
|
|||||||
"name": "headquarterAddress",
|
"name": "headquarterAddress",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
|
(v6/*: any*/),
|
||||||
|
(v7/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": (v8/*: any*/),
|
||||||
|
"concreteType": "MembershipConnection",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "memberships",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
(v9/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "MembershipEdge",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "edges",
|
||||||
|
"plural": true,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "Membership",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "node",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
(v2/*: any*/),
|
||||||
|
(v10/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "emailAddress",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
(v11/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "authMethod",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
(v6/*: any*/),
|
||||||
|
(v4/*: any*/)
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
(v12/*: any*/)
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
(v13/*: any*/),
|
||||||
|
(v14/*: any*/)
|
||||||
|
],
|
||||||
|
"storageKey": "memberships(first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": (v8/*: any*/),
|
||||||
|
"filters": (v15/*: any*/),
|
||||||
|
"handle": "connection",
|
||||||
|
"key": "MembersSettingsTabMemberships_memberships",
|
||||||
|
"kind": "LinkedHandle",
|
||||||
|
"name": "memberships"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": (v8/*: any*/),
|
||||||
|
"concreteType": "InvitationConnection",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "invitations",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
(v9/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "InvitationEdge",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "edges",
|
||||||
|
"plural": true,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "Invitation",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "node",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
(v2/*: any*/),
|
||||||
|
(v10/*: any*/),
|
||||||
|
(v5/*: any*/),
|
||||||
|
(v11/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "status",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
(v6/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "expiresAt",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "acceptedAt",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
(v4/*: any*/)
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
(v12/*: any*/)
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
(v13/*: any*/),
|
||||||
|
(v14/*: any*/)
|
||||||
|
],
|
||||||
|
"storageKey": "invitations(first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": (v8/*: any*/),
|
||||||
|
"filters": (v15/*: any*/),
|
||||||
|
"handle": "connection",
|
||||||
|
"key": "MembersSettingsTabInvitations_invitations",
|
||||||
|
"kind": "LinkedHandle",
|
||||||
|
"name": "invitations"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -369,143 +486,157 @@ return {
|
|||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
(v6/*: any*/),
|
|
||||||
(v7/*: any*/),
|
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": (v10/*: any*/),
|
"args": null,
|
||||||
"concreteType": "MembershipConnection",
|
"concreteType": "SAMLConfiguration",
|
||||||
"kind": "LinkedField",
|
"kind": "LinkedField",
|
||||||
"name": "memberships",
|
"name": "samlConfigurations",
|
||||||
"plural": false,
|
"plural": true,
|
||||||
"selections": [
|
"selections": [
|
||||||
(v11/*: any*/),
|
(v2/*: any*/),
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"concreteType": "MembershipEdge",
|
"kind": "ScalarField",
|
||||||
"kind": "LinkedField",
|
"name": "enabled",
|
||||||
"name": "edges",
|
|
||||||
"plural": true,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "Membership",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "node",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
(v2/*: any*/),
|
|
||||||
(v12/*: any*/),
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "emailAddress",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v13/*: any*/),
|
|
||||||
(v6/*: any*/),
|
|
||||||
(v4/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v14/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
(v15/*: any*/),
|
|
||||||
(v16/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": "memberships(first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": (v10/*: any*/),
|
|
||||||
"filters": [
|
|
||||||
"orderBy"
|
|
||||||
],
|
|
||||||
"handle": "connection",
|
|
||||||
"key": "SettingsPageMemberships_memberships",
|
|
||||||
"kind": "LinkedHandle",
|
|
||||||
"name": "memberships"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": (v17/*: any*/),
|
|
||||||
"concreteType": "InvitationConnection",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "invitations",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
(v11/*: any*/),
|
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"concreteType": "InvitationEdge",
|
"kind": "ScalarField",
|
||||||
"kind": "LinkedField",
|
"name": "emailDomain",
|
||||||
"name": "edges",
|
|
||||||
"plural": true,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "Invitation",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "node",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
(v2/*: any*/),
|
|
||||||
(v5/*: any*/),
|
|
||||||
(v12/*: any*/),
|
|
||||||
(v13/*: any*/),
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "status",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "expiresAt",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "acceptedAt",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v6/*: any*/),
|
|
||||||
(v4/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v14/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
(v15/*: any*/),
|
{
|
||||||
(v16/*: any*/)
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "enforcementPolicy",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "domainVerified",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "domainVerificationToken",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "domainVerifiedAt",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "spEntityId",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "spAcsUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "spMetadataUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "testLoginUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpEntityId",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpSsoUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpCertificate",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpMetadataUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeEmail",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeFirstname",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeLastname",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeRole",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "defaultRole",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "autoSignupEnabled",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"storageKey": "invitations(filter:{\"statuses\":[\"PENDING\",\"EXPIRED\"]},first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
|
"storageKey": null
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": (v17/*: any*/),
|
|
||||||
"filters": [
|
|
||||||
"orderBy",
|
|
||||||
"filter"
|
|
||||||
],
|
|
||||||
"handle": "connection",
|
|
||||||
"key": "SettingsPageInvitations_invitations",
|
|
||||||
"kind": "LinkedHandle",
|
|
||||||
"name": "invitations"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"type": "Organization",
|
"type": "Organization",
|
||||||
@@ -517,16 +648,16 @@ return {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "3d38000d9d2105ef8ae3d56edf31b372",
|
"cacheID": "782dc2328cea4f0c350427bda92a5615",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"name": "OrganizationGraph_ViewQuery",
|
"name": "OrganizationGraph_ViewQuery",
|
||||||
"operationKind": "query",
|
"operationKind": "query",
|
||||||
"text": "query OrganizationGraph_ViewQuery(\n $organizationId: ID!\n) {\n node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n ...SettingsPageFragment\n ...SettingsPageMembershipsFragment\n ...SettingsPageInvitationsFragment\n }\n id\n }\n}\n\nfragment SettingsPageFragment on Organization {\n id\n name\n logoUrl\n horizontalLogoUrl\n description\n websiteUrl\n email\n headquarterAddress\n customDomain {\n id\n domain\n sslStatus\n dnsRecords {\n type\n name\n value\n ttl\n purpose\n }\n createdAt\n updatedAt\n sslExpiresAt\n }\n createdAt\n updatedAt\n}\n\nfragment SettingsPageInvitationsFragment on Organization {\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}, filter: {statuses: [PENDING, EXPIRED]}) {\n totalCount\n edges {\n node {\n id\n email\n fullName\n role\n status\n expiresAt\n acceptedAt\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment SettingsPageMembershipsFragment on Organization {\n memberships(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n fullName\n emailAddress\n role\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
|
"text": "query OrganizationGraph_ViewQuery(\n $organizationId: ID!\n) {\n node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n ...SettingsPageFragment\n }\n id\n }\n}\n\nfragment DomainSettingsTabFragment on Organization {\n id\n customDomain {\n id\n domain\n sslStatus\n dnsRecords {\n type\n name\n value\n ttl\n purpose\n }\n createdAt\n updatedAt\n sslExpiresAt\n }\n}\n\nfragment GeneralSettingsTabFragment on Organization {\n id\n name\n logoUrl\n horizontalLogoUrl\n description\n websiteUrl\n email\n headquarterAddress\n createdAt\n updatedAt\n}\n\nfragment MembersSettingsTabInvitationsFragment on Organization {\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n fullName\n email\n role\n status\n createdAt\n expiresAt\n acceptedAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment MembersSettingsTabMembershipsFragment on Organization {\n memberships(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n fullName\n emailAddress\n role\n authMethod\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment SAMLSettingsTabFragment on Organization {\n id\n name\n samlConfigurations {\n id\n enabled\n emailDomain\n enforcementPolicy\n domainVerified\n domainVerificationToken\n domainVerifiedAt\n spEntityId\n spAcsUrl\n spMetadataUrl\n testLoginUrl\n idpEntityId\n idpSsoUrl\n idpCertificate\n idpMetadataUrl\n attributeEmail\n attributeFirstname\n attributeLastname\n attributeRole\n defaultRole\n autoSignupEnabled\n }\n}\n\nfragment SettingsPageFragment on Organization {\n id\n name\n ...GeneralSettingsTabFragment\n ...MembersSettingsTabMembershipsFragment\n ...MembersSettingsTabInvitationsFragment\n ...DomainSettingsTabFragment\n ...SAMLSettingsTabFragment\n}\n"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "fda1489f2b80fd3d0b3962574bd7dfe3";
|
(node as any).hash = "196e8c1fc9c2e0b3c76c8b338ed5c7f7";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
|
|||||||
273
apps/console/src/hooks/graph/__generated__/SAMLConfigurationGraphCreateMutation.graphql.ts
generated
Normal file
273
apps/console/src/hooks/graph/__generated__/SAMLConfigurationGraphCreateMutation.graphql.ts
generated
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* @generated SignedSource<<9232c5fa755e56b6a1ecdcdd51ad83db>>
|
||||||
|
* @lightSyntaxTransform
|
||||||
|
* @nogrep
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { ConcreteRequest } from 'relay-runtime';
|
||||||
|
export type SAMLEnforcementPolicy = "OFF" | "OPTIONAL" | "REQUIRED";
|
||||||
|
export type CreateSAMLConfigurationInput = {
|
||||||
|
attributeEmail?: string | null | undefined;
|
||||||
|
attributeFirstname?: string | null | undefined;
|
||||||
|
attributeLastname?: string | null | undefined;
|
||||||
|
attributeRole?: string | null | undefined;
|
||||||
|
autoSignupEnabled?: boolean | null | undefined;
|
||||||
|
defaultRole?: string | null | undefined;
|
||||||
|
emailDomain: string;
|
||||||
|
enforcementPolicy: SAMLEnforcementPolicy;
|
||||||
|
idpCertificate?: string | null | undefined;
|
||||||
|
idpEntityId?: string | null | undefined;
|
||||||
|
idpMetadataUrl?: string | null | undefined;
|
||||||
|
idpMetadataXml?: string | null | undefined;
|
||||||
|
idpSsoUrl?: string | null | undefined;
|
||||||
|
organizationId: string;
|
||||||
|
spCertificate?: string | null | undefined;
|
||||||
|
spPrivateKey?: string | null | undefined;
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphCreateMutation$variables = {
|
||||||
|
input: CreateSAMLConfigurationInput;
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphCreateMutation$data = {
|
||||||
|
readonly createSAMLConfiguration: {
|
||||||
|
readonly samlConfiguration: {
|
||||||
|
readonly attributeEmail: string;
|
||||||
|
readonly attributeFirstname: string;
|
||||||
|
readonly attributeLastname: string;
|
||||||
|
readonly attributeRole: string;
|
||||||
|
readonly autoSignupEnabled: boolean;
|
||||||
|
readonly createdAt: any;
|
||||||
|
readonly defaultRole: string;
|
||||||
|
readonly emailDomain: string;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly enforcementPolicy: SAMLEnforcementPolicy;
|
||||||
|
readonly id: string;
|
||||||
|
readonly idpCertificate: string;
|
||||||
|
readonly idpEntityId: string;
|
||||||
|
readonly idpMetadataUrl: string | null | undefined;
|
||||||
|
readonly idpSsoUrl: string;
|
||||||
|
readonly spAcsUrl: string;
|
||||||
|
readonly spEntityId: string;
|
||||||
|
readonly spMetadataUrl: string;
|
||||||
|
readonly testLoginUrl: string;
|
||||||
|
readonly updatedAt: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphCreateMutation = {
|
||||||
|
response: SAMLConfigurationGraphCreateMutation$data;
|
||||||
|
variables: SAMLConfigurationGraphCreateMutation$variables;
|
||||||
|
};
|
||||||
|
|
||||||
|
const node: ConcreteRequest = (function(){
|
||||||
|
var v0 = [
|
||||||
|
{
|
||||||
|
"defaultValue": null,
|
||||||
|
"kind": "LocalArgument",
|
||||||
|
"name": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
v1 = [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"kind": "Variable",
|
||||||
|
"name": "input",
|
||||||
|
"variableName": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"concreteType": "CreateSAMLConfigurationPayload",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "createSAMLConfiguration",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "SAMLConfiguration",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "samlConfiguration",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "id",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "enabled",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "emailDomain",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "enforcementPolicy",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "spEntityId",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "spAcsUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "spMetadataUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "testLoginUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpEntityId",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpSsoUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpCertificate",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpMetadataUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeEmail",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeFirstname",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeLastname",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeRole",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "defaultRole",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "autoSignupEnabled",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "createdAt",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "updatedAt",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
"fragment": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Fragment",
|
||||||
|
"metadata": null,
|
||||||
|
"name": "SAMLConfigurationGraphCreateMutation",
|
||||||
|
"selections": (v1/*: any*/),
|
||||||
|
"type": "Mutation",
|
||||||
|
"abstractKey": null
|
||||||
|
},
|
||||||
|
"kind": "Request",
|
||||||
|
"operation": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Operation",
|
||||||
|
"name": "SAMLConfigurationGraphCreateMutation",
|
||||||
|
"selections": (v1/*: any*/)
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"cacheID": "71d5f80565cc8bc4cef0382c315b8cb1",
|
||||||
|
"id": null,
|
||||||
|
"metadata": {},
|
||||||
|
"name": "SAMLConfigurationGraphCreateMutation",
|
||||||
|
"operationKind": "mutation",
|
||||||
|
"text": "mutation SAMLConfigurationGraphCreateMutation(\n $input: CreateSAMLConfigurationInput!\n) {\n createSAMLConfiguration(input: $input) {\n samlConfiguration {\n id\n enabled\n emailDomain\n enforcementPolicy\n spEntityId\n spAcsUrl\n spMetadataUrl\n testLoginUrl\n idpEntityId\n idpSsoUrl\n idpCertificate\n idpMetadataUrl\n attributeEmail\n attributeFirstname\n attributeLastname\n attributeRole\n defaultRole\n autoSignupEnabled\n createdAt\n updatedAt\n }\n }\n}\n"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
(node as any).hash = "04237f5ad3588264149310798d6137f2";
|
||||||
|
|
||||||
|
export default node;
|
||||||
92
apps/console/src/hooks/graph/__generated__/SAMLConfigurationGraphDeleteMutation.graphql.ts
generated
Normal file
92
apps/console/src/hooks/graph/__generated__/SAMLConfigurationGraphDeleteMutation.graphql.ts
generated
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* @generated SignedSource<<55ce4f4205b3eb01954423a75e6e9369>>
|
||||||
|
* @lightSyntaxTransform
|
||||||
|
* @nogrep
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { ConcreteRequest } from 'relay-runtime';
|
||||||
|
export type DeleteSAMLConfigurationInput = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphDeleteMutation$variables = {
|
||||||
|
input: DeleteSAMLConfigurationInput;
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphDeleteMutation$data = {
|
||||||
|
readonly deleteSAMLConfiguration: {
|
||||||
|
readonly deletedSAMLConfigurationId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphDeleteMutation = {
|
||||||
|
response: SAMLConfigurationGraphDeleteMutation$data;
|
||||||
|
variables: SAMLConfigurationGraphDeleteMutation$variables;
|
||||||
|
};
|
||||||
|
|
||||||
|
const node: ConcreteRequest = (function(){
|
||||||
|
var v0 = [
|
||||||
|
{
|
||||||
|
"defaultValue": null,
|
||||||
|
"kind": "LocalArgument",
|
||||||
|
"name": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
v1 = [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"kind": "Variable",
|
||||||
|
"name": "input",
|
||||||
|
"variableName": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"concreteType": "DeleteSAMLConfigurationPayload",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "deleteSAMLConfiguration",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "deletedSAMLConfigurationId",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
"fragment": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Fragment",
|
||||||
|
"metadata": null,
|
||||||
|
"name": "SAMLConfigurationGraphDeleteMutation",
|
||||||
|
"selections": (v1/*: any*/),
|
||||||
|
"type": "Mutation",
|
||||||
|
"abstractKey": null
|
||||||
|
},
|
||||||
|
"kind": "Request",
|
||||||
|
"operation": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Operation",
|
||||||
|
"name": "SAMLConfigurationGraphDeleteMutation",
|
||||||
|
"selections": (v1/*: any*/)
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"cacheID": "be1192099e1b3d0765a358de10685870",
|
||||||
|
"id": null,
|
||||||
|
"metadata": {},
|
||||||
|
"name": "SAMLConfigurationGraphDeleteMutation",
|
||||||
|
"operationKind": "mutation",
|
||||||
|
"text": "mutation SAMLConfigurationGraphDeleteMutation(\n $input: DeleteSAMLConfigurationInput!\n) {\n deleteSAMLConfiguration(input: $input) {\n deletedSAMLConfigurationId\n }\n}\n"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
(node as any).hash = "869072f879524c5c2acbc684f536bfe5";
|
||||||
|
|
||||||
|
export default node;
|
||||||
113
apps/console/src/hooks/graph/__generated__/SAMLConfigurationGraphDisableMutation.graphql.ts
generated
Normal file
113
apps/console/src/hooks/graph/__generated__/SAMLConfigurationGraphDisableMutation.graphql.ts
generated
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* @generated SignedSource<<2101e8d4c307eb6c37e83c625ca7801f>>
|
||||||
|
* @lightSyntaxTransform
|
||||||
|
* @nogrep
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { ConcreteRequest } from 'relay-runtime';
|
||||||
|
export type DisableSAMLInput = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphDisableMutation$variables = {
|
||||||
|
input: DisableSAMLInput;
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphDisableMutation$data = {
|
||||||
|
readonly disableSAML: {
|
||||||
|
readonly samlConfiguration: {
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphDisableMutation = {
|
||||||
|
response: SAMLConfigurationGraphDisableMutation$data;
|
||||||
|
variables: SAMLConfigurationGraphDisableMutation$variables;
|
||||||
|
};
|
||||||
|
|
||||||
|
const node: ConcreteRequest = (function(){
|
||||||
|
var v0 = [
|
||||||
|
{
|
||||||
|
"defaultValue": null,
|
||||||
|
"kind": "LocalArgument",
|
||||||
|
"name": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
v1 = [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"kind": "Variable",
|
||||||
|
"name": "input",
|
||||||
|
"variableName": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"concreteType": "DisableSAMLPayload",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "disableSAML",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "SAMLConfiguration",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "samlConfiguration",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "id",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "enabled",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
"fragment": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Fragment",
|
||||||
|
"metadata": null,
|
||||||
|
"name": "SAMLConfigurationGraphDisableMutation",
|
||||||
|
"selections": (v1/*: any*/),
|
||||||
|
"type": "Mutation",
|
||||||
|
"abstractKey": null
|
||||||
|
},
|
||||||
|
"kind": "Request",
|
||||||
|
"operation": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Operation",
|
||||||
|
"name": "SAMLConfigurationGraphDisableMutation",
|
||||||
|
"selections": (v1/*: any*/)
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"cacheID": "dbed05d465035d865dfd1f6473f88401",
|
||||||
|
"id": null,
|
||||||
|
"metadata": {},
|
||||||
|
"name": "SAMLConfigurationGraphDisableMutation",
|
||||||
|
"operationKind": "mutation",
|
||||||
|
"text": "mutation SAMLConfigurationGraphDisableMutation(\n $input: DisableSAMLInput!\n) {\n disableSAML(input: $input) {\n samlConfiguration {\n id\n enabled\n }\n }\n}\n"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
(node as any).hash = "6492fd7729c72ca805ef8f99d1399081";
|
||||||
|
|
||||||
|
export default node;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<b883cd0523e827b2c71b89e1d249d6dd>>
|
* @generated SignedSource<<8bcd8aad334a74db810b3d4c17fbb6ac>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -9,22 +9,23 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
import { ConcreteRequest } from 'relay-runtime';
|
import { ConcreteRequest } from 'relay-runtime';
|
||||||
export type AcceptInvitationInput = {
|
export type EnableSAMLInput = {
|
||||||
invitationId: string;
|
id: string;
|
||||||
};
|
};
|
||||||
export type OrganizationsPage_AcceptInvitationMutation$variables = {
|
export type SAMLConfigurationGraphEnableMutation$variables = {
|
||||||
input: AcceptInvitationInput;
|
input: EnableSAMLInput;
|
||||||
};
|
};
|
||||||
export type OrganizationsPage_AcceptInvitationMutation$data = {
|
export type SAMLConfigurationGraphEnableMutation$data = {
|
||||||
readonly acceptInvitation: {
|
readonly enableSAML: {
|
||||||
readonly invitation: {
|
readonly samlConfiguration: {
|
||||||
|
readonly enabled: boolean;
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type OrganizationsPage_AcceptInvitationMutation = {
|
export type SAMLConfigurationGraphEnableMutation = {
|
||||||
response: OrganizationsPage_AcceptInvitationMutation$data;
|
response: SAMLConfigurationGraphEnableMutation$data;
|
||||||
variables: OrganizationsPage_AcceptInvitationMutation$variables;
|
variables: SAMLConfigurationGraphEnableMutation$variables;
|
||||||
};
|
};
|
||||||
|
|
||||||
const node: ConcreteRequest = (function(){
|
const node: ConcreteRequest = (function(){
|
||||||
@@ -45,17 +46,17 @@ v1 = [
|
|||||||
"variableName": "input"
|
"variableName": "input"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"concreteType": "AcceptInvitationPayload",
|
"concreteType": "EnableSAMLPayload",
|
||||||
"kind": "LinkedField",
|
"kind": "LinkedField",
|
||||||
"name": "acceptInvitation",
|
"name": "enableSAML",
|
||||||
"plural": false,
|
"plural": false,
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"concreteType": "Invitation",
|
"concreteType": "SAMLConfiguration",
|
||||||
"kind": "LinkedField",
|
"kind": "LinkedField",
|
||||||
"name": "invitation",
|
"name": "samlConfiguration",
|
||||||
"plural": false,
|
"plural": false,
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
@@ -64,6 +65,13 @@ v1 = [
|
|||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "enabled",
|
||||||
|
"storageKey": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
@@ -77,7 +85,7 @@ return {
|
|||||||
"argumentDefinitions": (v0/*: any*/),
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
"kind": "Fragment",
|
"kind": "Fragment",
|
||||||
"metadata": null,
|
"metadata": null,
|
||||||
"name": "OrganizationsPage_AcceptInvitationMutation",
|
"name": "SAMLConfigurationGraphEnableMutation",
|
||||||
"selections": (v1/*: any*/),
|
"selections": (v1/*: any*/),
|
||||||
"type": "Mutation",
|
"type": "Mutation",
|
||||||
"abstractKey": null
|
"abstractKey": null
|
||||||
@@ -86,20 +94,20 @@ return {
|
|||||||
"operation": {
|
"operation": {
|
||||||
"argumentDefinitions": (v0/*: any*/),
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
"kind": "Operation",
|
"kind": "Operation",
|
||||||
"name": "OrganizationsPage_AcceptInvitationMutation",
|
"name": "SAMLConfigurationGraphEnableMutation",
|
||||||
"selections": (v1/*: any*/)
|
"selections": (v1/*: any*/)
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "cc4a442037edaa624948b5be0c009823",
|
"cacheID": "2bd2356d146a9fa5f5a5f5480d310bc9",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"name": "OrganizationsPage_AcceptInvitationMutation",
|
"name": "SAMLConfigurationGraphEnableMutation",
|
||||||
"operationKind": "mutation",
|
"operationKind": "mutation",
|
||||||
"text": "mutation OrganizationsPage_AcceptInvitationMutation(\n $input: AcceptInvitationInput!\n) {\n acceptInvitation(input: $input) {\n invitation {\n id\n }\n }\n}\n"
|
"text": "mutation SAMLConfigurationGraphEnableMutation(\n $input: EnableSAMLInput!\n) {\n enableSAML(input: $input) {\n samlConfiguration {\n id\n enabled\n }\n }\n}\n"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "190213ab6fdc068343a270b4fa94e160";
|
(node as any).hash = "1627602d91776f718f1913011bdce786";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* @generated SignedSource<<55d58c6ee7e4dcd7ffe7e172aea932d5>>
|
||||||
|
* @lightSyntaxTransform
|
||||||
|
* @nogrep
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { ConcreteRequest } from 'relay-runtime';
|
||||||
|
export type InitiateDomainVerificationInput = {
|
||||||
|
emailDomain: string;
|
||||||
|
organizationId: string;
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphInitiateDomainVerificationMutation$variables = {
|
||||||
|
input: InitiateDomainVerificationInput;
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphInitiateDomainVerificationMutation$data = {
|
||||||
|
readonly initiateDomainVerification: {
|
||||||
|
readonly dnsRecord: string;
|
||||||
|
readonly samlConfiguration: {
|
||||||
|
readonly domainVerificationToken: string | null | undefined;
|
||||||
|
readonly domainVerified: boolean;
|
||||||
|
readonly domainVerifiedAt: any | null | undefined;
|
||||||
|
readonly emailDomain: string;
|
||||||
|
readonly id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphInitiateDomainVerificationMutation = {
|
||||||
|
response: SAMLConfigurationGraphInitiateDomainVerificationMutation$data;
|
||||||
|
variables: SAMLConfigurationGraphInitiateDomainVerificationMutation$variables;
|
||||||
|
};
|
||||||
|
|
||||||
|
const node: ConcreteRequest = (function(){
|
||||||
|
var v0 = [
|
||||||
|
{
|
||||||
|
"defaultValue": null,
|
||||||
|
"kind": "LocalArgument",
|
||||||
|
"name": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
v1 = [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"kind": "Variable",
|
||||||
|
"name": "input",
|
||||||
|
"variableName": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"concreteType": "InitiateDomainVerificationPayload",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "initiateDomainVerification",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "SAMLConfiguration",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "samlConfiguration",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "id",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "emailDomain",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "domainVerified",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "domainVerificationToken",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "domainVerifiedAt",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "dnsRecord",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
"fragment": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Fragment",
|
||||||
|
"metadata": null,
|
||||||
|
"name": "SAMLConfigurationGraphInitiateDomainVerificationMutation",
|
||||||
|
"selections": (v1/*: any*/),
|
||||||
|
"type": "Mutation",
|
||||||
|
"abstractKey": null
|
||||||
|
},
|
||||||
|
"kind": "Request",
|
||||||
|
"operation": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Operation",
|
||||||
|
"name": "SAMLConfigurationGraphInitiateDomainVerificationMutation",
|
||||||
|
"selections": (v1/*: any*/)
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"cacheID": "61453ca7fb2c7ff2fe8b657f6cfb7497",
|
||||||
|
"id": null,
|
||||||
|
"metadata": {},
|
||||||
|
"name": "SAMLConfigurationGraphInitiateDomainVerificationMutation",
|
||||||
|
"operationKind": "mutation",
|
||||||
|
"text": "mutation SAMLConfigurationGraphInitiateDomainVerificationMutation(\n $input: InitiateDomainVerificationInput!\n) {\n initiateDomainVerification(input: $input) {\n samlConfiguration {\n id\n emailDomain\n domainVerified\n domainVerificationToken\n domainVerifiedAt\n }\n dnsRecord\n }\n}\n"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
(node as any).hash = "5424f4fd94618327ae64292d31349e73";
|
||||||
|
|
||||||
|
export default node;
|
||||||
272
apps/console/src/hooks/graph/__generated__/SAMLConfigurationGraphUpdateMutation.graphql.ts
generated
Normal file
272
apps/console/src/hooks/graph/__generated__/SAMLConfigurationGraphUpdateMutation.graphql.ts
generated
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* @generated SignedSource<<694befcb248cbb23e66b9555626f223b>>
|
||||||
|
* @lightSyntaxTransform
|
||||||
|
* @nogrep
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { ConcreteRequest } from 'relay-runtime';
|
||||||
|
export type SAMLEnforcementPolicy = "OFF" | "OPTIONAL" | "REQUIRED";
|
||||||
|
export type UpdateSAMLConfigurationInput = {
|
||||||
|
attributeEmail?: string | null | undefined;
|
||||||
|
attributeFirstname?: string | null | undefined;
|
||||||
|
attributeLastname?: string | null | undefined;
|
||||||
|
attributeRole?: string | null | undefined;
|
||||||
|
autoSignupEnabled?: boolean | null | undefined;
|
||||||
|
defaultRole?: string | null | undefined;
|
||||||
|
enabled?: boolean | null | undefined;
|
||||||
|
enforcementPolicy?: SAMLEnforcementPolicy | null | undefined;
|
||||||
|
id: string;
|
||||||
|
idpCertificate?: string | null | undefined;
|
||||||
|
idpEntityId?: string | null | undefined;
|
||||||
|
idpMetadataUrl?: string | null | undefined;
|
||||||
|
idpSsoUrl?: string | null | undefined;
|
||||||
|
spCertificate?: string | null | undefined;
|
||||||
|
spPrivateKey?: string | null | undefined;
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphUpdateMutation$variables = {
|
||||||
|
input: UpdateSAMLConfigurationInput;
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphUpdateMutation$data = {
|
||||||
|
readonly updateSAMLConfiguration: {
|
||||||
|
readonly samlConfiguration: {
|
||||||
|
readonly attributeEmail: string;
|
||||||
|
readonly attributeFirstname: string;
|
||||||
|
readonly attributeLastname: string;
|
||||||
|
readonly attributeRole: string;
|
||||||
|
readonly autoSignupEnabled: boolean;
|
||||||
|
readonly createdAt: any;
|
||||||
|
readonly defaultRole: string;
|
||||||
|
readonly emailDomain: string;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly enforcementPolicy: SAMLEnforcementPolicy;
|
||||||
|
readonly id: string;
|
||||||
|
readonly idpCertificate: string;
|
||||||
|
readonly idpEntityId: string;
|
||||||
|
readonly idpMetadataUrl: string | null | undefined;
|
||||||
|
readonly idpSsoUrl: string;
|
||||||
|
readonly spAcsUrl: string;
|
||||||
|
readonly spEntityId: string;
|
||||||
|
readonly spMetadataUrl: string;
|
||||||
|
readonly testLoginUrl: string;
|
||||||
|
readonly updatedAt: any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphUpdateMutation = {
|
||||||
|
response: SAMLConfigurationGraphUpdateMutation$data;
|
||||||
|
variables: SAMLConfigurationGraphUpdateMutation$variables;
|
||||||
|
};
|
||||||
|
|
||||||
|
const node: ConcreteRequest = (function(){
|
||||||
|
var v0 = [
|
||||||
|
{
|
||||||
|
"defaultValue": null,
|
||||||
|
"kind": "LocalArgument",
|
||||||
|
"name": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
v1 = [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"kind": "Variable",
|
||||||
|
"name": "input",
|
||||||
|
"variableName": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"concreteType": "UpdateSAMLConfigurationPayload",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "updateSAMLConfiguration",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "SAMLConfiguration",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "samlConfiguration",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "id",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "enabled",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "emailDomain",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "enforcementPolicy",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "spEntityId",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "spAcsUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "spMetadataUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "testLoginUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpEntityId",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpSsoUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpCertificate",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpMetadataUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeEmail",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeFirstname",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeLastname",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeRole",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "defaultRole",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "autoSignupEnabled",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "createdAt",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "updatedAt",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
"fragment": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Fragment",
|
||||||
|
"metadata": null,
|
||||||
|
"name": "SAMLConfigurationGraphUpdateMutation",
|
||||||
|
"selections": (v1/*: any*/),
|
||||||
|
"type": "Mutation",
|
||||||
|
"abstractKey": null
|
||||||
|
},
|
||||||
|
"kind": "Request",
|
||||||
|
"operation": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Operation",
|
||||||
|
"name": "SAMLConfigurationGraphUpdateMutation",
|
||||||
|
"selections": (v1/*: any*/)
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"cacheID": "c9e4055888055b109aa71b665c9544ca",
|
||||||
|
"id": null,
|
||||||
|
"metadata": {},
|
||||||
|
"name": "SAMLConfigurationGraphUpdateMutation",
|
||||||
|
"operationKind": "mutation",
|
||||||
|
"text": "mutation SAMLConfigurationGraphUpdateMutation(\n $input: UpdateSAMLConfigurationInput!\n) {\n updateSAMLConfiguration(input: $input) {\n samlConfiguration {\n id\n enabled\n emailDomain\n enforcementPolicy\n spEntityId\n spAcsUrl\n spMetadataUrl\n testLoginUrl\n idpEntityId\n idpSsoUrl\n idpCertificate\n idpMetadataUrl\n attributeEmail\n attributeFirstname\n attributeLastname\n attributeRole\n defaultRole\n autoSignupEnabled\n createdAt\n updatedAt\n }\n }\n}\n"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
(node as any).hash = "d572af05811d6a05cdd4446fe175894d";
|
||||||
|
|
||||||
|
export default node;
|
||||||
129
apps/console/src/hooks/graph/__generated__/SAMLConfigurationGraphVerifyDomainMutation.graphql.ts
generated
Normal file
129
apps/console/src/hooks/graph/__generated__/SAMLConfigurationGraphVerifyDomainMutation.graphql.ts
generated
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* @generated SignedSource<<8baf2363740e7eb47e7654f2f55c09df>>
|
||||||
|
* @lightSyntaxTransform
|
||||||
|
* @nogrep
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { ConcreteRequest } from 'relay-runtime';
|
||||||
|
export type VerifyDomainInput = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphVerifyDomainMutation$variables = {
|
||||||
|
input: VerifyDomainInput;
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphVerifyDomainMutation$data = {
|
||||||
|
readonly verifyDomain: {
|
||||||
|
readonly samlConfiguration: {
|
||||||
|
readonly domainVerified: boolean;
|
||||||
|
readonly domainVerifiedAt: any | null | undefined;
|
||||||
|
readonly id: string;
|
||||||
|
};
|
||||||
|
readonly verified: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export type SAMLConfigurationGraphVerifyDomainMutation = {
|
||||||
|
response: SAMLConfigurationGraphVerifyDomainMutation$data;
|
||||||
|
variables: SAMLConfigurationGraphVerifyDomainMutation$variables;
|
||||||
|
};
|
||||||
|
|
||||||
|
const node: ConcreteRequest = (function(){
|
||||||
|
var v0 = [
|
||||||
|
{
|
||||||
|
"defaultValue": null,
|
||||||
|
"kind": "LocalArgument",
|
||||||
|
"name": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
v1 = [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"kind": "Variable",
|
||||||
|
"name": "input",
|
||||||
|
"variableName": "input"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"concreteType": "VerifyDomainPayload",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "verifyDomain",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "SAMLConfiguration",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "samlConfiguration",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "id",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "domainVerified",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "domainVerifiedAt",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "verified",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
"fragment": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Fragment",
|
||||||
|
"metadata": null,
|
||||||
|
"name": "SAMLConfigurationGraphVerifyDomainMutation",
|
||||||
|
"selections": (v1/*: any*/),
|
||||||
|
"type": "Mutation",
|
||||||
|
"abstractKey": null
|
||||||
|
},
|
||||||
|
"kind": "Request",
|
||||||
|
"operation": {
|
||||||
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
|
"kind": "Operation",
|
||||||
|
"name": "SAMLConfigurationGraphVerifyDomainMutation",
|
||||||
|
"selections": (v1/*: any*/)
|
||||||
|
},
|
||||||
|
"params": {
|
||||||
|
"cacheID": "1f241529f518d2f0701d9c4f7a731d6b",
|
||||||
|
"id": null,
|
||||||
|
"metadata": {},
|
||||||
|
"name": "SAMLConfigurationGraphVerifyDomainMutation",
|
||||||
|
"operationKind": "mutation",
|
||||||
|
"text": "mutation SAMLConfigurationGraphVerifyDomainMutation(\n $input: VerifyDomainInput!\n) {\n verifyDomain(input: $input) {\n samlConfiguration {\n id\n domainVerified\n domainVerifiedAt\n }\n verified\n }\n}\n"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
(node as any).hash = "ea3f1fe691b0c36ff7e54663479f7e7c";
|
||||||
|
|
||||||
|
export default node;
|
||||||
@@ -30,17 +30,16 @@ import {
|
|||||||
DropdownItem,
|
DropdownItem,
|
||||||
IconChevronGrabberVertical,
|
IconChevronGrabberVertical,
|
||||||
IconPlusLarge,
|
IconPlusLarge,
|
||||||
IconChevronDown,
|
|
||||||
Avatar,
|
Avatar,
|
||||||
IconPeopleAdd,
|
IconPeopleAdd,
|
||||||
Badge,
|
Badge,
|
||||||
|
IconLock,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
import { useLazyLoadQuery, usePaginationFragment } from "react-relay";
|
import { useLazyLoadQuery } from "react-relay";
|
||||||
import type { MainLayoutQuery as MainLayoutQueryType } from "./__generated__/MainLayoutQuery.graphql";
|
import type { MainLayoutQuery as MainLayoutQueryType } from "./__generated__/MainLayoutQuery.graphql";
|
||||||
import type { MainLayout_OrganizationSelector_viewer$key } from "./__generated__/MainLayout_OrganizationSelector_viewer.graphql";
|
import { Suspense, useState, useEffect } from "react";
|
||||||
import { Suspense, useState } from "react";
|
|
||||||
import { useToast } from "@probo/ui";
|
import { useToast } from "@probo/ui";
|
||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { PageError } from "/components/PageError";
|
import { PageError } from "/components/PageError";
|
||||||
@@ -54,7 +53,9 @@ const MainLayoutQuery = graphql`
|
|||||||
fullName
|
fullName
|
||||||
email
|
email
|
||||||
}
|
}
|
||||||
...MainLayout_OrganizationSelector_viewer
|
invitations(first: 1, filter: {statuses: [PENDING]}) {
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
organization: node(id: $organizationId) {
|
organization: node(id: $organizationId) {
|
||||||
... on Organization {
|
... on Organization {
|
||||||
@@ -66,33 +67,6 @@ const MainLayoutQuery = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const OrganizationSelectorFragment = graphql`
|
|
||||||
fragment MainLayout_OrganizationSelector_viewer on Viewer
|
|
||||||
@refetchable(queryName: "MainLayoutOrganizationSelectorPaginationQuery")
|
|
||||||
@argumentDefinitions(
|
|
||||||
first: { type: "Int", defaultValue: 25 }
|
|
||||||
after: { type: "CursorKey" }
|
|
||||||
) {
|
|
||||||
organizations(first: $first, after: $after, orderBy: {field: NAME, direction: ASC})
|
|
||||||
@connection(key: "MainLayout_OrganizationSelector_organizations") {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
logoUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pageInfo {
|
|
||||||
hasNextPage
|
|
||||||
endCursor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
invitations(first: 1, filter: {statuses: [PENDING]}) {
|
|
||||||
totalCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Site layout with a header and a sidebar
|
* Site layout with a header and a sidebar
|
||||||
*/
|
*/
|
||||||
@@ -228,7 +202,7 @@ function UserDropdown({ organizationId }: { organizationId: string }) {
|
|||||||
) => {
|
) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
fetch(buildEndpoint("/api/console/v1/auth/logout"), {
|
fetch(buildEndpoint("/auth/logout"), {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -271,6 +245,19 @@ function UserDropdown({ organizationId }: { organizationId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Organization {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logoUrl?: string | null;
|
||||||
|
authenticationMethod: string;
|
||||||
|
authStatus: "authenticated" | "unauthenticated" | "expired";
|
||||||
|
loginUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrganizationsResponse {
|
||||||
|
organizations: Organization[];
|
||||||
|
}
|
||||||
|
|
||||||
function OrganizationSelectorWrapper({ organizationId }: { organizationId: string }) {
|
function OrganizationSelectorWrapper({ organizationId }: { organizationId: string }) {
|
||||||
const data = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, { organizationId });
|
const data = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, { organizationId });
|
||||||
return <OrganizationSelector viewer={data.viewer} currentOrganization={data.organization} />;
|
return <OrganizationSelector viewer={data.viewer} currentOrganization={data.organization} />;
|
||||||
@@ -280,31 +267,55 @@ function OrganizationSelector({
|
|||||||
viewer,
|
viewer,
|
||||||
currentOrganization
|
currentOrganization
|
||||||
}: {
|
}: {
|
||||||
viewer: MainLayout_OrganizationSelector_viewer$key;
|
viewer: MainLayoutQueryType["response"]["viewer"];
|
||||||
currentOrganization: MainLayoutQueryType["response"]["organization"];
|
currentOrganization: MainLayoutQueryType["response"]["organization"];
|
||||||
}) {
|
}) {
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
const { data, loadNext, hasNext } = usePaginationFragment(
|
const pendingInvitationsCount = viewer.invitations.totalCount;
|
||||||
OrganizationSelectorFragment,
|
|
||||||
viewer
|
|
||||||
);
|
|
||||||
|
|
||||||
const organizations = data.organizations.edges.map((edge) => edge.node);
|
useEffect(() => {
|
||||||
const pendingInvitationsCount = data.invitations.totalCount;
|
const fetchOrganizations = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await fetch('/auth/organizations', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
const handleLoadMore = (e?: React.MouseEvent) => {
|
if (!response.ok) {
|
||||||
e?.preventDefault();
|
throw new Error('Failed to fetch organizations');
|
||||||
e?.stopPropagation();
|
}
|
||||||
|
|
||||||
if (hasNext && !isLoadingMore) {
|
const data: OrganizationsResponse = await response.json();
|
||||||
setIsLoadingMore(true);
|
setOrganizations(data.organizations);
|
||||||
loadNext(25, {
|
setError(null);
|
||||||
onComplete: () => setIsLoadingMore(false),
|
} catch (err) {
|
||||||
});
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
}
|
console.error('Failed to fetch organizations:', err);
|
||||||
};
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchOrganizations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
className="-ml-3"
|
||||||
|
variant="tertiary"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
{__("Error loading organizations")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -314,39 +325,70 @@ function OrganizationSelector({
|
|||||||
className="-ml-3"
|
className="-ml-3"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
iconAfter={IconChevronGrabberVertical}
|
iconAfter={IconChevronGrabberVertical}
|
||||||
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{currentOrganization?.name || ""}
|
{isLoading ? __("Loading...") : (currentOrganization?.name || "")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="max-h-150 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent hover:scrollbar-thumb-gray-400">
|
<div className="max-h-150 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent hover:scrollbar-thumb-gray-400">
|
||||||
{organizations.map((organization) => (
|
{isLoading ? (
|
||||||
<DropdownItem
|
<div className="px-3 py-2 text-gray-500">
|
||||||
asChild
|
{__("Loading organizations...")}
|
||||||
key={organization.id}
|
|
||||||
>
|
|
||||||
<Link to={`/organizations/${organization.id}`}>
|
|
||||||
<Avatar src={organization.logoUrl} name={organization.name} />
|
|
||||||
{organization.name}
|
|
||||||
</Link>
|
|
||||||
</DropdownItem>
|
|
||||||
))}
|
|
||||||
{hasNext && (
|
|
||||||
<div className="px-3 py-1 flex justify-center">
|
|
||||||
<Button
|
|
||||||
variant="tertiary"
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
onMouseDown={(e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
className="mx-auto"
|
|
||||||
icon={IconChevronDown}
|
|
||||||
disabled={isLoadingMore}
|
|
||||||
>
|
|
||||||
{isLoadingMore ? __("Loading...") : __("Show More")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : organizations.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-gray-500">
|
||||||
|
{__("No organizations found")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
organizations.map((organization) => {
|
||||||
|
const isAuthenticated = organization.authStatus === "authenticated";
|
||||||
|
const isExpired = organization.authStatus === "expired";
|
||||||
|
const needsAuth = organization.authStatus === "unauthenticated";
|
||||||
|
|
||||||
|
const targetUrl = isAuthenticated
|
||||||
|
? `/organizations/${organization.id}`
|
||||||
|
: organization.loginUrl;
|
||||||
|
|
||||||
|
const isSAMLUrl = targetUrl.includes('/auth/saml/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownItem
|
||||||
|
asChild
|
||||||
|
key={organization.id}
|
||||||
|
>
|
||||||
|
{isSAMLUrl ? (
|
||||||
|
<a href={targetUrl} className="flex items-center gap-2">
|
||||||
|
<Avatar name={organization.name} src={organization.logoUrl} />
|
||||||
|
<span className="flex-1">{organization.name}</span>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<IconCheckmark1 size={16} className="text-green-600" />
|
||||||
|
)}
|
||||||
|
{isExpired && (
|
||||||
|
<IconClock size={16} className="text-orange-600" />
|
||||||
|
)}
|
||||||
|
{needsAuth && (
|
||||||
|
<IconLock size={16} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link to={targetUrl} className="flex items-center gap-2">
|
||||||
|
<Avatar name={organization.name} src={organization.logoUrl} />
|
||||||
|
<span className="flex-1">{organization.name}</span>
|
||||||
|
{isAuthenticated && (
|
||||||
|
<IconCheckmark1 size={16} className="text-green-600" />
|
||||||
|
)}
|
||||||
|
{isExpired && (
|
||||||
|
<IconClock size={16} className="text-orange-600" />
|
||||||
|
)}
|
||||||
|
{needsAuth && (
|
||||||
|
<IconLock size={16} className="text-gray-400" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</DropdownItem>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DropdownSeparator />
|
<DropdownSeparator />
|
||||||
|
|||||||
@@ -1,263 +0,0 @@
|
|||||||
/**
|
|
||||||
* @generated SignedSource<<3a99643b92330af6920aac4c2286376f>>
|
|
||||||
* @lightSyntaxTransform
|
|
||||||
* @nogrep
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { ConcreteRequest } from 'relay-runtime';
|
|
||||||
import { FragmentRefs } from "relay-runtime";
|
|
||||||
export type MainLayoutOrganizationSelectorPaginationQuery$variables = {
|
|
||||||
after?: any | null | undefined;
|
|
||||||
first?: number | null | undefined;
|
|
||||||
};
|
|
||||||
export type MainLayoutOrganizationSelectorPaginationQuery$data = {
|
|
||||||
readonly viewer: {
|
|
||||||
readonly " $fragmentSpreads": FragmentRefs<"MainLayout_OrganizationSelector_viewer">;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
export type MainLayoutOrganizationSelectorPaginationQuery = {
|
|
||||||
response: MainLayoutOrganizationSelectorPaginationQuery$data;
|
|
||||||
variables: MainLayoutOrganizationSelectorPaginationQuery$variables;
|
|
||||||
};
|
|
||||||
|
|
||||||
const node: ConcreteRequest = (function(){
|
|
||||||
var v0 = [
|
|
||||||
{
|
|
||||||
"defaultValue": null,
|
|
||||||
"kind": "LocalArgument",
|
|
||||||
"name": "after"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"defaultValue": 25,
|
|
||||||
"kind": "LocalArgument",
|
|
||||||
"name": "first"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
v1 = {
|
|
||||||
"kind": "Variable",
|
|
||||||
"name": "after",
|
|
||||||
"variableName": "after"
|
|
||||||
},
|
|
||||||
v2 = {
|
|
||||||
"kind": "Variable",
|
|
||||||
"name": "first",
|
|
||||||
"variableName": "first"
|
|
||||||
},
|
|
||||||
v3 = [
|
|
||||||
(v1/*: any*/),
|
|
||||||
(v2/*: any*/),
|
|
||||||
{
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "orderBy",
|
|
||||||
"value": {
|
|
||||||
"direction": "ASC",
|
|
||||||
"field": "NAME"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
v4 = {
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "id",
|
|
||||||
"storageKey": null
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
"fragment": {
|
|
||||||
"argumentDefinitions": (v0/*: any*/),
|
|
||||||
"kind": "Fragment",
|
|
||||||
"metadata": null,
|
|
||||||
"name": "MainLayoutOrganizationSelectorPaginationQuery",
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "Viewer",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "viewer",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"args": [
|
|
||||||
(v1/*: any*/),
|
|
||||||
(v2/*: any*/)
|
|
||||||
],
|
|
||||||
"kind": "FragmentSpread",
|
|
||||||
"name": "MainLayout_OrganizationSelector_viewer"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"type": "Query",
|
|
||||||
"abstractKey": null
|
|
||||||
},
|
|
||||||
"kind": "Request",
|
|
||||||
"operation": {
|
|
||||||
"argumentDefinitions": (v0/*: any*/),
|
|
||||||
"kind": "Operation",
|
|
||||||
"name": "MainLayoutOrganizationSelectorPaginationQuery",
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "Viewer",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "viewer",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": (v3/*: any*/),
|
|
||||||
"concreteType": "OrganizationConnection",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "organizations",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "OrganizationEdge",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "edges",
|
|
||||||
"plural": true,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "Organization",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "node",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
(v4/*: any*/),
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "name",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "logoUrl",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "__typename",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "cursor",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "PageInfo",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "pageInfo",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "hasNextPage",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "endCursor",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": (v3/*: any*/),
|
|
||||||
"filters": [
|
|
||||||
"orderBy"
|
|
||||||
],
|
|
||||||
"handle": "connection",
|
|
||||||
"key": "MainLayout_OrganizationSelector_organizations",
|
|
||||||
"kind": "LinkedHandle",
|
|
||||||
"name": "organizations"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": [
|
|
||||||
{
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "filter",
|
|
||||||
"value": {
|
|
||||||
"statuses": [
|
|
||||||
"PENDING"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "first",
|
|
||||||
"value": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"concreteType": "InvitationConnection",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "invitations",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "totalCount",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": "invitations(filter:{\"statuses\":[\"PENDING\"]},first:1)"
|
|
||||||
},
|
|
||||||
(v4/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"params": {
|
|
||||||
"cacheID": "d51b49a50afe664ee9903c1bb265ab79",
|
|
||||||
"id": null,
|
|
||||||
"metadata": {},
|
|
||||||
"name": "MainLayoutOrganizationSelectorPaginationQuery",
|
|
||||||
"operationKind": "query",
|
|
||||||
"text": "query MainLayoutOrganizationSelectorPaginationQuery(\n $after: CursorKey\n $first: Int = 25\n) {\n viewer {\n ...MainLayout_OrganizationSelector_viewer_2HEEH6\n id\n }\n}\n\nfragment MainLayout_OrganizationSelector_viewer_2HEEH6 on Viewer {\n organizations(first: $first, after: $after, orderBy: {field: NAME, direction: ASC}) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n invitations(first: 1, filter: {statuses: [PENDING]}) {\n totalCount\n }\n}\n"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
(node as any).hash = "3e00f1a6f8089fc59144807a07fd1bdf";
|
|
||||||
|
|
||||||
export default node;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<1a7aa899a9b6251477122c87ae1c6431>>
|
* @generated SignedSource<<26b3620e6aed7f97ffb1710be1eb267a>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -9,7 +9,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
import { ConcreteRequest } from 'relay-runtime';
|
import { ConcreteRequest } from 'relay-runtime';
|
||||||
import { FragmentRefs } from "relay-runtime";
|
|
||||||
export type MainLayoutQuery$variables = {
|
export type MainLayoutQuery$variables = {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
};
|
};
|
||||||
@@ -21,11 +20,13 @@ export type MainLayoutQuery$data = {
|
|||||||
};
|
};
|
||||||
readonly viewer: {
|
readonly viewer: {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
|
readonly invitations: {
|
||||||
|
readonly totalCount: number;
|
||||||
|
};
|
||||||
readonly user: {
|
readonly user: {
|
||||||
readonly email: string;
|
readonly email: string;
|
||||||
readonly fullName: string;
|
readonly fullName: string;
|
||||||
};
|
};
|
||||||
readonly " $fragmentSpreads": FragmentRefs<"MainLayout_OrganizationSelector_viewer">;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type MainLayoutQuery = {
|
export type MainLayoutQuery = {
|
||||||
@@ -62,48 +63,59 @@ v3 = {
|
|||||||
"name": "email",
|
"name": "email",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v4 = [
|
v4 = {
|
||||||
|
"alias": null,
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"kind": "Literal",
|
||||||
|
"name": "filter",
|
||||||
|
"value": {
|
||||||
|
"statuses": [
|
||||||
|
"PENDING"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "Literal",
|
||||||
|
"name": "first",
|
||||||
|
"value": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"concreteType": "InvitationConnection",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "invitations",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "totalCount",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": "invitations(filter:{\"statuses\":[\"PENDING\"]},first:1)"
|
||||||
|
},
|
||||||
|
v5 = [
|
||||||
{
|
{
|
||||||
"kind": "Variable",
|
"kind": "Variable",
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"variableName": "organizationId"
|
"variableName": "organizationId"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
v5 = {
|
v6 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v6 = {
|
v7 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "logoUrl",
|
"name": "logoUrl",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
|
||||||
v7 = [
|
|
||||||
{
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "first",
|
|
||||||
"value": 25
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "orderBy",
|
|
||||||
"value": {
|
|
||||||
"direction": "ASC",
|
|
||||||
"field": "NAME"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
v8 = {
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "__typename",
|
|
||||||
"storageKey": null
|
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
"fragment": {
|
"fragment": {
|
||||||
@@ -134,17 +146,13 @@ return {
|
|||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
{
|
(v4/*: any*/)
|
||||||
"args": null,
|
|
||||||
"kind": "FragmentSpread",
|
|
||||||
"name": "MainLayout_OrganizationSelector_viewer"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": "organization",
|
"alias": "organization",
|
||||||
"args": (v4/*: any*/),
|
"args": (v5/*: any*/),
|
||||||
"concreteType": null,
|
"concreteType": null,
|
||||||
"kind": "LinkedField",
|
"kind": "LinkedField",
|
||||||
"name": "node",
|
"name": "node",
|
||||||
@@ -154,8 +162,8 @@ return {
|
|||||||
"kind": "InlineFragment",
|
"kind": "InlineFragment",
|
||||||
"selections": [
|
"selections": [
|
||||||
(v1/*: any*/),
|
(v1/*: any*/),
|
||||||
(v5/*: any*/),
|
(v6/*: any*/),
|
||||||
(v6/*: any*/)
|
(v7/*: any*/)
|
||||||
],
|
],
|
||||||
"type": "Organization",
|
"type": "Organization",
|
||||||
"abstractKey": null
|
"abstractKey": null
|
||||||
@@ -196,137 +204,31 @@ return {
|
|||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
{
|
(v4/*: any*/)
|
||||||
"alias": null,
|
|
||||||
"args": (v7/*: any*/),
|
|
||||||
"concreteType": "OrganizationConnection",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "organizations",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "OrganizationEdge",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "edges",
|
|
||||||
"plural": true,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "Organization",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "node",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
(v1/*: any*/),
|
|
||||||
(v5/*: any*/),
|
|
||||||
(v6/*: any*/),
|
|
||||||
(v8/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "cursor",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "PageInfo",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "pageInfo",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "hasNextPage",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "endCursor",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": "organizations(first:25,orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": (v7/*: any*/),
|
|
||||||
"filters": [
|
|
||||||
"orderBy"
|
|
||||||
],
|
|
||||||
"handle": "connection",
|
|
||||||
"key": "MainLayout_OrganizationSelector_organizations",
|
|
||||||
"kind": "LinkedHandle",
|
|
||||||
"name": "organizations"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": [
|
|
||||||
{
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "filter",
|
|
||||||
"value": {
|
|
||||||
"statuses": [
|
|
||||||
"PENDING"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "first",
|
|
||||||
"value": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"concreteType": "InvitationConnection",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "invitations",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "totalCount",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": "invitations(filter:{\"statuses\":[\"PENDING\"]},first:1)"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": "organization",
|
"alias": "organization",
|
||||||
"args": (v4/*: any*/),
|
"args": (v5/*: any*/),
|
||||||
"concreteType": null,
|
"concreteType": null,
|
||||||
"kind": "LinkedField",
|
"kind": "LinkedField",
|
||||||
"name": "node",
|
"name": "node",
|
||||||
"plural": false,
|
"plural": false,
|
||||||
"selections": [
|
"selections": [
|
||||||
(v8/*: any*/),
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "__typename",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
(v1/*: any*/),
|
(v1/*: any*/),
|
||||||
{
|
{
|
||||||
"kind": "InlineFragment",
|
"kind": "InlineFragment",
|
||||||
"selections": [
|
"selections": [
|
||||||
(v5/*: any*/),
|
(v6/*: any*/),
|
||||||
(v6/*: any*/)
|
(v7/*: any*/)
|
||||||
],
|
],
|
||||||
"type": "Organization",
|
"type": "Organization",
|
||||||
"abstractKey": null
|
"abstractKey": null
|
||||||
@@ -337,16 +239,16 @@ return {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "6de25e54419d82cb6465c903a0afdd78",
|
"cacheID": "a8f9f58d27677c55b5a217617db83e27",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"name": "MainLayoutQuery",
|
"name": "MainLayoutQuery",
|
||||||
"operationKind": "query",
|
"operationKind": "query",
|
||||||
"text": "query MainLayoutQuery(\n $organizationId: ID!\n) {\n viewer {\n id\n user {\n fullName\n email\n id\n }\n ...MainLayout_OrganizationSelector_viewer\n }\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n logoUrl\n }\n id\n }\n}\n\nfragment MainLayout_OrganizationSelector_viewer on Viewer {\n organizations(first: 25, orderBy: {field: NAME, direction: ASC}) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n invitations(first: 1, filter: {statuses: [PENDING]}) {\n totalCount\n }\n}\n"
|
"text": "query MainLayoutQuery(\n $organizationId: ID!\n) {\n viewer {\n id\n user {\n fullName\n email\n id\n }\n invitations(first: 1, filter: {statuses: [PENDING]}) {\n totalCount\n }\n }\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n logoUrl\n }\n id\n }\n}\n"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "aaaca58a896839aa85ce7c2fcf75de39";
|
(node as any).hash = "17986fcea321c4567d86584d1a9f89c1";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
/**
|
|
||||||
* @generated SignedSource<<b0e167541047d61efc6d30382c062d0d>>
|
|
||||||
* @lightSyntaxTransform
|
|
||||||
* @nogrep
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { ReaderFragment } from 'relay-runtime';
|
|
||||||
import { FragmentRefs } from "relay-runtime";
|
|
||||||
export type MainLayout_OrganizationSelector_viewer$data = {
|
|
||||||
readonly invitations: {
|
|
||||||
readonly totalCount: number;
|
|
||||||
};
|
|
||||||
readonly organizations: {
|
|
||||||
readonly edges: ReadonlyArray<{
|
|
||||||
readonly node: {
|
|
||||||
readonly id: string;
|
|
||||||
readonly logoUrl: string | null | undefined;
|
|
||||||
readonly name: string;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
readonly pageInfo: {
|
|
||||||
readonly endCursor: any | null | undefined;
|
|
||||||
readonly hasNextPage: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
readonly " $fragmentType": "MainLayout_OrganizationSelector_viewer";
|
|
||||||
};
|
|
||||||
export type MainLayout_OrganizationSelector_viewer$key = {
|
|
||||||
readonly " $data"?: MainLayout_OrganizationSelector_viewer$data;
|
|
||||||
readonly " $fragmentSpreads": FragmentRefs<"MainLayout_OrganizationSelector_viewer">;
|
|
||||||
};
|
|
||||||
|
|
||||||
import MainLayoutOrganizationSelectorPaginationQuery_graphql from './MainLayoutOrganizationSelectorPaginationQuery.graphql';
|
|
||||||
|
|
||||||
const node: ReaderFragment = (function(){
|
|
||||||
var v0 = [
|
|
||||||
"organizations"
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
"argumentDefinitions": [
|
|
||||||
{
|
|
||||||
"defaultValue": null,
|
|
||||||
"kind": "LocalArgument",
|
|
||||||
"name": "after"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"defaultValue": 25,
|
|
||||||
"kind": "LocalArgument",
|
|
||||||
"name": "first"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"kind": "Fragment",
|
|
||||||
"metadata": {
|
|
||||||
"connection": [
|
|
||||||
{
|
|
||||||
"count": "first",
|
|
||||||
"cursor": "after",
|
|
||||||
"direction": "forward",
|
|
||||||
"path": (v0/*: any*/)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"refetch": {
|
|
||||||
"connection": {
|
|
||||||
"forward": {
|
|
||||||
"count": "first",
|
|
||||||
"cursor": "after"
|
|
||||||
},
|
|
||||||
"backward": null,
|
|
||||||
"path": (v0/*: any*/)
|
|
||||||
},
|
|
||||||
"fragmentPathInResult": [
|
|
||||||
"viewer"
|
|
||||||
],
|
|
||||||
"operation": MainLayoutOrganizationSelectorPaginationQuery_graphql
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"name": "MainLayout_OrganizationSelector_viewer",
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": "organizations",
|
|
||||||
"args": [
|
|
||||||
{
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "orderBy",
|
|
||||||
"value": {
|
|
||||||
"direction": "ASC",
|
|
||||||
"field": "NAME"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"concreteType": "OrganizationConnection",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "__MainLayout_OrganizationSelector_organizations_connection",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "OrganizationEdge",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "edges",
|
|
||||||
"plural": true,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "Organization",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "node",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "id",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "name",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "logoUrl",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "__typename",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "cursor",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "PageInfo",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "pageInfo",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "hasNextPage",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "endCursor",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": "__MainLayout_OrganizationSelector_organizations_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": [
|
|
||||||
{
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "filter",
|
|
||||||
"value": {
|
|
||||||
"statuses": [
|
|
||||||
"PENDING"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "first",
|
|
||||||
"value": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"concreteType": "InvitationConnection",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "invitations",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "totalCount",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": "invitations(filter:{\"statuses\":[\"PENDING\"]},first:1)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"type": "Viewer",
|
|
||||||
"abstractKey": null
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
(node as any).hash = "3e00f1a6f8089fc59144807a07fd1bdf";
|
|
||||||
|
|
||||||
export default node;
|
|
||||||
@@ -1,110 +1,138 @@
|
|||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { useLazyLoadQuery } from "react-relay";
|
import { useEffect, useState } from "react";
|
||||||
import { graphql } from "relay-runtime";
|
|
||||||
import type { OrganizationsPageQuery as OrganizationsPageQueryType } from "./__generated__/OrganizationsPageQuery.graphql";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
IconPlusLarge,
|
IconPlusLarge,
|
||||||
|
IconCheckmark1,
|
||||||
|
IconLock,
|
||||||
|
IconClock,
|
||||||
|
Badge,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { usePageTitle } from "@probo/hooks";
|
import { usePageTitle } from "@probo/hooks";
|
||||||
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
|
||||||
import { formatDate } from "@probo/helpers";
|
import { formatDate } from "@probo/helpers";
|
||||||
|
|
||||||
const OrganizationsPageQuery = graphql`
|
interface Organization {
|
||||||
query OrganizationsPageQuery {
|
id: string;
|
||||||
viewer {
|
name: string;
|
||||||
organizations(first: 1000, orderBy: {field: NAME, direction: ASC}) @connection(key: "OrganizationsPage_organizations") {
|
logoUrl?: string | null;
|
||||||
__id
|
authenticationMethod: string;
|
||||||
edges {
|
authStatus: "authenticated" | "unauthenticated" | "expired";
|
||||||
node {
|
loginUrl: string;
|
||||||
id
|
}
|
||||||
name
|
|
||||||
logoUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
invitations(first: 1000, orderBy: {field: CREATED_AT, direction: DESC}, filter: {statuses: [PENDING]}) @connection(key: "OrganizationsPage_invitations") {
|
|
||||||
__id
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
email
|
|
||||||
fullName
|
|
||||||
role
|
|
||||||
expiresAt
|
|
||||||
acceptedAt
|
|
||||||
createdAt
|
|
||||||
organization {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const acceptInvitationMutation = graphql`
|
interface Invitation {
|
||||||
mutation OrganizationsPage_AcceptInvitationMutation($input: AcceptInvitationInput!) {
|
id: string;
|
||||||
acceptInvitation(input: $input) {
|
email: string;
|
||||||
invitation {
|
fullName: string;
|
||||||
id
|
role: string;
|
||||||
}
|
expiresAt: string;
|
||||||
}
|
acceptedAt?: string | null;
|
||||||
}
|
createdAt: string;
|
||||||
`;
|
organization: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function OrganizationsPage() {
|
export default function OrganizationsPage() {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const data = useLazyLoadQuery<OrganizationsPageQueryType>(
|
|
||||||
OrganizationsPageQuery,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
const organizations = data.viewer.organizations.edges.map(
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
(edge) => edge.node
|
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(true);
|
||||||
);
|
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||||
|
const [isLoadingInvitations, setIsLoadingInvitations] = useState(true);
|
||||||
|
const [isAccepting, setIsAccepting] = useState(false);
|
||||||
|
|
||||||
const pendingInvitations = data.viewer.invitations.edges.map(
|
// Fetch organizations from REST endpoint
|
||||||
(edge) => edge.node
|
useEffect(() => {
|
||||||
);
|
const fetchOrganizations = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/auth/organizations', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
const [acceptInvitation, isAccepting] = useMutationWithToasts(
|
if (!response.ok) {
|
||||||
acceptInvitationMutation,
|
throw new Error('Failed to fetch organizations');
|
||||||
{
|
}
|
||||||
successMessage: __("Invitation accepted successfully"),
|
|
||||||
errorMessage: __("Failed to accept invitation"),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAcceptInvitation = (invitationId: string, organizationId: string) => {
|
const data: { organizations: Organization[] } = await response.json();
|
||||||
acceptInvitation({
|
setOrganizations(data.organizations);
|
||||||
variables: {
|
} catch (err) {
|
||||||
input: {
|
console.error('Failed to fetch organizations:', err);
|
||||||
invitationId,
|
} finally {
|
||||||
|
setIsLoadingOrganizations(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchOrganizations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch pending invitations from REST endpoint
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchInvitations = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/auth/invitations', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch invitations');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: { invitations: Invitation[] } = await response.json();
|
||||||
|
setInvitations(data.invitations);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch invitations:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingInvitations(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchInvitations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAcceptInvitation = async (invitationId: string, organizationId: string) => {
|
||||||
|
setIsAccepting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/auth/invitations/accept', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
},
|
credentials: 'include',
|
||||||
onSuccess: () => {
|
body: JSON.stringify({ invitationId }),
|
||||||
navigate(`/organizations/${organizationId}`);
|
});
|
||||||
},
|
|
||||||
});
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to accept invitation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the organization after successful acceptance
|
||||||
|
navigate(`/organizations/${organizationId}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to accept invitation:', err);
|
||||||
|
alert(__('Failed to accept invitation'));
|
||||||
|
} finally {
|
||||||
|
setIsAccepting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
usePageTitle(__("Select an organization"));
|
usePageTitle(__("Select an organization"));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (organizations.length === 1 && pendingInvitations.length === 0) {
|
// Only auto-navigate once both organizations and invitations are loaded
|
||||||
navigate(`/organizations/${organizations[0].id}`);
|
if (!isLoadingOrganizations && !isLoadingInvitations) {
|
||||||
} else if (organizations.length === 0 && pendingInvitations.length === 0) {
|
if (organizations.length === 1 && invitations.length === 0) {
|
||||||
navigate("/organizations/new");
|
navigate(`/organizations/${organizations[0].id}`);
|
||||||
|
} else if (organizations.length === 0 && invitations.length === 0) {
|
||||||
|
navigate("/organizations/new");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [organizations, pendingInvitations]);
|
}, [organizations, invitations, isLoadingOrganizations, isLoadingInvitations, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -113,12 +141,12 @@ export default function OrganizationsPage() {
|
|||||||
{__("Select an organization")}
|
{__("Select an organization")}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="space-y-4 w-full">
|
<div className="space-y-4 w-full">
|
||||||
{pendingInvitations.length > 0 && (
|
{invitations.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="text-xl font-semibold">
|
<h2 className="text-xl font-semibold">
|
||||||
{__("Pending invitations")}
|
{__("Pending invitations")}
|
||||||
</h2>
|
</h2>
|
||||||
{pendingInvitations.map((invitation) => (
|
{invitations.map((invitation) => (
|
||||||
<InvitationCard
|
<InvitationCard
|
||||||
key={invitation.id}
|
key={invitation.id}
|
||||||
invitation={invitation}
|
invitation={invitation}
|
||||||
@@ -130,7 +158,7 @@ export default function OrganizationsPage() {
|
|||||||
)}
|
)}
|
||||||
{organizations.length > 0 && (
|
{organizations.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{pendingInvitations.length > 0 && (
|
{invitations.length > 0 && (
|
||||||
<h2 className="text-xl font-semibold">
|
<h2 className="text-xl font-semibold">
|
||||||
{__("Your organizations")}
|
{__("Your organizations")}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -166,18 +194,7 @@ export default function OrganizationsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type InvitationCardProps = {
|
type InvitationCardProps = {
|
||||||
invitation: {
|
invitation: Invitation;
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
fullName: string;
|
|
||||||
role: string;
|
|
||||||
expiresAt: string;
|
|
||||||
createdAt: string;
|
|
||||||
organization: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
onAccept: (invitationId: string, organizationId: string) => void;
|
onAccept: (invitationId: string, organizationId: string) => void;
|
||||||
isAccepting: boolean;
|
isAccepting: boolean;
|
||||||
};
|
};
|
||||||
@@ -211,35 +228,106 @@ function InvitationCard({ invitation, onAccept, isAccepting }: InvitationCardPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OrganizationCardProps = {
|
type OrganizationCardProps = {
|
||||||
organization: {
|
organization: Organization;
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
logoUrl: string | null | undefined;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function OrganizationCard({ organization }: OrganizationCardProps) {
|
function OrganizationCard({ organization }: OrganizationCardProps) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
|
const isAuthenticated = organization.authStatus === "authenticated";
|
||||||
|
const isExpired = organization.authStatus === "expired";
|
||||||
|
const needsAuth = organization.authStatus === "unauthenticated";
|
||||||
|
|
||||||
|
// Determine target URL and button text based on auth status
|
||||||
|
const targetUrl = isAuthenticated
|
||||||
|
? `/organizations/${organization.id}`
|
||||||
|
: organization.loginUrl;
|
||||||
|
|
||||||
|
const getAuthBadge = () => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Badge variant="success" className="flex items-center gap-1">
|
||||||
|
<IconCheckmark1 size={14} />
|
||||||
|
{__("Authenticated")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
return (
|
||||||
|
<Badge variant="warning" className="flex items-center gap-1">
|
||||||
|
<IconClock size={14} />
|
||||||
|
{__("Session expired")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsAuth) {
|
||||||
|
return (
|
||||||
|
<Badge variant="neutral" className="flex items-center gap-1">
|
||||||
|
<IconLock size={14} />
|
||||||
|
{__("Authentication required")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonText = () => {
|
||||||
|
if (isAuthenticated) return __("Select");
|
||||||
|
if (organization.authenticationMethod === "saml") return __("Login with SAML");
|
||||||
|
return __("Login");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if the URL is a backend SAML endpoint
|
||||||
|
const isSAMLUrl = targetUrl.includes('/auth/saml/');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padded className="w-full">
|
<Card padded className="w-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Link
|
{isSAMLUrl ? (
|
||||||
to={`/organizations/${organization.id}`}
|
<a
|
||||||
className="flex items-center gap-4 hover:text-primary flex-1"
|
href={targetUrl}
|
||||||
>
|
className="flex items-center gap-4 hover:text-primary flex-1"
|
||||||
<Avatar
|
>
|
||||||
src={organization.logoUrl}
|
<Avatar
|
||||||
name={organization.name}
|
src={organization.logoUrl}
|
||||||
size="l"
|
name={organization.name}
|
||||||
/>
|
size="l"
|
||||||
<h2 className="font-semibold text-xl">{organization.name}</h2>
|
/>
|
||||||
</Link>
|
<div className="flex flex-col gap-1">
|
||||||
|
<h2 className="font-semibold text-xl">{organization.name}</h2>
|
||||||
|
{getAuthBadge()}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={targetUrl}
|
||||||
|
className="flex items-center gap-4 hover:text-primary flex-1"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={organization.logoUrl}
|
||||||
|
name={organization.name}
|
||||||
|
size="l"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h2 className="font-semibold text-xl">{organization.name}</h2>
|
||||||
|
{getAuthBadge()}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link to={`/organizations/${organization.id}`}>
|
{isSAMLUrl ? (
|
||||||
{__("Select")}
|
<a href={targetUrl}>
|
||||||
</Link>
|
{getButtonText()}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link to={targetUrl}>
|
||||||
|
{getButtonText()}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,420 +0,0 @@
|
|||||||
/**
|
|
||||||
* @generated SignedSource<<580387b50cff64395ba9afd4c7454d20>>
|
|
||||||
* @lightSyntaxTransform
|
|
||||||
* @nogrep
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* tslint:disable */
|
|
||||||
/* eslint-disable */
|
|
||||||
// @ts-nocheck
|
|
||||||
|
|
||||||
import { ConcreteRequest } from 'relay-runtime';
|
|
||||||
export type OrganizationsPageQuery$variables = Record<PropertyKey, never>;
|
|
||||||
export type OrganizationsPageQuery$data = {
|
|
||||||
readonly viewer: {
|
|
||||||
readonly invitations: {
|
|
||||||
readonly __id: string;
|
|
||||||
readonly edges: ReadonlyArray<{
|
|
||||||
readonly node: {
|
|
||||||
readonly acceptedAt: any | null | undefined;
|
|
||||||
readonly createdAt: any;
|
|
||||||
readonly email: string;
|
|
||||||
readonly expiresAt: any;
|
|
||||||
readonly fullName: string;
|
|
||||||
readonly id: string;
|
|
||||||
readonly organization: {
|
|
||||||
readonly id: string;
|
|
||||||
readonly name: string;
|
|
||||||
};
|
|
||||||
readonly role: string;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
readonly organizations: {
|
|
||||||
readonly __id: string;
|
|
||||||
readonly edges: ReadonlyArray<{
|
|
||||||
readonly node: {
|
|
||||||
readonly id: string;
|
|
||||||
readonly logoUrl: string | null | undefined;
|
|
||||||
readonly name: string;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
export type OrganizationsPageQuery = {
|
|
||||||
response: OrganizationsPageQuery$data;
|
|
||||||
variables: OrganizationsPageQuery$variables;
|
|
||||||
};
|
|
||||||
|
|
||||||
const node: ConcreteRequest = (function(){
|
|
||||||
var v0 = {
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "orderBy",
|
|
||||||
"value": {
|
|
||||||
"direction": "ASC",
|
|
||||||
"field": "NAME"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
v1 = {
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "id",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
v2 = {
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "name",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
v3 = {
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "__typename",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
v4 = {
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "cursor",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
v5 = {
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "PageInfo",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "pageInfo",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "endCursor",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "hasNextPage",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
v6 = {
|
|
||||||
"kind": "ClientExtension",
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "__id",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
v7 = [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "OrganizationEdge",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "edges",
|
|
||||||
"plural": true,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "Organization",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "node",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
(v1/*: any*/),
|
|
||||||
(v2/*: any*/),
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "logoUrl",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v3/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v4/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v5/*: any*/),
|
|
||||||
(v6/*: any*/)
|
|
||||||
],
|
|
||||||
v8 = {
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "filter",
|
|
||||||
"value": {
|
|
||||||
"statuses": [
|
|
||||||
"PENDING"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
v9 = {
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "orderBy",
|
|
||||||
"value": {
|
|
||||||
"direction": "DESC",
|
|
||||||
"field": "CREATED_AT"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
v10 = [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "InvitationEdge",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "edges",
|
|
||||||
"plural": true,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "Invitation",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "node",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
(v1/*: any*/),
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "email",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "fullName",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "role",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "expiresAt",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "acceptedAt",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "createdAt",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "Organization",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "organization",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
(v1/*: any*/),
|
|
||||||
(v2/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v3/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v4/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v5/*: any*/),
|
|
||||||
(v6/*: any*/)
|
|
||||||
],
|
|
||||||
v11 = {
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "first",
|
|
||||||
"value": 1000
|
|
||||||
},
|
|
||||||
v12 = [
|
|
||||||
(v11/*: any*/),
|
|
||||||
(v0/*: any*/)
|
|
||||||
],
|
|
||||||
v13 = [
|
|
||||||
(v8/*: any*/),
|
|
||||||
(v11/*: any*/),
|
|
||||||
(v9/*: any*/)
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
"fragment": {
|
|
||||||
"argumentDefinitions": [],
|
|
||||||
"kind": "Fragment",
|
|
||||||
"metadata": null,
|
|
||||||
"name": "OrganizationsPageQuery",
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "Viewer",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "viewer",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": "organizations",
|
|
||||||
"args": [
|
|
||||||
(v0/*: any*/)
|
|
||||||
],
|
|
||||||
"concreteType": "OrganizationConnection",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "__OrganizationsPage_organizations_connection",
|
|
||||||
"plural": false,
|
|
||||||
"selections": (v7/*: any*/),
|
|
||||||
"storageKey": "__OrganizationsPage_organizations_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": "invitations",
|
|
||||||
"args": [
|
|
||||||
(v8/*: any*/),
|
|
||||||
(v9/*: any*/)
|
|
||||||
],
|
|
||||||
"concreteType": "InvitationConnection",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "__OrganizationsPage_invitations_connection",
|
|
||||||
"plural": false,
|
|
||||||
"selections": (v10/*: any*/),
|
|
||||||
"storageKey": "__OrganizationsPage_invitations_connection(filter:{\"statuses\":[\"PENDING\"]},orderBy:{\"direction\":\"DESC\",\"field\":\"CREATED_AT\"})"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"type": "Query",
|
|
||||||
"abstractKey": null
|
|
||||||
},
|
|
||||||
"kind": "Request",
|
|
||||||
"operation": {
|
|
||||||
"argumentDefinitions": [],
|
|
||||||
"kind": "Operation",
|
|
||||||
"name": "OrganizationsPageQuery",
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "Viewer",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "viewer",
|
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": (v12/*: any*/),
|
|
||||||
"concreteType": "OrganizationConnection",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "organizations",
|
|
||||||
"plural": false,
|
|
||||||
"selections": (v7/*: any*/),
|
|
||||||
"storageKey": "organizations(first:1000,orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": (v12/*: any*/),
|
|
||||||
"filters": [
|
|
||||||
"orderBy"
|
|
||||||
],
|
|
||||||
"handle": "connection",
|
|
||||||
"key": "OrganizationsPage_organizations",
|
|
||||||
"kind": "LinkedHandle",
|
|
||||||
"name": "organizations"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": (v13/*: any*/),
|
|
||||||
"concreteType": "InvitationConnection",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "invitations",
|
|
||||||
"plural": false,
|
|
||||||
"selections": (v10/*: any*/),
|
|
||||||
"storageKey": "invitations(filter:{\"statuses\":[\"PENDING\"]},first:1000,orderBy:{\"direction\":\"DESC\",\"field\":\"CREATED_AT\"})"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": (v13/*: any*/),
|
|
||||||
"filters": [
|
|
||||||
"orderBy",
|
|
||||||
"filter"
|
|
||||||
],
|
|
||||||
"handle": "connection",
|
|
||||||
"key": "OrganizationsPage_invitations",
|
|
||||||
"kind": "LinkedHandle",
|
|
||||||
"name": "invitations"
|
|
||||||
},
|
|
||||||
(v1/*: any*/)
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"params": {
|
|
||||||
"cacheID": "1b2e528133e56da643b9f8214a2f7192",
|
|
||||||
"id": null,
|
|
||||||
"metadata": {
|
|
||||||
"connection": [
|
|
||||||
{
|
|
||||||
"count": null,
|
|
||||||
"cursor": null,
|
|
||||||
"direction": "forward",
|
|
||||||
"path": [
|
|
||||||
"viewer",
|
|
||||||
"organizations"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"count": null,
|
|
||||||
"cursor": null,
|
|
||||||
"direction": "forward",
|
|
||||||
"path": [
|
|
||||||
"viewer",
|
|
||||||
"invitations"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"name": "OrganizationsPageQuery",
|
|
||||||
"operationKind": "query",
|
|
||||||
"text": "query OrganizationsPageQuery {\n viewer {\n organizations(first: 1000, orderBy: {field: NAME, direction: ASC}) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n invitations(first: 1000, orderBy: {field: CREATED_AT, direction: DESC}, filter: {statuses: [PENDING]}) {\n edges {\n node {\n id\n email\n fullName\n role\n expiresAt\n acceptedAt\n createdAt\n organization {\n id\n name\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n id\n }\n}\n"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
(node as any).hash = "ecd2534f36aa95ff7ff864ff22f09189";
|
|
||||||
|
|
||||||
export default node;
|
|
||||||
@@ -111,7 +111,7 @@ export default function ConfirmEmailPage() {
|
|||||||
<p className="text-green-600 dark:text-green-400">
|
<p className="text-green-600 dark:text-green-400">
|
||||||
{__("Your email has been confirmed successfully!")}
|
{__("Your email has been confirmed successfully!")}
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => navigate("/auth/login")} className="w-full">
|
<Button onClick={() => navigate("/authentication/login")} className="w-full">
|
||||||
{__("Proceed to Login")}
|
{__("Proceed to Login")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +141,7 @@ export default function ConfirmEmailPage() {
|
|||||||
{!isConfirmed && (
|
{!isConfirmed && (
|
||||||
<p className="text-sm text-txt-tertiary">
|
<p className="text-sm text-txt-tertiary">
|
||||||
<Link
|
<Link
|
||||||
to="/auth/login"
|
to="/authentication/login"
|
||||||
className="underline text-txt-primary hover:text-txt-secondary"
|
className="underline text-txt-primary hover:text-txt-secondary"
|
||||||
>
|
>
|
||||||
{__("Back to Login")}
|
{__("Back to Login")}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function ForgotPasswordPage() {
|
|||||||
|
|
||||||
const onSubmit = handleSubmit(async (data) => {
|
const onSubmit = handleSubmit(async (data) => {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
buildEndpoint("/api/console/v1/auth/forget-password"),
|
buildEndpoint("/auth/forget-password"),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -81,7 +81,7 @@ export default function ForgotPasswordPage() {
|
|||||||
<p className="text-sm text-txt-tertiary">
|
<p className="text-sm text-txt-tertiary">
|
||||||
{__("Remember your password?")}{" "}
|
{__("Remember your password?")}{" "}
|
||||||
<Link
|
<Link
|
||||||
to="/auth/login"
|
to="/authentication/login"
|
||||||
className="underline text-txt-primary hover:text-txt-secondary"
|
className="underline text-txt-primary hover:text-txt-secondary"
|
||||||
>
|
>
|
||||||
{__("Back to login")}
|
{__("Back to login")}
|
||||||
@@ -124,7 +124,7 @@ export default function ForgotPasswordPage() {
|
|||||||
<p className="text-sm text-txt-tertiary">
|
<p className="text-sm text-txt-tertiary">
|
||||||
{__("Remember your password?")}{" "}
|
{__("Remember your password?")}{" "}
|
||||||
<Link
|
<Link
|
||||||
to="/auth/login"
|
to="/authentication/login"
|
||||||
className="underline text-txt-primary hover:text-txt-secondary"
|
className="underline text-txt-primary hover:text-txt-secondary"
|
||||||
>
|
>
|
||||||
{__("Back to login")}
|
{__("Back to login")}
|
||||||
|
|||||||
@@ -1,81 +1,261 @@
|
|||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Button, Field, useToast } from "@probo/ui";
|
import { Button, Field, IconChevronLeft, useToast } from "@probo/ui";
|
||||||
import type { FormEventHandler } from "react";
|
import type { FormEventHandler } from "react";
|
||||||
import { Link } from "react-router";
|
import { useState } from "react";
|
||||||
|
import { Link, useSearchParams } from "react-router";
|
||||||
import { buildEndpoint } from "/providers/RelayProviders";
|
import { buildEndpoint } from "/providers/RelayProviders";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
const authMethod = searchParams.get("method");
|
||||||
|
const initialMode = authMethod === "password" ? "password" : authMethod === "sso" ? "sso" : "default";
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<"default" | "password" | "sso">(initialMode);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
|
||||||
|
const handlePasswordLogin: FormEventHandler<HTMLFormElement> = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const email = formData.get("email")?.toString();
|
const emailValue = formData.get("email")?.toString();
|
||||||
const password = formData.get("password")?.toString();
|
const passwordValue = formData.get("password")?.toString();
|
||||||
|
|
||||||
fetch(buildEndpoint("/api/console/v1/auth/login"), {
|
if (!emailValue || !passwordValue) return;
|
||||||
method: "POST",
|
|
||||||
headers: {
|
setIsLoading(true);
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
try {
|
||||||
body: JSON.stringify({ email, password }),
|
const res = await fetch(buildEndpoint("/auth/login"), {
|
||||||
})
|
method: "POST",
|
||||||
.then(async (res) => {
|
headers: {
|
||||||
if (!res.ok) {
|
"Content-Type": "application/json",
|
||||||
const error = await res.json();
|
},
|
||||||
throw new Error(error.message || __("Failed to login"));
|
body: JSON.stringify({ email: emailValue, password: passwordValue }),
|
||||||
}
|
|
||||||
window.location.href = "/";
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
title: __("Error"),
|
|
||||||
description: e.message as string,
|
|
||||||
variant: "error",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.message || __("Failed to login"));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = "/";
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: e.message as string,
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSSOLogin: FormEventHandler<HTMLFormElement> = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const emailValue = formData.get("email")?.toString();
|
||||||
|
|
||||||
|
if (!emailValue) return;
|
||||||
|
|
||||||
|
setIsChecking(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(buildEndpoint("/auth/check-sso"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email: emailValue }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.message || __("SSO not available for this email domain"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.ssoAvailable && data.samlConfigId) {
|
||||||
|
window.location.href = buildEndpoint(
|
||||||
|
`/auth/saml/login/${data.samlConfigId}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error(__("SSO not available for this email domain"));
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: e.message as string,
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setMode("default");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === "default") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-center text-2xl font-bold">
|
||||||
|
{__("Login to your account")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-txt-tertiary mt-1 mb-6">
|
||||||
|
{__("Choose your login method")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setMode("password")}
|
||||||
|
>
|
||||||
|
{__("Login with Email")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-border"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<span
|
||||||
|
className="px-4 text-xs uppercase text-txt-secondary"
|
||||||
|
style={{ backgroundColor: "var(--color-level-0)" }}
|
||||||
|
>
|
||||||
|
{__("Or")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setMode("sso")}
|
||||||
|
>
|
||||||
|
{__("Login with SSO")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center mt-6 text-sm text-txt-secondary">
|
||||||
|
{__("Don't have an account ?")}{" "}
|
||||||
|
<Link to="/authentication/register" className="underline hover:text-txt-primary">
|
||||||
|
{__("Register")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-sm text-txt-secondary">
|
||||||
|
{__("Forgot password?")}{" "}
|
||||||
|
<Link
|
||||||
|
to="/authentication/forgot-password"
|
||||||
|
className="underline hover:text-txt-primary"
|
||||||
|
>
|
||||||
|
{__("Reset password")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "password") {
|
||||||
|
return (
|
||||||
|
<form className="space-y-4" onSubmit={handlePasswordLogin}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex items-center gap-2 text-txt-secondary hover:text-txt-primary transition-colors mb-4"
|
||||||
|
>
|
||||||
|
<IconChevronLeft size={20} />
|
||||||
|
<span className="text-sm">{__("Back")}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 className="text-center text-2xl font-bold">
|
||||||
|
{__("Login with Email")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-txt-tertiary mt-1 mb-6">
|
||||||
|
{__("Enter your email and password")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
placeholder={__("Email")}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
label={__("Email")}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
required
|
||||||
|
placeholder={__("Password")}
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
label={__("Password")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? __("Logging in...") : __("Login")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center mt-6 text-sm text-txt-secondary">
|
||||||
|
{__("Don't have an account ?")}{" "}
|
||||||
|
<Link to="/authentication/register" className="underline hover:text-txt-primary">
|
||||||
|
{__("Register")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-sm text-txt-secondary">
|
||||||
|
{__("Forgot password?")}{" "}
|
||||||
|
<Link
|
||||||
|
to="/authentication/forgot-password"
|
||||||
|
className="underline hover:text-txt-primary"
|
||||||
|
>
|
||||||
|
{__("Reset password")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
<form className="space-y-4" onSubmit={handleSSOLogin}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBack}
|
||||||
|
className="flex items-center gap-2 text-txt-secondary hover:text-txt-primary transition-colors mb-4"
|
||||||
|
>
|
||||||
|
<IconChevronLeft size={20} />
|
||||||
|
<span className="text-sm">{__("Back")}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<h1 className="text-center text-2xl font-bold">
|
<h1 className="text-center text-2xl font-bold">
|
||||||
{__("Login to your account")}
|
{__("Login with SSO")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-center text-txt-tertiary mt-1 mb-6">
|
<p className="text-center text-txt-tertiary mt-1 mb-6">
|
||||||
{__("Enter your email below to login to your account")}
|
{__("Enter your work email to continue with SSO")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
required
|
required
|
||||||
placeholder={__("Email")}
|
placeholder={__("Work Email")}
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
label={__("Email")}
|
label={__("Work Email")}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Field
|
|
||||||
required
|
<Button className="w-full" disabled={isChecking}>
|
||||||
placeholder={__("Password")}
|
{isChecking ? __("Checking...") : __("Continue with SSO")}
|
||||||
name="password"
|
</Button>
|
||||||
type="password"
|
|
||||||
label={__("Password")}
|
|
||||||
/>
|
|
||||||
<Button className="w-full">{__("Login")}</Button>
|
|
||||||
|
|
||||||
<div className="text-center mt-6 text-sm text-txt-secondary">
|
<div className="text-center mt-6 text-sm text-txt-secondary">
|
||||||
{__("Don't have an account ?")}{" "}
|
{__("Don't have an account ?")}{" "}
|
||||||
<Link to="/auth/register" className="underline hover:text-txt-primary">
|
<Link to="/authentication/register" className="underline hover:text-txt-primary">
|
||||||
{__("Register")}
|
{__("Register")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center mt-6 text-sm text-txt-secondary">
|
|
||||||
{__("Forgot password?")}{" "}
|
|
||||||
<Link
|
|
||||||
to="/auth/forgot-password"
|
|
||||||
className="underline hover:text-txt-primary"
|
|
||||||
>
|
|
||||||
{__("Reset password")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function RegisterPage() {
|
|||||||
|
|
||||||
const onSubmit = handleSubmit(async (data) => {
|
const onSubmit = handleSubmit(async (data) => {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
buildEndpoint("/api/console/v1/auth/register"),
|
buildEndpoint("/auth/register"),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -106,7 +106,7 @@ export default function RegisterPage() {
|
|||||||
<p className="text-sm text-txt-tertiary">
|
<p className="text-sm text-txt-tertiary">
|
||||||
{__("Already have an account?")}{" "}
|
{__("Already have an account?")}{" "}
|
||||||
<Link
|
<Link
|
||||||
to="/auth/login"
|
to="/authentication/login"
|
||||||
className="underline text-txt-primary hover:text-txt-secondary"
|
className="underline text-txt-primary hover:text-txt-secondary"
|
||||||
>
|
>
|
||||||
{__("Log in here")}
|
{__("Log in here")}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function ResetPasswordPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
buildEndpoint("/api/console/v1/auth/reset-password"),
|
buildEndpoint("/auth/reset-password"),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -74,7 +74,7 @@ export default function ResetPasswordPage() {
|
|||||||
description: __("Password reset successfully"),
|
description: __("Password reset successfully"),
|
||||||
variant: "success",
|
variant: "success",
|
||||||
});
|
});
|
||||||
navigate("/auth/login", { replace: true });
|
navigate("/authentication/login", { replace: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
usePageTitle(__("Reset password"));
|
usePageTitle(__("Reset password"));
|
||||||
@@ -118,7 +118,7 @@ export default function ResetPasswordPage() {
|
|||||||
<p className="text-sm text-txt-tertiary">
|
<p className="text-sm text-txt-tertiary">
|
||||||
{__("Remember your password?")}{" "}
|
{__("Remember your password?")}{" "}
|
||||||
<Link
|
<Link
|
||||||
to="/auth/login"
|
to="/authentication/login"
|
||||||
className="underline text-txt-primary hover:text-txt-secondary"
|
className="underline text-txt-primary hover:text-txt-secondary"
|
||||||
>
|
>
|
||||||
{__("Log in here")}
|
{__("Log in here")}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function SignupFromInvitationPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
buildEndpoint("/api/console/v1/auth/signup-from-invitation"),
|
buildEndpoint("/auth/signup-from-invitation"),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -125,7 +125,7 @@ export default function SignupFromInvitationPage() {
|
|||||||
<p className="text-sm text-txt-tertiary">
|
<p className="text-sm text-txt-tertiary">
|
||||||
{__("Already have an account?")}{" "}
|
{__("Already have an account?")}{" "}
|
||||||
<Link
|
<Link
|
||||||
to="/auth/login"
|
to="/authentication/login"
|
||||||
className="underline text-txt-primary hover:text-txt-secondary"
|
className="underline text-txt-primary hover:text-txt-secondary"
|
||||||
>
|
>
|
||||||
{__("Log in here")}
|
{__("Log in here")}
|
||||||
|
|||||||
@@ -1,213 +1,41 @@
|
|||||||
import {
|
import { Outlet } from "react-router";
|
||||||
Avatar,
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogFooter,
|
|
||||||
Field,
|
|
||||||
FileButton,
|
|
||||||
IconTrashCan,
|
|
||||||
Label,
|
|
||||||
PageHeader,
|
|
||||||
Spinner,
|
|
||||||
TabBadge,
|
|
||||||
TabItem,
|
|
||||||
Tabs,
|
|
||||||
Tbody,
|
|
||||||
Td,
|
|
||||||
Textarea,
|
|
||||||
Th,
|
|
||||||
Thead,
|
|
||||||
Tr,
|
|
||||||
useConfirm,
|
|
||||||
useDialogRef,
|
|
||||||
} from "@probo/ui";
|
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import type { PreloadedQuery } from "react-relay";
|
import type { PreloadedQuery } from "react-relay";
|
||||||
import type { OrganizationGraph_ViewQuery } from "/hooks/graph/__generated__/OrganizationGraph_ViewQuery.graphql";
|
import type { OrganizationGraph_ViewQuery } from "/hooks/graph/__generated__/OrganizationGraph_ViewQuery.graphql";
|
||||||
import { useFragment, usePreloadedQuery, usePaginationFragment } from "react-relay";
|
import { usePreloadedQuery } from "react-relay";
|
||||||
import { organizationViewQuery } from "/hooks/graph/OrganizationGraph";
|
import { organizationViewQuery } from "/hooks/graph/OrganizationGraph";
|
||||||
import { graphql } from "relay-runtime";
|
import {
|
||||||
import { SortableTable, SortableTh } from "/components/SortableTable";
|
IconSettingsGear2,
|
||||||
import clsx from "clsx";
|
IconPeopleAdd,
|
||||||
import type { SettingsPageFragment$key } from "./__generated__/SettingsPageFragment.graphql";
|
IconStore,
|
||||||
import type {
|
IconLock,
|
||||||
SettingsPageMembershipsFragment$data,
|
PageHeader,
|
||||||
SettingsPageMembershipsFragment$key
|
TabLink,
|
||||||
} from "./__generated__/SettingsPageMembershipsFragment.graphql";
|
Tabs,
|
||||||
import type {
|
} from "@probo/ui";
|
||||||
SettingsPageInvitationsFragment$data,
|
|
||||||
SettingsPageInvitationsFragment$key
|
|
||||||
} from "./__generated__/SettingsPageInvitationsFragment.graphql";
|
|
||||||
import { useState, type ChangeEventHandler, useEffect, useRef } from "react";
|
|
||||||
import { sprintf } from "@probo/helpers";
|
|
||||||
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
|
||||||
import { z } from "zod";
|
|
||||||
import type { NodeOf } from "/types";
|
|
||||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||||
import { InviteUserDialog } from "/components/organizations/InviteUserDialog";
|
import { graphql } from "relay-runtime";
|
||||||
import { useDeleteOrganizationMutation } from "/hooks/graph/OrganizationGraph";
|
import type { SettingsPageFragment$key } from "./__generated__/SettingsPageFragment.graphql";
|
||||||
import { useNavigate } from "react-router";
|
import { useFragment } from "react-relay";
|
||||||
import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog";
|
|
||||||
import { CustomDomainManager } from "/components/customDomains/CustomDomainManager";
|
|
||||||
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
|
||||||
|
|
||||||
const organizationSchema = z.object({
|
|
||||||
name: z.string().min(1, "Organization name is required"),
|
|
||||||
description: z.string().optional(),
|
|
||||||
websiteUrl: z.string().optional(),
|
|
||||||
email: z.string().optional(),
|
|
||||||
headquarterAddress: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type OrganizationFormData = z.infer<typeof organizationSchema>;
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
queryRef: PreloadedQuery<OrganizationGraph_ViewQuery>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const organizationFragment = graphql`
|
const organizationFragment = graphql`
|
||||||
fragment SettingsPageFragment on Organization {
|
fragment SettingsPageFragment on Organization {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
logoUrl
|
...GeneralSettingsTabFragment
|
||||||
horizontalLogoUrl
|
...MembersSettingsTabMembershipsFragment
|
||||||
description
|
...MembersSettingsTabInvitationsFragment
|
||||||
websiteUrl
|
...DomainSettingsTabFragment
|
||||||
email
|
...SAMLSettingsTabFragment
|
||||||
headquarterAddress
|
|
||||||
customDomain {
|
|
||||||
id
|
|
||||||
domain
|
|
||||||
sslStatus
|
|
||||||
dnsRecords {
|
|
||||||
type
|
|
||||||
name
|
|
||||||
value
|
|
||||||
ttl
|
|
||||||
purpose
|
|
||||||
}
|
|
||||||
createdAt
|
|
||||||
updatedAt
|
|
||||||
sslExpiresAt
|
|
||||||
}
|
|
||||||
createdAt
|
|
||||||
updatedAt
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const paginatedMembershipsFragment = graphql`
|
type Props = {
|
||||||
fragment SettingsPageMembershipsFragment on Organization
|
queryRef: PreloadedQuery<OrganizationGraph_ViewQuery>;
|
||||||
@refetchable(queryName: "SettingsMembershipsRefetchQuery")
|
};
|
||||||
@argumentDefinitions(
|
|
||||||
first: { type: "Int", defaultValue: 20 }
|
|
||||||
order: { type: "MembershipOrder", defaultValue: { direction: ASC, field: CREATED_AT } }
|
|
||||||
after: { type: "CursorKey", defaultValue: null }
|
|
||||||
before: { type: "CursorKey", defaultValue: null }
|
|
||||||
last: { type: "Int", defaultValue: null }
|
|
||||||
) {
|
|
||||||
memberships(
|
|
||||||
first: $first
|
|
||||||
after: $after
|
|
||||||
last: $last
|
|
||||||
before: $before
|
|
||||||
orderBy: $order
|
|
||||||
) @connection(key: "SettingsPageMemberships_memberships") {
|
|
||||||
__id
|
|
||||||
totalCount
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
fullName
|
|
||||||
emailAddress
|
|
||||||
role
|
|
||||||
createdAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const paginatedInvitationsFragment = graphql`
|
|
||||||
fragment SettingsPageInvitationsFragment on Organization
|
|
||||||
@refetchable(queryName: "SettingsInvitationsRefetchQuery")
|
|
||||||
@argumentDefinitions(
|
|
||||||
first: { type: "Int", defaultValue: 20 }
|
|
||||||
order: { type: "InvitationOrder", defaultValue: { direction: ASC, field: CREATED_AT } }
|
|
||||||
after: { type: "CursorKey", defaultValue: null }
|
|
||||||
before: { type: "CursorKey", defaultValue: null }
|
|
||||||
last: { type: "Int", defaultValue: null }
|
|
||||||
) {
|
|
||||||
invitations(
|
|
||||||
first: $first
|
|
||||||
after: $after
|
|
||||||
last: $last
|
|
||||||
before: $before
|
|
||||||
orderBy: $order
|
|
||||||
filter: {statuses: [PENDING, EXPIRED]}
|
|
||||||
) @connection(key: "SettingsPageInvitations_invitations") {
|
|
||||||
__id
|
|
||||||
totalCount
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
email
|
|
||||||
fullName
|
|
||||||
role
|
|
||||||
status
|
|
||||||
expiresAt
|
|
||||||
acceptedAt
|
|
||||||
createdAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const deleteInvitationMutation = graphql`
|
|
||||||
mutation SettingsPage_DeleteInvitationMutation(
|
|
||||||
$input: DeleteInvitationInput!
|
|
||||||
$connections: [ID!]!
|
|
||||||
) {
|
|
||||||
deleteInvitation(input: $input) {
|
|
||||||
deletedInvitationId @deleteEdge(connections: $connections)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const updateOrganizationMutation = graphql`
|
|
||||||
mutation SettingsPage_UpdateMutation($input: UpdateOrganizationInput!) {
|
|
||||||
updateOrganization(input: $input) {
|
|
||||||
organization {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
logoUrl
|
|
||||||
horizontalLogoUrl
|
|
||||||
description
|
|
||||||
websiteUrl
|
|
||||||
email
|
|
||||||
headquarterAddress
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const deleteHorizontalLogoMutation = graphql`
|
|
||||||
mutation SettingsPage_DeleteHorizontalLogoMutation($input: DeleteOrganizationHorizontalLogoInput!) {
|
|
||||||
deleteOrganizationHorizontalLogo(input: $input) {
|
|
||||||
organization {
|
|
||||||
id
|
|
||||||
horizontalLogoUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export default function SettingsPage({ queryRef }: Props) {
|
export default function SettingsPage({ queryRef }: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const navigate = useNavigate();
|
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const organizationKey = usePreloadedQuery(
|
const organizationKey = usePreloadedQuery(
|
||||||
organizationViewQuery,
|
organizationViewQuery,
|
||||||
@@ -218,683 +46,30 @@ export default function SettingsPage({ queryRef }: Props) {
|
|||||||
organizationKey
|
organizationKey
|
||||||
);
|
);
|
||||||
|
|
||||||
const membershipsPagination = usePaginationFragment(
|
|
||||||
paginatedMembershipsFragment,
|
|
||||||
organizationKey as SettingsPageMembershipsFragment$key
|
|
||||||
);
|
|
||||||
|
|
||||||
const invitationsPagination = usePaginationFragment(
|
|
||||||
paginatedInvitationsFragment,
|
|
||||||
organizationKey as SettingsPageInvitationsFragment$key
|
|
||||||
);
|
|
||||||
|
|
||||||
const refetchMemberships = () => {
|
|
||||||
membershipsPagination.refetch({}, { fetchPolicy: 'network-only' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const refetchInvitations = () => {
|
|
||||||
invitationsPagination.refetch({}, { fetchPolicy: 'network-only' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const [updateOrganization, isUpdatingOrganization] = useMutationWithToasts(
|
|
||||||
updateOrganizationMutation,
|
|
||||||
{
|
|
||||||
successMessage: __("Organization updated successfully"),
|
|
||||||
errorMessage: __("Failed to update organization"),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const [deleteHorizontalLogo, isDeletingHorizontalLogo] = useMutationWithToasts(
|
|
||||||
deleteHorizontalLogoMutation,
|
|
||||||
{
|
|
||||||
successMessage: __("Horizontal logo deleted successfully"),
|
|
||||||
errorMessage: __("Failed to delete horizontal logo"),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const [deleteOrganization, isDeleting] = useDeleteOrganizationMutation();
|
|
||||||
const memberships = membershipsPagination.data.memberships?.edges.map((edge) => edge.node) || [];
|
|
||||||
const invitations = invitationsPagination.data.invitations?.edges.map((edge) => edge.node) || [];
|
|
||||||
const [activeTab, setActiveTab] = useState<"memberships" | "invitations">("memberships");
|
|
||||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
|
||||||
const [horizontalLogoPreview, setHorizontalLogoPreview] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const { formState, handleSubmit, register, reset } = useFormWithSchema(
|
|
||||||
organizationSchema,
|
|
||||||
{
|
|
||||||
defaultValues: {
|
|
||||||
name: organization.name || "",
|
|
||||||
description: organization.description || "",
|
|
||||||
websiteUrl: organization.websiteUrl || "",
|
|
||||||
email: organization.email || "",
|
|
||||||
headquarterAddress: organization.headquarterAddress || "",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const prevOrgDataRef = useRef({
|
|
||||||
name: organization.name,
|
|
||||||
description: organization.description,
|
|
||||||
websiteUrl: organization.websiteUrl,
|
|
||||||
email: organization.email,
|
|
||||||
headquarterAddress: organization.headquarterAddress,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = prevOrgDataRef.current;
|
|
||||||
const hasFormFieldChanges =
|
|
||||||
prev.name !== organization.name ||
|
|
||||||
prev.description !== organization.description ||
|
|
||||||
prev.websiteUrl !== organization.websiteUrl ||
|
|
||||||
prev.email !== organization.email ||
|
|
||||||
prev.headquarterAddress !== organization.headquarterAddress;
|
|
||||||
|
|
||||||
if (hasFormFieldChanges) {
|
|
||||||
reset({
|
|
||||||
name: organization.name || "",
|
|
||||||
description: organization.description || "",
|
|
||||||
websiteUrl: organization.websiteUrl || "",
|
|
||||||
email: organization.email || "",
|
|
||||||
headquarterAddress: organization.headquarterAddress || "",
|
|
||||||
});
|
|
||||||
setLogoPreview(null);
|
|
||||||
setHorizontalLogoPreview(null);
|
|
||||||
|
|
||||||
prevOrgDataRef.current = {
|
|
||||||
name: organization.name,
|
|
||||||
description: organization.description,
|
|
||||||
websiteUrl: organization.websiteUrl,
|
|
||||||
email: organization.email,
|
|
||||||
headquarterAddress: organization.headquarterAddress,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [organization, reset]);
|
|
||||||
|
|
||||||
const onSubmit = handleSubmit((data: OrganizationFormData) => {
|
|
||||||
updateOrganization({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
organizationId: organization.id,
|
|
||||||
name: data.name,
|
|
||||||
description: data.description || undefined,
|
|
||||||
websiteUrl: data.websiteUrl || undefined,
|
|
||||||
email: data.email || undefined,
|
|
||||||
headquarterAddress: data.headquarterAddress || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleLogoChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onloadend = () => {
|
|
||||||
setLogoPreview(reader.result as string);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
|
|
||||||
updateOrganization({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
organizationId: organization.id,
|
|
||||||
logoFile: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
uploadables: {
|
|
||||||
"input.logoFile": file,
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setLogoPreview(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHorizontalLogoChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onloadend = () => {
|
|
||||||
setHorizontalLogoPreview(reader.result as string);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
|
|
||||||
updateOrganization({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
organizationId: organization.id,
|
|
||||||
horizontalLogoFile: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
uploadables: {
|
|
||||||
"input.horizontalLogoFile": file,
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setHorizontalLogoPreview(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteDialogRef = useDialogRef();
|
|
||||||
|
|
||||||
const handleDeleteHorizontalLogo = () => {
|
|
||||||
deleteHorizontalLogo({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
organizationId: organization.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
deleteDialogRef.current?.close();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteOrganization = () => {
|
|
||||||
return deleteOrganization({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
organizationId: organization.id,
|
|
||||||
},
|
|
||||||
connections: [],
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
navigate("/", { replace: true });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader title={__("Settings")} />
|
<PageHeader title={__("Settings")} />
|
||||||
|
|
||||||
{/* Organization settings */}
|
<Tabs>
|
||||||
<form onSubmit={onSubmit} className="space-y-6">
|
<TabLink to={`/organizations/${organizationId}/settings/general`}>
|
||||||
<div className="space-y-4">
|
<IconSettingsGear2 size={20} />
|
||||||
<div className="flex items-center justify-between">
|
{__("General")}
|
||||||
<h2 className="text-base font-medium">
|
</TabLink>
|
||||||
{__("Organization details")}
|
<TabLink to={`/organizations/${organizationId}/settings/members`}>
|
||||||
</h2>
|
<IconPeopleAdd size={20} />
|
||||||
{formState.isSubmitting && <Spinner />}
|
{__("Members")}
|
||||||
</div>
|
</TabLink>
|
||||||
<Card padded className="space-y-4">
|
<TabLink to={`/organizations/${organizationId}/settings/domain`}>
|
||||||
<div>
|
<IconStore size={20} />
|
||||||
<Label>{__("Organization logo")}</Label>
|
{__("Domain")}
|
||||||
<div className="flex w-max items-center gap-4">
|
</TabLink>
|
||||||
<Avatar
|
<TabLink to={`/organizations/${organizationId}/settings/saml-sso`}>
|
||||||
src={logoPreview || organization.logoUrl}
|
<IconLock size={20} />
|
||||||
name={organization.name}
|
{__("SAML SSO")}
|
||||||
size="xl"
|
</TabLink>
|
||||||
/>
|
</Tabs>
|
||||||
<FileButton
|
|
||||||
disabled={formState.isSubmitting || isUpdatingOrganization}
|
|
||||||
onChange={handleLogoChange}
|
|
||||||
variant="secondary"
|
|
||||||
className="ml-auto"
|
|
||||||
accept="image/png,image/jpeg,image/jpg"
|
|
||||||
>
|
|
||||||
{isUpdatingOrganization ? __("Uploading...") : __("Change logo")}
|
|
||||||
</FileButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>{__("Horizontal logo")}</Label>
|
|
||||||
<p className="text-sm text-txt-tertiary mb-2">
|
|
||||||
{__("Upload a horizontal version of your logo for use in documents")}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{(horizontalLogoPreview || organization.horizontalLogoUrl) && (
|
|
||||||
<div className="border border-border-solid rounded-md p-4 bg-surface-secondary">
|
|
||||||
<img
|
|
||||||
src={horizontalLogoPreview || organization.horizontalLogoUrl || undefined}
|
|
||||||
alt={__("Horizontal logo")}
|
|
||||||
className="h-12 max-w-xs object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FileButton
|
|
||||||
disabled={formState.isSubmitting || isUpdatingOrganization}
|
|
||||||
onChange={handleHorizontalLogoChange}
|
|
||||||
variant="secondary"
|
|
||||||
accept="image/png,image/jpeg,image/jpg"
|
|
||||||
>
|
|
||||||
{isUpdatingOrganization
|
|
||||||
? __("Uploading...")
|
|
||||||
: (horizontalLogoPreview || organization.horizontalLogoUrl)
|
|
||||||
? __("Change horizontal logo")
|
|
||||||
: __("Upload horizontal logo")}
|
|
||||||
</FileButton>
|
|
||||||
{organization.horizontalLogoUrl && (
|
|
||||||
<Dialog
|
|
||||||
ref={deleteDialogRef}
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="quaternary"
|
|
||||||
icon={IconTrashCan}
|
|
||||||
aria-label={__("Delete horizontal logo")}
|
|
||||||
className="text-red-600 hover:text-red-700"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
title={__("Delete Horizontal Logo")}
|
|
||||||
className="max-w-md"
|
|
||||||
>
|
|
||||||
<DialogContent padded>
|
|
||||||
<p className="text-txt-secondary">
|
|
||||||
{__("Are you sure you want to delete the horizontal logo?")}
|
|
||||||
</p>
|
|
||||||
<p className="text-txt-secondary mt-2">
|
|
||||||
{__("This action cannot be undone.")}
|
|
||||||
</p>
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<Outlet context={{ organization }} />
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
onClick={handleDeleteHorizontalLogo}
|
|
||||||
disabled={isDeletingHorizontalLogo}
|
|
||||||
icon={isDeletingHorizontalLogo ? Spinner : IconTrashCan}
|
|
||||||
>
|
|
||||||
{isDeletingHorizontalLogo ? __("Deleting...") : __("Delete")}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Field
|
|
||||||
{...register("name")}
|
|
||||||
readOnly={formState.isSubmitting}
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
label={__("Organization name")}
|
|
||||||
placeholder={__("Organization name")}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<Label>{__("Description")}</Label>
|
|
||||||
<Textarea
|
|
||||||
{...register("description")}
|
|
||||||
readOnly={formState.isSubmitting}
|
|
||||||
name="description"
|
|
||||||
placeholder={__("Brief description of your organization")}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
{...register("websiteUrl")}
|
|
||||||
readOnly={formState.isSubmitting}
|
|
||||||
name="websiteUrl"
|
|
||||||
type="url"
|
|
||||||
label={__("Website URL")}
|
|
||||||
placeholder={__("https://example.com")}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
{...register("email")}
|
|
||||||
readOnly={formState.isSubmitting}
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
label={__("Email")}
|
|
||||||
placeholder={__("contact@example.com")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label>{__("Headquarter Address")}</Label>
|
|
||||||
<Textarea
|
|
||||||
{...register("headquarterAddress")}
|
|
||||||
readOnly={formState.isSubmitting}
|
|
||||||
name="headquarterAddress"
|
|
||||||
placeholder={__("123 Main St, City, Country")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formState.isDirty && (
|
|
||||||
<div className="flex justify-end pt-6">
|
|
||||||
<Button type="submit" disabled={formState.isSubmitting || isUpdatingOrganization}>
|
|
||||||
{(formState.isSubmitting || isUpdatingOrganization)
|
|
||||||
? __("Updating...")
|
|
||||||
: __("Update Organization")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-base font-medium">{__("Workspace members")}</h2>
|
|
||||||
<InviteUserDialog
|
|
||||||
connectionId={invitationsPagination.data.invitations?.__id}
|
|
||||||
onRefetch={refetchInvitations}
|
|
||||||
>
|
|
||||||
<Button variant="secondary">{__("Invite member")}</Button>
|
|
||||||
</InviteUserDialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs>
|
|
||||||
<TabItem
|
|
||||||
active={activeTab === "memberships"}
|
|
||||||
onClick={() => setActiveTab("memberships")}
|
|
||||||
>
|
|
||||||
{__("Members")}
|
|
||||||
{(membershipsPagination.data.memberships?.totalCount || 0) > 0 && (
|
|
||||||
<TabBadge>{membershipsPagination.data.memberships?.totalCount}</TabBadge>
|
|
||||||
)}
|
|
||||||
</TabItem>
|
|
||||||
<TabItem
|
|
||||||
active={activeTab === "invitations"}
|
|
||||||
onClick={() => setActiveTab("invitations")}
|
|
||||||
>
|
|
||||||
{__("Invitations")}
|
|
||||||
{(invitationsPagination.data.invitations?.totalCount || 0) > 0 && (
|
|
||||||
<TabBadge>{invitationsPagination.data.invitations?.totalCount}</TabBadge>
|
|
||||||
)}
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<div className="px-6 pb-6 pt-6">
|
|
||||||
{activeTab === "memberships" && (
|
|
||||||
<SortableTable
|
|
||||||
{...membershipsPagination}
|
|
||||||
refetch={({ order }: { order: { direction: string; field: string } }) => {
|
|
||||||
membershipsPagination.refetch({
|
|
||||||
order: {
|
|
||||||
direction: order.direction as "ASC" | "DESC",
|
|
||||||
field: order.field as "CREATED_AT" | "FULL_NAME" | "EMAIL_ADDRESS" | "ROLE"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Thead>
|
|
||||||
<Tr>
|
|
||||||
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
|
|
||||||
<SortableTh field="EMAIL_ADDRESS">{__("Email")}</SortableTh>
|
|
||||||
<SortableTh field="ROLE">{__("Role")}</SortableTh>
|
|
||||||
<SortableTh field="CREATED_AT">{__("Joined")}</SortableTh>
|
|
||||||
<Th></Th>
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{memberships.length === 0 ? (
|
|
||||||
<Tr>
|
|
||||||
<Td colSpan={5} className="text-center text-txt-secondary">
|
|
||||||
{__("No members")}
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
) : (
|
|
||||||
memberships.map((membership) => (
|
|
||||||
<MembershipRow
|
|
||||||
key={membership.id}
|
|
||||||
membership={membership}
|
|
||||||
connectionId={membershipsPagination.data.memberships?.__id}
|
|
||||||
organizationId={organizationId}
|
|
||||||
onRefetch={refetchMemberships}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Tbody>
|
|
||||||
</SortableTable>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "invitations" && (
|
|
||||||
<SortableTable
|
|
||||||
{...invitationsPagination}
|
|
||||||
refetch={({ order }: { order: { direction: string; field: string } }) => {
|
|
||||||
invitationsPagination.refetch({
|
|
||||||
order: {
|
|
||||||
direction: order.direction as "ASC" | "DESC",
|
|
||||||
field: order.field as "CREATED_AT" | "EXPIRES_AT" | "FULL_NAME" | "EMAIL" | "ROLE" | "STATUS" | "ACCEPTED_AT"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Thead>
|
|
||||||
<Tr>
|
|
||||||
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
|
|
||||||
<SortableTh field="EMAIL">{__("Email")}</SortableTh>
|
|
||||||
<SortableTh field="ROLE">{__("Role")}</SortableTh>
|
|
||||||
<SortableTh field="CREATED_AT">{__("Invited")}</SortableTh>
|
|
||||||
<Th>{__("Status")}</Th>
|
|
||||||
<SortableTh field="ACCEPTED_AT">{__("Accepted at")}</SortableTh>
|
|
||||||
<Th></Th>
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{invitations.length === 0 ? (
|
|
||||||
<Tr>
|
|
||||||
<Td colSpan={7} className="text-center text-txt-secondary">
|
|
||||||
{__("No invitations")}
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
) : (
|
|
||||||
invitations.map((invitation) => (
|
|
||||||
<InvitationRow
|
|
||||||
key={invitation.id}
|
|
||||||
invitation={invitation}
|
|
||||||
connectionId={invitationsPagination.data.invitations?.__id}
|
|
||||||
organizationId={organizationId}
|
|
||||||
onRefetch={refetchInvitations}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Tbody>
|
|
||||||
</SortableTable>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-base font-medium">{__("Custom Domain")}</h2>
|
|
||||||
<CustomDomainManager
|
|
||||||
organizationId={organization.id}
|
|
||||||
customDomain={organization.customDomain}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-base font-medium text-red-600">
|
|
||||||
{__("Danger Zone")}
|
|
||||||
</h2>
|
|
||||||
<Card padded className="border-red-200 flex items-center gap-3">
|
|
||||||
<div className="mr-auto">
|
|
||||||
<h3 className="text-base font-semibold text-red-700">
|
|
||||||
{__("Delete Organization")}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-txt-tertiary">
|
|
||||||
{__("Permanently delete this organization and all its data.")}{" "}
|
|
||||||
<span className="text-red-600 font-medium">
|
|
||||||
{__("This action cannot be undone.")}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<DeleteOrganizationDialog
|
|
||||||
organizationName={organization.name}
|
|
||||||
onConfirm={handleDeleteOrganization}
|
|
||||||
isDeleting={isDeleting}
|
|
||||||
>
|
|
||||||
<Button variant="danger" icon={IconTrashCan} disabled={isDeleting}>
|
|
||||||
{isDeleting ? __("Deleting...") : __("Delete Organization")}
|
|
||||||
</Button>
|
|
||||||
</DeleteOrganizationDialog>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeMemberMutation = graphql`
|
|
||||||
mutation SettingsPage_RemoveMemberMutation(
|
|
||||||
$input: RemoveMemberInput!
|
|
||||||
$connections: [ID!]!
|
|
||||||
) {
|
|
||||||
removeMember(input: $input) {
|
|
||||||
deletedMemberId @deleteEdge(connections: $connections)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function InvitationRow(props: {
|
|
||||||
invitation: NodeOf<SettingsPageInvitationsFragment$data["invitations"]>;
|
|
||||||
connectionId?: string;
|
|
||||||
organizationId: string;
|
|
||||||
onRefetch: () => void;
|
|
||||||
}) {
|
|
||||||
const { __ } = useTranslate();
|
|
||||||
const confirm = useConfirm();
|
|
||||||
const [deleteInvitation, isDeleting] = useMutationWithToasts(
|
|
||||||
deleteInvitationMutation,
|
|
||||||
{
|
|
||||||
successMessage: __("Invitation deleted successfully"),
|
|
||||||
errorMessage: __("Failed to delete invitation"),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDelete = () => {
|
|
||||||
confirm(
|
|
||||||
() => {
|
|
||||||
return deleteInvitation({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
invitationId: props.invitation.id,
|
|
||||||
},
|
|
||||||
connections: props.connectionId ? [props.connectionId] : [],
|
|
||||||
},
|
|
||||||
onCompleted: () => {
|
|
||||||
props.onRefetch();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: sprintf(
|
|
||||||
__("Are you sure you want to delete the invitation for %s?"),
|
|
||||||
props.invitation.fullName
|
|
||||||
),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tr className={clsx(isDeleting && "opacity-60 pointer-events-none")}>
|
|
||||||
<Td>
|
|
||||||
<div className="font-semibold">{props.invitation.fullName}</div>
|
|
||||||
</Td>
|
|
||||||
<Td>{props.invitation.email}</Td>
|
|
||||||
<Td>
|
|
||||||
<Badge>{props.invitation.role}</Badge>
|
|
||||||
</Td>
|
|
||||||
<Td>{new Date(props.invitation.createdAt).toLocaleDateString()}</Td>
|
|
||||||
<Td>
|
|
||||||
{props.invitation.status === "ACCEPTED" ? (
|
|
||||||
<Badge variant="success">{__("Accepted")}</Badge>
|
|
||||||
) : props.invitation.status === "EXPIRED" ? (
|
|
||||||
<Badge variant="danger">{__("Expired")}</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="warning">{__("Pending")}</Badge>
|
|
||||||
)}
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
{props.invitation.acceptedAt ? new Date(props.invitation.acceptedAt).toLocaleDateString() : "-"}
|
|
||||||
</Td>
|
|
||||||
<Td noLink width={80} className="text-end">
|
|
||||||
<div
|
|
||||||
className="flex gap-2 justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{isDeleting ? (
|
|
||||||
<Spinner size={16} />
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
onClick={onDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
icon={IconTrashCan}
|
|
||||||
aria-label={__("Delete invitation")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MembershipRow(props: {
|
|
||||||
membership: NodeOf<SettingsPageMembershipsFragment$data["memberships"]>;
|
|
||||||
connectionId?: string;
|
|
||||||
organizationId: string;
|
|
||||||
onRefetch: () => void;
|
|
||||||
}) {
|
|
||||||
const { __ } = useTranslate();
|
|
||||||
const [removeMember, isRemoving] = useMutationWithToasts(removeMemberMutation, {
|
|
||||||
successMessage: __("Member removed successfully"),
|
|
||||||
errorMessage: __("Failed to remove member"),
|
|
||||||
});
|
|
||||||
const confirm = useConfirm();
|
|
||||||
const [isRemoved, setIsRemoved] = useState(false);
|
|
||||||
|
|
||||||
if (isRemoved) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onRemove = async () => {
|
|
||||||
confirm(
|
|
||||||
() => {
|
|
||||||
return removeMember({
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
memberId: props.membership.id,
|
|
||||||
organizationId: props.organizationId,
|
|
||||||
},
|
|
||||||
connections: props.connectionId ? [props.connectionId] : [],
|
|
||||||
},
|
|
||||||
onCompleted: () => {
|
|
||||||
setIsRemoved(true);
|
|
||||||
props.onRefetch();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: sprintf(
|
|
||||||
__("Are you sure you want to remove %s?"),
|
|
||||||
props.membership.fullName
|
|
||||||
),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tr className={clsx(isRemoving && "opacity-60 pointer-events-none")}>
|
|
||||||
<Td>
|
|
||||||
<div className="font-semibold">{props.membership.fullName}</div>
|
|
||||||
</Td>
|
|
||||||
<Td>{props.membership.emailAddress}</Td>
|
|
||||||
<Td>
|
|
||||||
<Badge>{props.membership.role}</Badge>
|
|
||||||
</Td>
|
|
||||||
<Td>{new Date(props.membership.createdAt).toLocaleDateString()}</Td>
|
|
||||||
<Td noLink width={80} className="text-end">
|
|
||||||
<div
|
|
||||||
className="flex gap-2 justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{isRemoving ? (
|
|
||||||
<Spinner size={16} />
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
onClick={onRemove}
|
|
||||||
disabled={isRemoving}
|
|
||||||
icon={IconTrashCan}
|
|
||||||
aria-label={__("Remove member")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<cf0ce3993d747999dfbe2ca60b509023>>
|
* @generated SignedSource<<552529ec3c732ce161f2dcb4a16179b9>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -9,34 +9,11 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
import { ReaderFragment } from 'relay-runtime';
|
import { ReaderFragment } from 'relay-runtime';
|
||||||
export type SSLStatus = "ACTIVE" | "EXPIRED" | "FAILED" | "PENDING" | "PROVISIONING" | "RENEWING";
|
|
||||||
import { FragmentRefs } from "relay-runtime";
|
import { FragmentRefs } from "relay-runtime";
|
||||||
export type SettingsPageFragment$data = {
|
export type SettingsPageFragment$data = {
|
||||||
readonly createdAt: any;
|
|
||||||
readonly customDomain: {
|
|
||||||
readonly createdAt: any;
|
|
||||||
readonly dnsRecords: ReadonlyArray<{
|
|
||||||
readonly name: string;
|
|
||||||
readonly purpose: string;
|
|
||||||
readonly ttl: number;
|
|
||||||
readonly type: string;
|
|
||||||
readonly value: string;
|
|
||||||
}>;
|
|
||||||
readonly domain: string;
|
|
||||||
readonly id: string;
|
|
||||||
readonly sslExpiresAt: any | null | undefined;
|
|
||||||
readonly sslStatus: SSLStatus;
|
|
||||||
readonly updatedAt: any;
|
|
||||||
} | null | undefined;
|
|
||||||
readonly description: string | null | undefined;
|
|
||||||
readonly email: string | null | undefined;
|
|
||||||
readonly headquarterAddress: string | null | undefined;
|
|
||||||
readonly horizontalLogoUrl: string | null | undefined;
|
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly logoUrl: string | null | undefined;
|
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly updatedAt: any;
|
readonly " $fragmentSpreads": FragmentRefs<"DomainSettingsTabFragment" | "GeneralSettingsTabFragment" | "MembersSettingsTabInvitationsFragment" | "MembersSettingsTabMembershipsFragment" | "SAMLSettingsTabFragment">;
|
||||||
readonly websiteUrl: string | null | undefined;
|
|
||||||
readonly " $fragmentType": "SettingsPageFragment";
|
readonly " $fragmentType": "SettingsPageFragment";
|
||||||
};
|
};
|
||||||
export type SettingsPageFragment$key = {
|
export type SettingsPageFragment$key = {
|
||||||
@@ -44,168 +21,56 @@ export type SettingsPageFragment$key = {
|
|||||||
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageFragment">;
|
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageFragment">;
|
||||||
};
|
};
|
||||||
|
|
||||||
const node: ReaderFragment = (function(){
|
const node: ReaderFragment = {
|
||||||
var v0 = {
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "id",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
v1 = {
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "name",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
v2 = {
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "createdAt",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
v3 = {
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "updatedAt",
|
|
||||||
"storageKey": null
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
"argumentDefinitions": [],
|
"argumentDefinitions": [],
|
||||||
"kind": "Fragment",
|
"kind": "Fragment",
|
||||||
"metadata": null,
|
"metadata": null,
|
||||||
"name": "SettingsPageFragment",
|
"name": "SettingsPageFragment",
|
||||||
"selections": [
|
"selections": [
|
||||||
(v0/*: any*/),
|
|
||||||
(v1/*: any*/),
|
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "logoUrl",
|
"name": "id",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "horizontalLogoUrl",
|
"name": "name",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "FragmentSpread",
|
||||||
"name": "description",
|
"name": "GeneralSettingsTabFragment"
|
||||||
"storageKey": null
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "FragmentSpread",
|
||||||
"name": "websiteUrl",
|
"name": "MembersSettingsTabMembershipsFragment"
|
||||||
"storageKey": null
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "FragmentSpread",
|
||||||
"name": "email",
|
"name": "MembersSettingsTabInvitationsFragment"
|
||||||
"storageKey": null
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "FragmentSpread",
|
||||||
"name": "headquarterAddress",
|
"name": "DomainSettingsTabFragment"
|
||||||
"storageKey": null
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
"args": null,
|
||||||
"concreteType": "CustomDomain",
|
"kind": "FragmentSpread",
|
||||||
"kind": "LinkedField",
|
"name": "SAMLSettingsTabFragment"
|
||||||
"name": "customDomain",
|
}
|
||||||
"plural": false,
|
|
||||||
"selections": [
|
|
||||||
(v0/*: any*/),
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "domain",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "sslStatus",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"concreteType": "DNSRecordInstruction",
|
|
||||||
"kind": "LinkedField",
|
|
||||||
"name": "dnsRecords",
|
|
||||||
"plural": true,
|
|
||||||
"selections": [
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "type",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v1/*: any*/),
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "value",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "ttl",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "purpose",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v2/*: any*/),
|
|
||||||
(v3/*: any*/),
|
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "sslExpiresAt",
|
|
||||||
"storageKey": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v2/*: any*/),
|
|
||||||
(v3/*: any*/)
|
|
||||||
],
|
],
|
||||||
"type": "Organization",
|
"type": "Organization",
|
||||||
"abstractKey": null
|
"abstractKey": null
|
||||||
};
|
};
|
||||||
})();
|
|
||||||
|
|
||||||
(node as any).hash = "1e64bcccf8ef3a8ead79b3446e8a3ccd";
|
(node as any).hash = "4f0ec089ac8ee79935eb56c22de31eca";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useOutletContext } from "react-router";
|
||||||
|
import { useFragment, graphql } from "react-relay";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import { CustomDomainManager } from "/components/customDomains/CustomDomainManager";
|
||||||
|
import type { DomainSettingsTabFragment$key } from "./__generated__/DomainSettingsTabFragment.graphql";
|
||||||
|
|
||||||
|
const domainSettingsTabFragment = graphql`
|
||||||
|
fragment DomainSettingsTabFragment on Organization {
|
||||||
|
id
|
||||||
|
customDomain {
|
||||||
|
id
|
||||||
|
domain
|
||||||
|
sslStatus
|
||||||
|
dnsRecords {
|
||||||
|
type
|
||||||
|
name
|
||||||
|
value
|
||||||
|
ttl
|
||||||
|
purpose
|
||||||
|
}
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
sslExpiresAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type OutletContext = {
|
||||||
|
organization: DomainSettingsTabFragment$key;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DomainSettingsTab() {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { organization: organizationKey } = useOutletContext<OutletContext>();
|
||||||
|
const organization = useFragment(domainSettingsTabFragment, organizationKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-base font-medium">{__("Custom Domain")}</h2>
|
||||||
|
<CustomDomainManager
|
||||||
|
organizationId={organization.id}
|
||||||
|
customDomain={organization.customDomain}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { useState, useRef, useEffect, type ChangeEventHandler } from "react";
|
||||||
|
import { useOutletContext, useNavigate } from "react-router";
|
||||||
|
import { useFragment, graphql } from "react-relay";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
Field,
|
||||||
|
FileButton,
|
||||||
|
IconTrashCan,
|
||||||
|
Label,
|
||||||
|
Spinner,
|
||||||
|
Textarea,
|
||||||
|
useDialogRef,
|
||||||
|
} from "@probo/ui";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||||
|
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { GeneralSettingsTabFragment$key } from "./__generated__/GeneralSettingsTabFragment.graphql";
|
||||||
|
import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog";
|
||||||
|
import { useDeleteOrganizationMutation } from "/hooks/graph/OrganizationGraph";
|
||||||
|
|
||||||
|
const generalSettingsTabFragment = graphql`
|
||||||
|
fragment GeneralSettingsTabFragment on Organization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
logoUrl
|
||||||
|
horizontalLogoUrl
|
||||||
|
description
|
||||||
|
websiteUrl
|
||||||
|
email
|
||||||
|
headquarterAddress
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const updateOrganizationMutation = graphql`
|
||||||
|
mutation GeneralSettingsTab_UpdateMutation($input: UpdateOrganizationInput!) {
|
||||||
|
updateOrganization(input: $input) {
|
||||||
|
organization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
logoUrl
|
||||||
|
horizontalLogoUrl
|
||||||
|
description
|
||||||
|
websiteUrl
|
||||||
|
email
|
||||||
|
headquarterAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const deleteHorizontalLogoMutation = graphql`
|
||||||
|
mutation GeneralSettingsTab_DeleteHorizontalLogoMutation(
|
||||||
|
$input: DeleteOrganizationHorizontalLogoInput!
|
||||||
|
) {
|
||||||
|
deleteOrganizationHorizontalLogo(input: $input) {
|
||||||
|
organization {
|
||||||
|
id
|
||||||
|
horizontalLogoUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const organizationSchema = z.object({
|
||||||
|
name: z.string().min(1, "Organization name is required"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
websiteUrl: z.string().optional(),
|
||||||
|
email: z.string().optional(),
|
||||||
|
headquarterAddress: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type OrganizationFormData = z.infer<typeof organizationSchema>;
|
||||||
|
|
||||||
|
type OutletContext = {
|
||||||
|
organization: GeneralSettingsTabFragment$key;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GeneralSettingsTab() {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { organization: organizationKey } = useOutletContext<OutletContext>();
|
||||||
|
const organization = useFragment(generalSettingsTabFragment, organizationKey);
|
||||||
|
const deleteDialogRef = useDialogRef();
|
||||||
|
|
||||||
|
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||||
|
const [horizontalLogoPreview, setHorizontalLogoPreview] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const [updateOrganization, isUpdatingOrganization] = useMutationWithToasts(
|
||||||
|
updateOrganizationMutation,
|
||||||
|
{
|
||||||
|
successMessage: __("Organization updated successfully"),
|
||||||
|
errorMessage: __("Failed to update organization"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const [deleteHorizontalLogo, isDeletingHorizontalLogo] =
|
||||||
|
useMutationWithToasts(deleteHorizontalLogoMutation, {
|
||||||
|
successMessage: __("Horizontal logo deleted successfully"),
|
||||||
|
errorMessage: __("Failed to delete horizontal logo"),
|
||||||
|
});
|
||||||
|
const [deleteOrganization, isDeletingOrganization] =
|
||||||
|
useDeleteOrganizationMutation();
|
||||||
|
|
||||||
|
const { formState, handleSubmit, register, reset } = useFormWithSchema(
|
||||||
|
organizationSchema,
|
||||||
|
{
|
||||||
|
defaultValues: {
|
||||||
|
name: organization.name || "",
|
||||||
|
description: organization.description || "",
|
||||||
|
websiteUrl: organization.websiteUrl || "",
|
||||||
|
email: organization.email || "",
|
||||||
|
headquarterAddress: organization.headquarterAddress || "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const prevOrgDataRef = useRef({
|
||||||
|
name: organization.name,
|
||||||
|
description: organization.description,
|
||||||
|
websiteUrl: organization.websiteUrl,
|
||||||
|
email: organization.email,
|
||||||
|
headquarterAddress: organization.headquarterAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const prevData = prevOrgDataRef.current;
|
||||||
|
const currentData = {
|
||||||
|
name: organization.name,
|
||||||
|
description: organization.description,
|
||||||
|
websiteUrl: organization.websiteUrl,
|
||||||
|
email: organization.email,
|
||||||
|
headquarterAddress: organization.headquarterAddress,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (JSON.stringify(prevData) !== JSON.stringify(currentData)) {
|
||||||
|
reset({
|
||||||
|
name: organization.name || "",
|
||||||
|
description: organization.description || "",
|
||||||
|
websiteUrl: organization.websiteUrl || "",
|
||||||
|
email: organization.email || "",
|
||||||
|
headquarterAddress: organization.headquarterAddress || "",
|
||||||
|
});
|
||||||
|
prevOrgDataRef.current = currentData;
|
||||||
|
}
|
||||||
|
}, [organization, reset]);
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit((data: OrganizationFormData) => {
|
||||||
|
updateOrganization({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
organizationId: organization.id,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || null,
|
||||||
|
websiteUrl: data.websiteUrl || null,
|
||||||
|
email: data.email || null,
|
||||||
|
headquarterAddress: data.headquarterAddress || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLogoChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
setLogoPreview(reader.result as string);
|
||||||
|
updateOrganization({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
organizationId: organization.id,
|
||||||
|
logo: file,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted: () => {
|
||||||
|
setLogoPreview(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHorizontalLogoChange: ChangeEventHandler<HTMLInputElement> = (
|
||||||
|
e
|
||||||
|
) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
setHorizontalLogoPreview(reader.result as string);
|
||||||
|
updateOrganization({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
organizationId: organization.id,
|
||||||
|
horizontalLogo: file,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted: () => {
|
||||||
|
setHorizontalLogoPreview(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteHorizontalLogo = () => {
|
||||||
|
deleteHorizontalLogo({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
organizationId: organization.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted: () => {
|
||||||
|
deleteDialogRef.current?.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteOrganization = () => {
|
||||||
|
return deleteOrganization({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
organizationId: organization.id,
|
||||||
|
},
|
||||||
|
connections: [],
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-medium">
|
||||||
|
{__("Organization details")}
|
||||||
|
</h2>
|
||||||
|
{formState.isSubmitting && <Spinner />}
|
||||||
|
</div>
|
||||||
|
<Card padded className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>{__("Organization logo")}</Label>
|
||||||
|
<div className="flex w-max items-center gap-4">
|
||||||
|
<Avatar
|
||||||
|
src={logoPreview || organization.logoUrl}
|
||||||
|
name={organization.name}
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
<FileButton
|
||||||
|
disabled={formState.isSubmitting || isUpdatingOrganization}
|
||||||
|
onChange={handleLogoChange}
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-auto"
|
||||||
|
accept="image/png,image/jpeg,image/jpg"
|
||||||
|
>
|
||||||
|
{isUpdatingOrganization
|
||||||
|
? __("Uploading...")
|
||||||
|
: __("Change logo")}
|
||||||
|
</FileButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{__("Horizontal logo")}</Label>
|
||||||
|
<p className="text-sm text-txt-tertiary mb-2">
|
||||||
|
{__(
|
||||||
|
"Upload a horizontal version of your logo for use in documents"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{(horizontalLogoPreview || organization.horizontalLogoUrl) && (
|
||||||
|
<div className="border border-border-solid rounded-md p-4 bg-surface-secondary">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
horizontalLogoPreview ||
|
||||||
|
organization.horizontalLogoUrl ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
alt={__("Horizontal logo")}
|
||||||
|
className="h-12 max-w-xs object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FileButton
|
||||||
|
disabled={formState.isSubmitting || isUpdatingOrganization}
|
||||||
|
onChange={handleHorizontalLogoChange}
|
||||||
|
variant="secondary"
|
||||||
|
accept="image/png,image/jpeg,image/jpg"
|
||||||
|
>
|
||||||
|
{isUpdatingOrganization
|
||||||
|
? __("Uploading...")
|
||||||
|
: horizontalLogoPreview || organization.horizontalLogoUrl
|
||||||
|
? __("Change horizontal logo")
|
||||||
|
: __("Upload horizontal logo")}
|
||||||
|
</FileButton>
|
||||||
|
{organization.horizontalLogoUrl && (
|
||||||
|
<Dialog
|
||||||
|
ref={deleteDialogRef}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="quaternary"
|
||||||
|
icon={IconTrashCan}
|
||||||
|
aria-label={__("Delete horizontal logo")}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={__("Delete Horizontal Logo")}
|
||||||
|
className="max-w-md"
|
||||||
|
>
|
||||||
|
<DialogContent padded>
|
||||||
|
<p className="text-txt-secondary">
|
||||||
|
{__(
|
||||||
|
"Are you sure you want to delete the horizontal logo?"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-txt-secondary mt-2">
|
||||||
|
{__("This action cannot be undone.")}
|
||||||
|
</p>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleDeleteHorizontalLogo}
|
||||||
|
disabled={isDeletingHorizontalLogo}
|
||||||
|
icon={isDeletingHorizontalLogo ? Spinner : IconTrashCan}
|
||||||
|
>
|
||||||
|
{isDeletingHorizontalLogo
|
||||||
|
? __("Deleting...")
|
||||||
|
: __("Delete")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
{...register("name")}
|
||||||
|
readOnly={formState.isSubmitting}
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
label={__("Organization name")}
|
||||||
|
placeholder={__("Organization name")}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label>{__("Description")}</Label>
|
||||||
|
<Textarea
|
||||||
|
{...register("description")}
|
||||||
|
readOnly={formState.isSubmitting}
|
||||||
|
name="description"
|
||||||
|
placeholder={__("Brief description of your organization")}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Field
|
||||||
|
{...register("websiteUrl")}
|
||||||
|
readOnly={formState.isSubmitting}
|
||||||
|
name="websiteUrl"
|
||||||
|
type="url"
|
||||||
|
label={__("Website URL")}
|
||||||
|
placeholder={__("https://example.com")}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
{...register("email")}
|
||||||
|
readOnly={formState.isSubmitting}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
label={__("Email")}
|
||||||
|
placeholder={__("contact@example.com")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{__("Headquarter Address")}</Label>
|
||||||
|
<Textarea
|
||||||
|
{...register("headquarterAddress")}
|
||||||
|
readOnly={formState.isSubmitting}
|
||||||
|
name="headquarterAddress"
|
||||||
|
placeholder={__("123 Main St, City, Country")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formState.isDirty && (
|
||||||
|
<div className="flex justify-end pt-6">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={formState.isSubmitting || isUpdatingOrganization}
|
||||||
|
>
|
||||||
|
{formState.isSubmitting || isUpdatingOrganization
|
||||||
|
? __("Updating...")
|
||||||
|
: __("Update Organization")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 mt-12">
|
||||||
|
<h2 className="text-base font-medium text-red-600">
|
||||||
|
{__("Danger Zone")}
|
||||||
|
</h2>
|
||||||
|
<Card padded className="border-red-200 flex items-center gap-3">
|
||||||
|
<div className="mr-auto">
|
||||||
|
<h3 className="text-base font-semibold text-red-700">
|
||||||
|
{__("Delete Organization")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-txt-tertiary">
|
||||||
|
{__("Permanently delete this organization and all its data.")}{" "}
|
||||||
|
<span className="text-red-600 font-medium">
|
||||||
|
{__("This action cannot be undone.")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DeleteOrganizationDialog
|
||||||
|
organizationName={organization.name}
|
||||||
|
onConfirm={handleDeleteOrganization}
|
||||||
|
isDeleting={isDeletingOrganization}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
icon={IconTrashCan}
|
||||||
|
disabled={isDeletingOrganization}
|
||||||
|
>
|
||||||
|
{__("Delete Organization")}
|
||||||
|
</Button>
|
||||||
|
</DeleteOrganizationDialog>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useOutletContext } from "react-router";
|
||||||
|
import { usePaginationFragment, graphql } from "react-relay";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
IconTrashCan,
|
||||||
|
Spinner,
|
||||||
|
TabBadge,
|
||||||
|
TabItem,
|
||||||
|
Tabs,
|
||||||
|
Tbody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
Thead,
|
||||||
|
Tr,
|
||||||
|
useConfirm,
|
||||||
|
} from "@probo/ui";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import { SortableTable, SortableTh } from "/components/SortableTable";
|
||||||
|
import { InviteUserDialog } from "/components/organizations/InviteUserDialog";
|
||||||
|
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
|
||||||
|
import { sprintf } from "@probo/helpers";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import type { NodeOf } from "/types";
|
||||||
|
import type {
|
||||||
|
MembersSettingsTabMembershipsFragment$data,
|
||||||
|
MembersSettingsTabMembershipsFragment$key
|
||||||
|
} from "./__generated__/MembersSettingsTabMembershipsFragment.graphql";
|
||||||
|
import type {
|
||||||
|
MembersSettingsTabInvitationsFragment$data,
|
||||||
|
MembersSettingsTabInvitationsFragment$key
|
||||||
|
} from "./__generated__/MembersSettingsTabInvitationsFragment.graphql";
|
||||||
|
|
||||||
|
const paginatedMembershipsFragment = graphql`
|
||||||
|
fragment MembersSettingsTabMembershipsFragment on Organization
|
||||||
|
@refetchable(queryName: "MembersSettingsTabMembershipsRefetchQuery")
|
||||||
|
@argumentDefinitions(
|
||||||
|
first: { type: "Int", defaultValue: 20 }
|
||||||
|
order: { type: "MembershipOrder", defaultValue: { direction: ASC, field: CREATED_AT } }
|
||||||
|
after: { type: "CursorKey", defaultValue: null }
|
||||||
|
before: { type: "CursorKey", defaultValue: null }
|
||||||
|
last: { type: "Int", defaultValue: null }
|
||||||
|
) {
|
||||||
|
memberships(
|
||||||
|
first: $first
|
||||||
|
after: $after
|
||||||
|
last: $last
|
||||||
|
before: $before
|
||||||
|
orderBy: $order
|
||||||
|
) @connection(key: "MembersSettingsTabMemberships_memberships") {
|
||||||
|
__id
|
||||||
|
totalCount
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
fullName
|
||||||
|
emailAddress
|
||||||
|
role
|
||||||
|
authMethod
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const paginatedInvitationsFragment = graphql`
|
||||||
|
fragment MembersSettingsTabInvitationsFragment on Organization
|
||||||
|
@refetchable(queryName: "MembersSettingsTabInvitationsRefetchQuery")
|
||||||
|
@argumentDefinitions(
|
||||||
|
first: { type: "Int", defaultValue: 20 }
|
||||||
|
order: { type: "InvitationOrder", defaultValue: { direction: ASC, field: CREATED_AT } }
|
||||||
|
after: { type: "CursorKey", defaultValue: null }
|
||||||
|
before: { type: "CursorKey", defaultValue: null }
|
||||||
|
last: { type: "Int", defaultValue: null }
|
||||||
|
) {
|
||||||
|
invitations(
|
||||||
|
first: $first
|
||||||
|
after: $after
|
||||||
|
last: $last
|
||||||
|
before: $before
|
||||||
|
orderBy: $order
|
||||||
|
) @connection(key: "MembersSettingsTabInvitations_invitations") {
|
||||||
|
__id
|
||||||
|
totalCount
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
fullName
|
||||||
|
email
|
||||||
|
role
|
||||||
|
status
|
||||||
|
createdAt
|
||||||
|
expiresAt
|
||||||
|
acceptedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const removeMemberMutation = graphql`
|
||||||
|
mutation MembersSettingsTab_RemoveMemberMutation(
|
||||||
|
$input: RemoveMemberInput!
|
||||||
|
$connections: [ID!]!
|
||||||
|
) {
|
||||||
|
removeMember(input: $input) {
|
||||||
|
deletedMemberId @deleteEdge(connections: $connections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const deleteInvitationMutation = graphql`
|
||||||
|
mutation MembersSettingsTab_DeleteInvitationMutation(
|
||||||
|
$input: DeleteInvitationInput!
|
||||||
|
$connections: [ID!]!
|
||||||
|
) {
|
||||||
|
deleteInvitation(input: $input) {
|
||||||
|
deletedInvitationId @deleteEdge(connections: $connections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type OutletContext = {
|
||||||
|
organization: MembersSettingsTabMembershipsFragment$key & MembersSettingsTabInvitationsFragment$key & { id: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MembersSettingsTab() {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { organization: organizationKey } = useOutletContext<OutletContext>();
|
||||||
|
|
||||||
|
const membershipsPagination = usePaginationFragment(
|
||||||
|
paginatedMembershipsFragment,
|
||||||
|
organizationKey as MembersSettingsTabMembershipsFragment$key
|
||||||
|
);
|
||||||
|
|
||||||
|
const invitationsPagination = usePaginationFragment(
|
||||||
|
paginatedInvitationsFragment,
|
||||||
|
organizationKey as MembersSettingsTabInvitationsFragment$key
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchMemberships = () => {
|
||||||
|
membershipsPagination.refetch({}, { fetchPolicy: 'network-only' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const refetchInvitations = () => {
|
||||||
|
invitationsPagination.refetch({}, { fetchPolicy: 'network-only' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const memberships = membershipsPagination.data.memberships?.edges.map((edge) => edge.node) || [];
|
||||||
|
const invitations = invitationsPagination.data.invitations?.edges.map((edge) => edge.node) || [];
|
||||||
|
const [activeTab, setActiveTab] = useState<"memberships" | "invitations">("memberships");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-medium">{__("Workspace members")}</h2>
|
||||||
|
<InviteUserDialog
|
||||||
|
connectionId={invitationsPagination.data.invitations?.__id}
|
||||||
|
onRefetch={refetchInvitations}
|
||||||
|
>
|
||||||
|
<Button variant="secondary">{__("Invite member")}</Button>
|
||||||
|
</InviteUserDialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<TabItem
|
||||||
|
active={activeTab === "memberships"}
|
||||||
|
onClick={() => setActiveTab("memberships")}
|
||||||
|
>
|
||||||
|
{__("Members")}
|
||||||
|
{(membershipsPagination.data.memberships?.totalCount || 0) > 0 && (
|
||||||
|
<TabBadge>{membershipsPagination.data.memberships?.totalCount}</TabBadge>
|
||||||
|
)}
|
||||||
|
</TabItem>
|
||||||
|
<TabItem
|
||||||
|
active={activeTab === "invitations"}
|
||||||
|
onClick={() => setActiveTab("invitations")}
|
||||||
|
>
|
||||||
|
{__("Invitations")}
|
||||||
|
{(invitationsPagination.data.invitations?.totalCount || 0) > 0 && (
|
||||||
|
<TabBadge>{invitationsPagination.data.invitations?.totalCount}</TabBadge>
|
||||||
|
)}
|
||||||
|
</TabItem>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="px-6 pb-6 pt-6">
|
||||||
|
{activeTab === "memberships" && (
|
||||||
|
<SortableTable
|
||||||
|
{...membershipsPagination}
|
||||||
|
refetch={({ order }: { order: { direction: string; field: string } }) => {
|
||||||
|
membershipsPagination.refetch({
|
||||||
|
order: {
|
||||||
|
direction: order.direction as "ASC" | "DESC",
|
||||||
|
field: order.field as "CREATED_AT" | "FULL_NAME" | "EMAIL_ADDRESS" | "ROLE"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
|
||||||
|
<SortableTh field="EMAIL_ADDRESS">{__("Email")}</SortableTh>
|
||||||
|
<SortableTh field="ROLE">{__("Role")}</SortableTh>
|
||||||
|
<SortableTh field="CREATED_AT">{__("Joined")}</SortableTh>
|
||||||
|
<Th></Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{memberships.length === 0 ? (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={5} className="text-center text-txt-secondary">
|
||||||
|
{__("No members")}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
) : (
|
||||||
|
memberships.map((membership) => (
|
||||||
|
<MembershipRow
|
||||||
|
key={membership.id}
|
||||||
|
membership={membership}
|
||||||
|
connectionId={membershipsPagination.data.memberships?.__id}
|
||||||
|
organizationId={(organizationKey as { id: string }).id}
|
||||||
|
onRefetch={refetchMemberships}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Tbody>
|
||||||
|
</SortableTable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "invitations" && (
|
||||||
|
<SortableTable
|
||||||
|
{...invitationsPagination}
|
||||||
|
refetch={({ order }: { order: { direction: string; field: string } }) => {
|
||||||
|
invitationsPagination.refetch({
|
||||||
|
order: {
|
||||||
|
direction: order.direction as "ASC" | "DESC",
|
||||||
|
field: order.field as "CREATED_AT" | "EXPIRES_AT" | "FULL_NAME" | "EMAIL" | "ROLE" | "STATUS" | "ACCEPTED_AT"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
|
||||||
|
<SortableTh field="EMAIL">{__("Email")}</SortableTh>
|
||||||
|
<SortableTh field="ROLE">{__("Role")}</SortableTh>
|
||||||
|
<SortableTh field="CREATED_AT">{__("Invited")}</SortableTh>
|
||||||
|
<Th>{__("Status")}</Th>
|
||||||
|
<SortableTh field="ACCEPTED_AT">{__("Accepted at")}</SortableTh>
|
||||||
|
<Th></Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{invitations.length === 0 ? (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={7} className="text-center text-txt-secondary">
|
||||||
|
{__("No invitations")}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
) : (
|
||||||
|
invitations.map((invitation) => (
|
||||||
|
<InvitationRow
|
||||||
|
key={invitation.id}
|
||||||
|
invitation={invitation}
|
||||||
|
connectionId={invitationsPagination.data.invitations?.__id}
|
||||||
|
organizationId={(organizationKey as { id: string }).id}
|
||||||
|
onRefetch={refetchInvitations}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Tbody>
|
||||||
|
</SortableTable>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvitationRow(props: {
|
||||||
|
invitation: NodeOf<MembersSettingsTabInvitationsFragment$data["invitations"]>;
|
||||||
|
connectionId?: string;
|
||||||
|
organizationId: string;
|
||||||
|
onRefetch: () => void;
|
||||||
|
}) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [deleteInvitation, isDeleting] = useMutationWithToasts(
|
||||||
|
deleteInvitationMutation,
|
||||||
|
{
|
||||||
|
successMessage: __("Invitation deleted successfully"),
|
||||||
|
errorMessage: __("Failed to delete invitation"),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDelete = () => {
|
||||||
|
confirm(
|
||||||
|
() => {
|
||||||
|
return deleteInvitation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
invitationId: props.invitation.id,
|
||||||
|
},
|
||||||
|
connections: props.connectionId ? [props.connectionId] : [],
|
||||||
|
},
|
||||||
|
onCompleted: () => {
|
||||||
|
props.onRefetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: sprintf(
|
||||||
|
__("Are you sure you want to delete the invitation for %s?"),
|
||||||
|
props.invitation.fullName
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr className={clsx(isDeleting && "opacity-60 pointer-events-none")}>
|
||||||
|
<Td>
|
||||||
|
<div className="font-semibold">{props.invitation.fullName}</div>
|
||||||
|
</Td>
|
||||||
|
<Td>{props.invitation.email}</Td>
|
||||||
|
<Td>
|
||||||
|
<Badge>{props.invitation.role}</Badge>
|
||||||
|
</Td>
|
||||||
|
<Td>{new Date(props.invitation.createdAt).toLocaleDateString()}</Td>
|
||||||
|
<Td>
|
||||||
|
{props.invitation.status === "ACCEPTED" ? (
|
||||||
|
<Badge variant="success">{__("Accepted")}</Badge>
|
||||||
|
) : props.invitation.status === "EXPIRED" ? (
|
||||||
|
<Badge variant="danger">{__("Expired")}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="warning">{__("Pending")}</Badge>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{props.invitation.acceptedAt ? new Date(props.invitation.acceptedAt).toLocaleDateString() : "-"}
|
||||||
|
</Td>
|
||||||
|
<Td noLink width={80} className="text-end">
|
||||||
|
<div
|
||||||
|
className="flex gap-2 justify-end"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Spinner size={16} />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
icon={IconTrashCan}
|
||||||
|
aria-label={__("Delete invitation")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MembershipRow(props: {
|
||||||
|
membership: NodeOf<MembersSettingsTabMembershipsFragment$data["memberships"]>;
|
||||||
|
connectionId?: string;
|
||||||
|
organizationId: string;
|
||||||
|
onRefetch: () => void;
|
||||||
|
}) {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const [removeMember, isRemoving] = useMutationWithToasts(removeMemberMutation, {
|
||||||
|
successMessage: __("Member removed successfully"),
|
||||||
|
errorMessage: __("Failed to remove member"),
|
||||||
|
});
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const [isRemoved, setIsRemoved] = useState(false);
|
||||||
|
|
||||||
|
if (isRemoved) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRemove = async () => {
|
||||||
|
confirm(
|
||||||
|
() => {
|
||||||
|
return removeMember({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
memberId: props.membership.id,
|
||||||
|
organizationId: props.organizationId,
|
||||||
|
},
|
||||||
|
connections: props.connectionId ? [props.connectionId] : [],
|
||||||
|
},
|
||||||
|
onCompleted: () => {
|
||||||
|
setIsRemoved(true);
|
||||||
|
props.onRefetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: sprintf(
|
||||||
|
__("Are you sure you want to remove %s?"),
|
||||||
|
props.membership.fullName
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr className={clsx(isRemoving && "opacity-60 pointer-events-none")}>
|
||||||
|
<Td>
|
||||||
|
<div className="font-semibold">{props.membership.fullName}</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{props.membership.emailAddress}
|
||||||
|
{props.membership.authMethod === "SAML" && (
|
||||||
|
<Badge variant="info">SAML</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Badge>{props.membership.role}</Badge>
|
||||||
|
</Td>
|
||||||
|
<Td>{new Date(props.membership.createdAt).toLocaleDateString()}</Td>
|
||||||
|
<Td noLink width={80} className="text-end">
|
||||||
|
<div
|
||||||
|
className="flex gap-2 justify-end"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{isRemoving ? (
|
||||||
|
<Spinner size={16} />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={isRemoving}
|
||||||
|
icon={IconTrashCan}
|
||||||
|
aria-label={__("Remove member")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,783 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useOutletContext } from "react-router";
|
||||||
|
import { useFragment, graphql } from "react-relay";
|
||||||
|
import { Controller } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
Field,
|
||||||
|
Label,
|
||||||
|
Option,
|
||||||
|
Select,
|
||||||
|
Table,
|
||||||
|
Tbody,
|
||||||
|
Td,
|
||||||
|
Textarea,
|
||||||
|
Th,
|
||||||
|
Thead,
|
||||||
|
Tr,
|
||||||
|
useConfirm,
|
||||||
|
useDialogRef,
|
||||||
|
} from "@probo/ui";
|
||||||
|
import { useTranslate } from "@probo/i18n";
|
||||||
|
import { useFormWithSchema } from "/hooks/useFormWithSchema";
|
||||||
|
import {
|
||||||
|
useCreateSAMLConfigurationMutation,
|
||||||
|
useUpdateSAMLConfigurationMutation,
|
||||||
|
useDeleteSAMLConfigurationMutation,
|
||||||
|
useEnableSAMLMutation,
|
||||||
|
useDisableSAMLMutation,
|
||||||
|
useInitiateDomainVerificationMutation,
|
||||||
|
useVerifyDomainMutation,
|
||||||
|
} from "/hooks/graph/SAMLConfigurationGraph";
|
||||||
|
import type { SAMLSettingsTabFragment$key } from "./__generated__/SAMLSettingsTabFragment.graphql";
|
||||||
|
|
||||||
|
const samlSettingsTabFragment = graphql`
|
||||||
|
fragment SAMLSettingsTabFragment on Organization {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
samlConfigurations {
|
||||||
|
id
|
||||||
|
enabled
|
||||||
|
emailDomain
|
||||||
|
enforcementPolicy
|
||||||
|
domainVerified
|
||||||
|
domainVerificationToken
|
||||||
|
domainVerifiedAt
|
||||||
|
spEntityId
|
||||||
|
spAcsUrl
|
||||||
|
spMetadataUrl
|
||||||
|
testLoginUrl
|
||||||
|
idpEntityId
|
||||||
|
idpSsoUrl
|
||||||
|
idpCertificate
|
||||||
|
idpMetadataUrl
|
||||||
|
attributeEmail
|
||||||
|
attributeFirstname
|
||||||
|
attributeLastname
|
||||||
|
attributeRole
|
||||||
|
defaultRole
|
||||||
|
autoSignupEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const initiateSchema = z.object({
|
||||||
|
emailDomain: z.string().min(1, "Email domain is required").regex(/^[a-z0-9.-]+\.[a-z]{2,}$/i, "Must be a valid domain (e.g., example.com)"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const samlConfigSchema = z.object({
|
||||||
|
emailDomain: z.string().min(1, "Email domain is required").regex(/^[a-z0-9.-]+\.[a-z]{2,}$/i, "Must be a valid domain (e.g., example.com)"),
|
||||||
|
enforcementPolicy: z.enum(["OFF", "OPTIONAL", "REQUIRED"]),
|
||||||
|
spCertificate: z.string().optional(),
|
||||||
|
spPrivateKey: z.string().optional(),
|
||||||
|
idpEntityId: z.string().min(1, "IdP Entity ID is required"),
|
||||||
|
idpSsoUrl: z.string().url("IdP SSO URL must be a valid URL"),
|
||||||
|
idpCertificate: z.string().min(1, "IdP Certificate is required"),
|
||||||
|
idpMetadataUrl: z.string().url("IdP Metadata URL must be a valid URL").optional().or(z.literal("")),
|
||||||
|
attributeEmail: z.string().optional(),
|
||||||
|
attributeFirstname: z.string().optional(),
|
||||||
|
attributeLastname: z.string().optional(),
|
||||||
|
attributeRole: z.string().optional(),
|
||||||
|
defaultRole: z.string().optional(),
|
||||||
|
autoSignupEnabled: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
type OutletContext = {
|
||||||
|
organization: SAMLSettingsTabFragment$key;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SetupStep = "initiate" | "verify" | "configure";
|
||||||
|
|
||||||
|
export default function SAMLSettingsTab() {
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const { organization: organizationKey } = useOutletContext<OutletContext>();
|
||||||
|
const organization = useFragment(samlSettingsTabFragment, organizationKey);
|
||||||
|
const configs = organization.samlConfigurations;
|
||||||
|
|
||||||
|
const dialogRef = useDialogRef();
|
||||||
|
const [editingConfig, setEditingConfig] = useState<typeof configs[0] | null>(null);
|
||||||
|
const [currentStep, setCurrentStep] = useState<SetupStep>("initiate");
|
||||||
|
const [dnsRecord, setDnsRecord] = useState<string>("");
|
||||||
|
|
||||||
|
const [createMutation, isCreating] = useCreateSAMLConfigurationMutation();
|
||||||
|
const [updateMutation, isUpdating] = useUpdateSAMLConfigurationMutation();
|
||||||
|
const [deleteMutation] = useDeleteSAMLConfigurationMutation();
|
||||||
|
const [enableMutation, isEnabling] = useEnableSAMLMutation();
|
||||||
|
const [disableMutation, isDisabling] = useDisableSAMLMutation();
|
||||||
|
const [initiateDomainMutation, isInitiating] = useInitiateDomainVerificationMutation();
|
||||||
|
const [verifyDomainMutation, isVerifying] = useVerifyDomainMutation();
|
||||||
|
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const handleOpenModal = (config?: typeof configs[0]) => {
|
||||||
|
setEditingConfig(config || null);
|
||||||
|
if (config) {
|
||||||
|
if (!config.domainVerified) {
|
||||||
|
setCurrentStep("verify");
|
||||||
|
setDnsRecord(`probo-verification=${config.domainVerificationToken}`);
|
||||||
|
} else {
|
||||||
|
setCurrentStep("configure");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCurrentStep("initiate");
|
||||||
|
}
|
||||||
|
dialogRef.current?.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setEditingConfig(null);
|
||||||
|
setCurrentStep("initiate");
|
||||||
|
setDnsRecord("");
|
||||||
|
dialogRef.current?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const initiateForm = useFormWithSchema(initiateSchema, {
|
||||||
|
defaultValues: {
|
||||||
|
emailDomain: editingConfig?.emailDomain || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useFormWithSchema(samlConfigSchema, {
|
||||||
|
defaultValues: editingConfig
|
||||||
|
? {
|
||||||
|
emailDomain: editingConfig.emailDomain || "",
|
||||||
|
enforcementPolicy: editingConfig.enforcementPolicy || "OPTIONAL",
|
||||||
|
idpEntityId: editingConfig.idpEntityId || "",
|
||||||
|
idpSsoUrl: editingConfig.idpSsoUrl || "",
|
||||||
|
idpCertificate: editingConfig.idpCertificate || "",
|
||||||
|
idpMetadataUrl: editingConfig.idpMetadataUrl || "",
|
||||||
|
attributeEmail: editingConfig.attributeEmail || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||||
|
attributeFirstname: editingConfig.attributeFirstname || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
||||||
|
attributeLastname: editingConfig.attributeLastname || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
|
||||||
|
attributeRole: editingConfig.attributeRole || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role",
|
||||||
|
defaultRole: editingConfig.defaultRole || "MEMBER",
|
||||||
|
autoSignupEnabled: editingConfig.autoSignupEnabled || false,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
emailDomain: "",
|
||||||
|
enforcementPolicy: "OPTIONAL",
|
||||||
|
idpEntityId: "",
|
||||||
|
idpSsoUrl: "",
|
||||||
|
idpCertificate: "",
|
||||||
|
idpMetadataUrl: "",
|
||||||
|
attributeEmail: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||||
|
attributeFirstname: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
||||||
|
attributeLastname: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
|
||||||
|
attributeRole: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role",
|
||||||
|
defaultRole: "MEMBER",
|
||||||
|
autoSignupEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingConfig) {
|
||||||
|
form.reset({
|
||||||
|
emailDomain: editingConfig.emailDomain || "",
|
||||||
|
enforcementPolicy: editingConfig.enforcementPolicy || "OPTIONAL",
|
||||||
|
idpEntityId: editingConfig.idpEntityId || "",
|
||||||
|
idpSsoUrl: editingConfig.idpSsoUrl || "",
|
||||||
|
idpCertificate: editingConfig.idpCertificate || "",
|
||||||
|
idpMetadataUrl: editingConfig.idpMetadataUrl || "",
|
||||||
|
attributeEmail: editingConfig.attributeEmail || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||||
|
attributeFirstname: editingConfig.attributeFirstname || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
||||||
|
attributeLastname: editingConfig.attributeLastname || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
|
||||||
|
attributeRole: editingConfig.attributeRole || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role",
|
||||||
|
defaultRole: editingConfig.defaultRole || "MEMBER",
|
||||||
|
autoSignupEnabled: editingConfig.autoSignupEnabled || false,
|
||||||
|
});
|
||||||
|
initiateForm.reset({
|
||||||
|
emailDomain: editingConfig.emailDomain || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.reset({
|
||||||
|
emailDomain: "",
|
||||||
|
enforcementPolicy: "OPTIONAL",
|
||||||
|
idpEntityId: "",
|
||||||
|
idpSsoUrl: "",
|
||||||
|
idpCertificate: "",
|
||||||
|
idpMetadataUrl: "",
|
||||||
|
attributeEmail: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||||
|
attributeFirstname: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
||||||
|
attributeLastname: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
|
||||||
|
attributeRole: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role",
|
||||||
|
defaultRole: "MEMBER",
|
||||||
|
autoSignupEnabled: false,
|
||||||
|
});
|
||||||
|
initiateForm.reset({
|
||||||
|
emailDomain: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [editingConfig, form, initiateForm]);
|
||||||
|
|
||||||
|
const handleInitiateDomain = initiateForm.handleSubmit((data) => {
|
||||||
|
initiateDomainMutation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
organizationId: organization.id,
|
||||||
|
emailDomain: data.emailDomain,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted: (response) => {
|
||||||
|
setDnsRecord(response.initiateDomainVerification.dnsRecord);
|
||||||
|
setEditingConfig(response.initiateDomainVerification.samlConfiguration as any);
|
||||||
|
setCurrentStep("verify");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleVerifyDomain = () => {
|
||||||
|
if (!editingConfig) return;
|
||||||
|
verifyDomainMutation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: editingConfig.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted: (response) => {
|
||||||
|
if (response.verifyDomain.verified) {
|
||||||
|
setCurrentStep("configure");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit((data) => {
|
||||||
|
if (editingConfig) {
|
||||||
|
updateMutation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: editingConfig.id,
|
||||||
|
enforcementPolicy: data.enforcementPolicy,
|
||||||
|
idpEntityId: data.idpEntityId,
|
||||||
|
idpSsoUrl: data.idpSsoUrl,
|
||||||
|
idpCertificate: data.idpCertificate,
|
||||||
|
idpMetadataUrl: data.idpMetadataUrl || null,
|
||||||
|
attributeEmail: data.attributeEmail || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||||
|
attributeFirstname: data.attributeFirstname || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
||||||
|
attributeLastname: data.attributeLastname || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
|
||||||
|
attributeRole: data.attributeRole || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role",
|
||||||
|
defaultRole: data.defaultRole || "MEMBER",
|
||||||
|
autoSignupEnabled: data.autoSignupEnabled || false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted: () => {
|
||||||
|
handleCloseModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createMutation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
organizationId: organization.id,
|
||||||
|
emailDomain: data.emailDomain,
|
||||||
|
enforcementPolicy: data.enforcementPolicy,
|
||||||
|
idpEntityId: data.idpEntityId,
|
||||||
|
idpSsoUrl: data.idpSsoUrl,
|
||||||
|
idpCertificate: data.idpCertificate,
|
||||||
|
idpMetadataUrl: data.idpMetadataUrl || null,
|
||||||
|
attributeEmail: data.attributeEmail || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||||
|
attributeFirstname: data.attributeFirstname || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
||||||
|
attributeLastname: data.attributeLastname || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
|
||||||
|
attributeRole: data.attributeRole || "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role",
|
||||||
|
defaultRole: data.defaultRole || "MEMBER",
|
||||||
|
autoSignupEnabled: data.autoSignupEnabled || false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted: () => {
|
||||||
|
handleCloseModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggleEnabled = (config: typeof configs[0]) => {
|
||||||
|
if (config.enabled) {
|
||||||
|
confirm(
|
||||||
|
async () => {
|
||||||
|
disableMutation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: config.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: __("Disable SAML"),
|
||||||
|
message: __(
|
||||||
|
"Are you sure you want to disable SAML authentication for " + config.emailDomain + "?"
|
||||||
|
),
|
||||||
|
label: __("Disable"),
|
||||||
|
variant: "danger",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
enableMutation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: config.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (config: typeof configs[0]) => {
|
||||||
|
confirm(
|
||||||
|
async () => {
|
||||||
|
deleteMutation({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
id: config.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onCompleted: () => {
|
||||||
|
handleCloseModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: __("Delete SAML Configuration"),
|
||||||
|
message: __(
|
||||||
|
"Are you sure you want to delete the SAML configuration for " + config.emailDomain + "? This action cannot be undone."
|
||||||
|
),
|
||||||
|
label: __("Delete"),
|
||||||
|
variant: "danger",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCopy = (url: string) => {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
setCopiedUrl(url);
|
||||||
|
setTimeout(() => setCopiedUrl(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEnforcementPolicyLabel = (policy: string) => {
|
||||||
|
switch (policy) {
|
||||||
|
case "OFF":
|
||||||
|
return __("Your team members can't use single sign-on and must use their password");
|
||||||
|
case "REQUIRED":
|
||||||
|
return __("Your team members must use single sign-on to log in");
|
||||||
|
case "OPTIONAL":
|
||||||
|
default:
|
||||||
|
return __("Your team members may use either single sign-on or their password to log in");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-base font-medium">{__("SAML Single Sign-On")}</h2>
|
||||||
|
<Button onClick={() => handleOpenModal()}>
|
||||||
|
{__("Add Configuration")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{configs.length === 0 ? (
|
||||||
|
<Card padded>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
{__("No SAML Configurations")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
{__("Set up SAML 2.0 single sign-on for your organization by adding a configuration for each email domain.")}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => handleOpenModal()}>
|
||||||
|
{__("Add Your First Configuration")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th>{__("Email Domain")}</Th>
|
||||||
|
<Th>{__("Domain Status")}</Th>
|
||||||
|
<Th>{__("SAML Status")}</Th>
|
||||||
|
<Th>{__("Enforcement")}</Th>
|
||||||
|
<Th>{__("SSO URL")}</Th>
|
||||||
|
<Th></Th>
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{configs.map((config) => (
|
||||||
|
<Tr key={config.id}>
|
||||||
|
<Td>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenModal(config)}
|
||||||
|
className="font-semibold text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
{config.emailDomain}
|
||||||
|
</button>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
config.domainVerified
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-yellow-100 text-yellow-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.domainVerified ? __("Verified") : __("Pending Verification")}
|
||||||
|
</span>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
config.enabled
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-gray-100 text-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.enabled ? __("Enabled") : __("Disabled")}
|
||||||
|
</span>
|
||||||
|
</Td>
|
||||||
|
<Td>{config.enforcementPolicy}</Td>
|
||||||
|
<Td>
|
||||||
|
{config.domainVerified && config.enabled ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(config.testLoginUrl)}
|
||||||
|
className="text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
{copiedUrl === config.testLoginUrl ? __("Copied!") : __("Copy URL")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">—</span>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td width={180} className="text-end">
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
{config.domainVerified ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={config.enabled ? "danger" : "primary"}
|
||||||
|
onClick={() => handleToggleEnabled(config)}
|
||||||
|
disabled={isEnabling || isDisabling}
|
||||||
|
>
|
||||||
|
{config.enabled ? __("Disable") : __("Enable")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleOpenModal(config)}
|
||||||
|
>
|
||||||
|
{__("Edit")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => handleOpenModal(config)}
|
||||||
|
>
|
||||||
|
{__("Verify Domain")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => handleDelete(config)}
|
||||||
|
>
|
||||||
|
{__("Delete")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog ref={dialogRef} onClose={handleCloseModal}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogTitle>
|
||||||
|
{currentStep === "initiate" && __("Step 1: Register Domain")}
|
||||||
|
{currentStep === "verify" && __("Step 2: Verify Domain Ownership")}
|
||||||
|
{currentStep === "configure" && (editingConfig?.domainVerified ? __("Configure SAML") : __("Step 3: Configure SAML"))}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
{currentStep === "initiate" && (
|
||||||
|
<form onSubmit={handleInitiateDomain} className="space-y-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium mb-4">
|
||||||
|
{__("Register Your Domain")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
{__("To set up SAML SSO, you must first register and verify ownership of your email domain.")}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
{...initiateForm.register("emailDomain")}
|
||||||
|
label={__("Email Domain") + " *"}
|
||||||
|
placeholder="example.com"
|
||||||
|
error={initiateForm.formState.errors.emailDomain?.message}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-600">
|
||||||
|
{__("The email domain this SAML configuration will apply to (e.g., example.com)")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === "verify" && (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium mb-4">
|
||||||
|
{__("Verify Domain Ownership")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
{__("Add the following TXT record to your domain's DNS configuration to verify ownership:")}
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-sm">{__("Host/Name:")}</span>
|
||||||
|
<code className="ml-2 bg-white px-2 py-1 rounded text-sm">@</code>
|
||||||
|
<span className="ml-2 text-xs text-gray-600">{__("or use your domain name")}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-sm">{__("Type:")}</span>
|
||||||
|
<code className="ml-2 bg-white px-2 py-1 rounded text-sm">TXT</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-sm">{__("Value:")}</span>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<code className="flex-1 bg-white px-2 py-1 rounded text-sm break-all font-mono">
|
||||||
|
{dnsRecord}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => handleCopy(dnsRecord)}
|
||||||
|
>
|
||||||
|
{copiedUrl === dnsRecord ? __("Copied!") : __("Copy")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
<strong>{__("Note:")}</strong> {__("DNS changes may take up to 48 hours to propagate, but typically complete within a few minutes.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === "configure" && (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium mb-4">
|
||||||
|
{__("Basic Configuration")}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Field
|
||||||
|
{...form.register("emailDomain")}
|
||||||
|
label={__("Email Domain") + " *"}
|
||||||
|
placeholder="example.com"
|
||||||
|
disabled={!!editingConfig}
|
||||||
|
error={form.formState.errors.emailDomain?.message}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
{editingConfig
|
||||||
|
? __("Email domain cannot be changed after creation")
|
||||||
|
: __("The email domain this SAML configuration applies to (e.g., example.com)")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="enforcementPolicy">{__("Enforcement Policy") + " *"}</Label>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="enforcementPolicy"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="mt-2">
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
>
|
||||||
|
<Option value="OPTIONAL">{__("Optional")}</Option>
|
||||||
|
<Option value="REQUIRED">{__("Required")}</Option>
|
||||||
|
<Option value="OFF">{__("Off")}</Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{form.watch("enforcementPolicy") && (
|
||||||
|
<p className="text-xs text-gray-600 mt-2">
|
||||||
|
{getEnforcementPolicyLabel(form.watch("enforcementPolicy"))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium mb-4">
|
||||||
|
{__("Identity Provider Configuration")}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
{...form.register("idpEntityId")}
|
||||||
|
label={__("IdP Entity ID") + " *"}
|
||||||
|
placeholder="https://idp.example.com/metadata"
|
||||||
|
error={form.formState.errors.idpEntityId?.message}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
{...form.register("idpSsoUrl")}
|
||||||
|
label={__("IdP SSO URL") + " *"}
|
||||||
|
placeholder="https://idp.example.com/sso"
|
||||||
|
error={form.formState.errors.idpSsoUrl?.message}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="idpCertificate">
|
||||||
|
{__("IdP X.509 Certificate") + " *"}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
{...form.register("idpCertificate")}
|
||||||
|
id="idpCertificate"
|
||||||
|
rows={6}
|
||||||
|
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
{form.formState.errors.idpCertificate && (
|
||||||
|
<p className="text-sm text-red-600 mt-1">
|
||||||
|
{form.formState.errors.idpCertificate.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Field
|
||||||
|
{...form.register("idpMetadataUrl")}
|
||||||
|
label={__("IdP Metadata URL (Optional)")}
|
||||||
|
placeholder="https://idp.example.com/metadata.xml"
|
||||||
|
error={form.formState.errors.idpMetadataUrl?.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium mb-4">
|
||||||
|
{__("Attribute Mapping")}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
{...form.register("attributeEmail")}
|
||||||
|
label={__("Email Attribute")}
|
||||||
|
placeholder="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
|
||||||
|
error={form.formState.errors.attributeEmail?.message}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
{...form.register("attributeFirstname")}
|
||||||
|
label={__("First Name Attribute")}
|
||||||
|
placeholder="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
|
||||||
|
error={form.formState.errors.attributeFirstname?.message}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
{...form.register("attributeLastname")}
|
||||||
|
label={__("Last Name Attribute")}
|
||||||
|
placeholder="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
|
||||||
|
error={form.formState.errors.attributeLastname?.message}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
{...form.register("attributeRole")}
|
||||||
|
label={__("Role Attribute")}
|
||||||
|
placeholder="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role"
|
||||||
|
error={form.formState.errors.attributeRole?.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-medium mb-4">
|
||||||
|
{__("Default Role")}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field
|
||||||
|
{...form.register("defaultRole")}
|
||||||
|
label={__("Default Role")}
|
||||||
|
placeholder="MEMBER"
|
||||||
|
error={form.formState.errors.defaultRole?.message}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{__("The IdP must provide roles directly (OWNER, ADMIN, MEMBER, VIEWER). This default role will be used when the role attribute is missing or invalid.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="autoSignupEnabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="autoSignupEnabled" className="cursor-pointer">
|
||||||
|
{__("Enable automatic user signup via SAML")}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
disabled={isCreating || isUpdating || isInitiating || isVerifying}
|
||||||
|
>
|
||||||
|
{__("Cancel")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{currentStep === "initiate" && (
|
||||||
|
<Button
|
||||||
|
onClick={handleInitiateDomain}
|
||||||
|
disabled={isInitiating}
|
||||||
|
>
|
||||||
|
{__("Next: Verify Domain")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === "verify" && (
|
||||||
|
<Button
|
||||||
|
onClick={handleVerifyDomain}
|
||||||
|
disabled={isVerifying}
|
||||||
|
>
|
||||||
|
{__("Verify and Continue")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === "configure" && (
|
||||||
|
<Button
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={isCreating || isUpdating}
|
||||||
|
>
|
||||||
|
{editingConfig?.domainVerified ? __("Update Configuration") : __("Create Configuration")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
apps/console/src/pages/organizations/settings/__generated__/DomainSettingsTabFragment.graphql.ts
generated
Normal file
154
apps/console/src/pages/organizations/settings/__generated__/DomainSettingsTabFragment.graphql.ts
generated
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* @generated SignedSource<<0e2aa976b1c9bb8dddf8b1dcc2f171d1>>
|
||||||
|
* @lightSyntaxTransform
|
||||||
|
* @nogrep
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { ReaderFragment } from 'relay-runtime';
|
||||||
|
export type SSLStatus = "ACTIVE" | "EXPIRED" | "FAILED" | "PENDING" | "PROVISIONING" | "RENEWING";
|
||||||
|
import { FragmentRefs } from "relay-runtime";
|
||||||
|
export type DomainSettingsTabFragment$data = {
|
||||||
|
readonly customDomain: {
|
||||||
|
readonly createdAt: any;
|
||||||
|
readonly dnsRecords: ReadonlyArray<{
|
||||||
|
readonly name: string;
|
||||||
|
readonly purpose: string;
|
||||||
|
readonly ttl: number;
|
||||||
|
readonly type: string;
|
||||||
|
readonly value: string;
|
||||||
|
}>;
|
||||||
|
readonly domain: string;
|
||||||
|
readonly id: string;
|
||||||
|
readonly sslExpiresAt: any | null | undefined;
|
||||||
|
readonly sslStatus: SSLStatus;
|
||||||
|
readonly updatedAt: any;
|
||||||
|
} | null | undefined;
|
||||||
|
readonly id: string;
|
||||||
|
readonly " $fragmentType": "DomainSettingsTabFragment";
|
||||||
|
};
|
||||||
|
export type DomainSettingsTabFragment$key = {
|
||||||
|
readonly " $data"?: DomainSettingsTabFragment$data;
|
||||||
|
readonly " $fragmentSpreads": FragmentRefs<"DomainSettingsTabFragment">;
|
||||||
|
};
|
||||||
|
|
||||||
|
const node: ReaderFragment = (function(){
|
||||||
|
var v0 = {
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "id",
|
||||||
|
"storageKey": null
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
"argumentDefinitions": [],
|
||||||
|
"kind": "Fragment",
|
||||||
|
"metadata": null,
|
||||||
|
"name": "DomainSettingsTabFragment",
|
||||||
|
"selections": [
|
||||||
|
(v0/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "CustomDomain",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "customDomain",
|
||||||
|
"plural": false,
|
||||||
|
"selections": [
|
||||||
|
(v0/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "domain",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "sslStatus",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "DNSRecordInstruction",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "dnsRecords",
|
||||||
|
"plural": true,
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "type",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "name",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "value",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "ttl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "purpose",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "createdAt",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "updatedAt",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "sslExpiresAt",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "Organization",
|
||||||
|
"abstractKey": null
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
(node as any).hash = "00306efb96d302284155f5324ba2fb99";
|
||||||
|
|
||||||
|
export default node;
|
||||||
114
apps/console/src/pages/organizations/settings/__generated__/GeneralSettingsTabFragment.graphql.ts
generated
Normal file
114
apps/console/src/pages/organizations/settings/__generated__/GeneralSettingsTabFragment.graphql.ts
generated
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* @generated SignedSource<<02beba57812b8dd7c5c61fb10291de5a>>
|
||||||
|
* @lightSyntaxTransform
|
||||||
|
* @nogrep
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { ReaderFragment } from 'relay-runtime';
|
||||||
|
import { FragmentRefs } from "relay-runtime";
|
||||||
|
export type GeneralSettingsTabFragment$data = {
|
||||||
|
readonly createdAt: any;
|
||||||
|
readonly description: string | null | undefined;
|
||||||
|
readonly email: string | null | undefined;
|
||||||
|
readonly headquarterAddress: string | null | undefined;
|
||||||
|
readonly horizontalLogoUrl: string | null | undefined;
|
||||||
|
readonly id: string;
|
||||||
|
readonly logoUrl: string | null | undefined;
|
||||||
|
readonly name: string;
|
||||||
|
readonly updatedAt: any;
|
||||||
|
readonly websiteUrl: string | null | undefined;
|
||||||
|
readonly " $fragmentType": "GeneralSettingsTabFragment";
|
||||||
|
};
|
||||||
|
export type GeneralSettingsTabFragment$key = {
|
||||||
|
readonly " $data"?: GeneralSettingsTabFragment$data;
|
||||||
|
readonly " $fragmentSpreads": FragmentRefs<"GeneralSettingsTabFragment">;
|
||||||
|
};
|
||||||
|
|
||||||
|
const node: ReaderFragment = {
|
||||||
|
"argumentDefinitions": [],
|
||||||
|
"kind": "Fragment",
|
||||||
|
"metadata": null,
|
||||||
|
"name": "GeneralSettingsTabFragment",
|
||||||
|
"selections": [
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "id",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "name",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "logoUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "horizontalLogoUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "description",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "websiteUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "email",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "headquarterAddress",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "createdAt",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "updatedAt",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "Organization",
|
||||||
|
"abstractKey": null
|
||||||
|
};
|
||||||
|
|
||||||
|
(node as any).hash = "f6552148ce1c0061c4f5cbef78d46c0e";
|
||||||
|
|
||||||
|
export default node;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<4ed3d530d746d8b84b2dba8752e57abc>>
|
* @generated SignedSource<<8e0818a9214ba3613f9d356e61a75e08>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -12,10 +12,10 @@ import { ConcreteRequest } from 'relay-runtime';
|
|||||||
export type DeleteOrganizationHorizontalLogoInput = {
|
export type DeleteOrganizationHorizontalLogoInput = {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
};
|
};
|
||||||
export type SettingsPage_DeleteHorizontalLogoMutation$variables = {
|
export type GeneralSettingsTab_DeleteHorizontalLogoMutation$variables = {
|
||||||
input: DeleteOrganizationHorizontalLogoInput;
|
input: DeleteOrganizationHorizontalLogoInput;
|
||||||
};
|
};
|
||||||
export type SettingsPage_DeleteHorizontalLogoMutation$data = {
|
export type GeneralSettingsTab_DeleteHorizontalLogoMutation$data = {
|
||||||
readonly deleteOrganizationHorizontalLogo: {
|
readonly deleteOrganizationHorizontalLogo: {
|
||||||
readonly organization: {
|
readonly organization: {
|
||||||
readonly horizontalLogoUrl: string | null | undefined;
|
readonly horizontalLogoUrl: string | null | undefined;
|
||||||
@@ -23,9 +23,9 @@ export type SettingsPage_DeleteHorizontalLogoMutation$data = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type SettingsPage_DeleteHorizontalLogoMutation = {
|
export type GeneralSettingsTab_DeleteHorizontalLogoMutation = {
|
||||||
response: SettingsPage_DeleteHorizontalLogoMutation$data;
|
response: GeneralSettingsTab_DeleteHorizontalLogoMutation$data;
|
||||||
variables: SettingsPage_DeleteHorizontalLogoMutation$variables;
|
variables: GeneralSettingsTab_DeleteHorizontalLogoMutation$variables;
|
||||||
};
|
};
|
||||||
|
|
||||||
const node: ConcreteRequest = (function(){
|
const node: ConcreteRequest = (function(){
|
||||||
@@ -85,7 +85,7 @@ return {
|
|||||||
"argumentDefinitions": (v0/*: any*/),
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
"kind": "Fragment",
|
"kind": "Fragment",
|
||||||
"metadata": null,
|
"metadata": null,
|
||||||
"name": "SettingsPage_DeleteHorizontalLogoMutation",
|
"name": "GeneralSettingsTab_DeleteHorizontalLogoMutation",
|
||||||
"selections": (v1/*: any*/),
|
"selections": (v1/*: any*/),
|
||||||
"type": "Mutation",
|
"type": "Mutation",
|
||||||
"abstractKey": null
|
"abstractKey": null
|
||||||
@@ -94,20 +94,20 @@ return {
|
|||||||
"operation": {
|
"operation": {
|
||||||
"argumentDefinitions": (v0/*: any*/),
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
"kind": "Operation",
|
"kind": "Operation",
|
||||||
"name": "SettingsPage_DeleteHorizontalLogoMutation",
|
"name": "GeneralSettingsTab_DeleteHorizontalLogoMutation",
|
||||||
"selections": (v1/*: any*/)
|
"selections": (v1/*: any*/)
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "e631480d50c9347050fdc62075a2e3a3",
|
"cacheID": "fbfaf507b48e2ef274e44372a70b3d88",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"name": "SettingsPage_DeleteHorizontalLogoMutation",
|
"name": "GeneralSettingsTab_DeleteHorizontalLogoMutation",
|
||||||
"operationKind": "mutation",
|
"operationKind": "mutation",
|
||||||
"text": "mutation SettingsPage_DeleteHorizontalLogoMutation(\n $input: DeleteOrganizationHorizontalLogoInput!\n) {\n deleteOrganizationHorizontalLogo(input: $input) {\n organization {\n id\n horizontalLogoUrl\n }\n }\n}\n"
|
"text": "mutation GeneralSettingsTab_DeleteHorizontalLogoMutation(\n $input: DeleteOrganizationHorizontalLogoInput!\n) {\n deleteOrganizationHorizontalLogo(input: $input) {\n organization {\n id\n horizontalLogoUrl\n }\n }\n}\n"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "751c3ff44c59511451095ffc66446c2f";
|
(node as any).hash = "7910936d423f99e36ee0a082f5c9336c";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<ac93ed76dd8e53876d200e40f5b62edb>>
|
* @generated SignedSource<<270832e99647c6636914a9644a65358c>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -19,10 +19,10 @@ export type UpdateOrganizationInput = {
|
|||||||
organizationId: string;
|
organizationId: string;
|
||||||
websiteUrl?: string | null | undefined;
|
websiteUrl?: string | null | undefined;
|
||||||
};
|
};
|
||||||
export type SettingsPage_UpdateMutation$variables = {
|
export type GeneralSettingsTab_UpdateMutation$variables = {
|
||||||
input: UpdateOrganizationInput;
|
input: UpdateOrganizationInput;
|
||||||
};
|
};
|
||||||
export type SettingsPage_UpdateMutation$data = {
|
export type GeneralSettingsTab_UpdateMutation$data = {
|
||||||
readonly updateOrganization: {
|
readonly updateOrganization: {
|
||||||
readonly organization: {
|
readonly organization: {
|
||||||
readonly description: string | null | undefined;
|
readonly description: string | null | undefined;
|
||||||
@@ -36,9 +36,9 @@ export type SettingsPage_UpdateMutation$data = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type SettingsPage_UpdateMutation = {
|
export type GeneralSettingsTab_UpdateMutation = {
|
||||||
response: SettingsPage_UpdateMutation$data;
|
response: GeneralSettingsTab_UpdateMutation$data;
|
||||||
variables: SettingsPage_UpdateMutation$variables;
|
variables: GeneralSettingsTab_UpdateMutation$variables;
|
||||||
};
|
};
|
||||||
|
|
||||||
const node: ConcreteRequest = (function(){
|
const node: ConcreteRequest = (function(){
|
||||||
@@ -140,7 +140,7 @@ return {
|
|||||||
"argumentDefinitions": (v0/*: any*/),
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
"kind": "Fragment",
|
"kind": "Fragment",
|
||||||
"metadata": null,
|
"metadata": null,
|
||||||
"name": "SettingsPage_UpdateMutation",
|
"name": "GeneralSettingsTab_UpdateMutation",
|
||||||
"selections": (v1/*: any*/),
|
"selections": (v1/*: any*/),
|
||||||
"type": "Mutation",
|
"type": "Mutation",
|
||||||
"abstractKey": null
|
"abstractKey": null
|
||||||
@@ -149,20 +149,20 @@ return {
|
|||||||
"operation": {
|
"operation": {
|
||||||
"argumentDefinitions": (v0/*: any*/),
|
"argumentDefinitions": (v0/*: any*/),
|
||||||
"kind": "Operation",
|
"kind": "Operation",
|
||||||
"name": "SettingsPage_UpdateMutation",
|
"name": "GeneralSettingsTab_UpdateMutation",
|
||||||
"selections": (v1/*: any*/)
|
"selections": (v1/*: any*/)
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "097a6e519249d6f2b3c2a95963f85a6c",
|
"cacheID": "55c07b334317a5ca30023ef5354a6c45",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"name": "SettingsPage_UpdateMutation",
|
"name": "GeneralSettingsTab_UpdateMutation",
|
||||||
"operationKind": "mutation",
|
"operationKind": "mutation",
|
||||||
"text": "mutation SettingsPage_UpdateMutation(\n $input: UpdateOrganizationInput!\n) {\n updateOrganization(input: $input) {\n organization {\n id\n name\n logoUrl\n horizontalLogoUrl\n description\n websiteUrl\n email\n headquarterAddress\n }\n }\n}\n"
|
"text": "mutation GeneralSettingsTab_UpdateMutation(\n $input: UpdateOrganizationInput!\n) {\n updateOrganization(input: $input) {\n organization {\n id\n name\n logoUrl\n horizontalLogoUrl\n description\n websiteUrl\n email\n headquarterAddress\n }\n }\n}\n"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "c676129018636d84dbca9d8c98962af7";
|
(node as any).hash = "f1731adcb4bf7b7214301a612f48567e";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<7ca4537a89a599fe5d2b7ecad753aa64>>
|
* @generated SignedSource<<1077fac0cf9631664adf53347f21a5b0>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
import { ReaderFragment } from 'relay-runtime';
|
import { ReaderFragment } from 'relay-runtime';
|
||||||
export type InvitationStatus = "ACCEPTED" | "EXPIRED" | "PENDING";
|
export type InvitationStatus = "ACCEPTED" | "EXPIRED" | "PENDING";
|
||||||
import { FragmentRefs } from "relay-runtime";
|
import { FragmentRefs } from "relay-runtime";
|
||||||
export type SettingsPageInvitationsFragment$data = {
|
export type MembersSettingsTabInvitationsFragment$data = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly invitations: {
|
readonly invitations: {
|
||||||
readonly __id: string;
|
readonly __id: string;
|
||||||
@@ -29,14 +29,14 @@ export type SettingsPageInvitationsFragment$data = {
|
|||||||
}>;
|
}>;
|
||||||
readonly totalCount: number;
|
readonly totalCount: number;
|
||||||
};
|
};
|
||||||
readonly " $fragmentType": "SettingsPageInvitationsFragment";
|
readonly " $fragmentType": "MembersSettingsTabInvitationsFragment";
|
||||||
};
|
};
|
||||||
export type SettingsPageInvitationsFragment$key = {
|
export type MembersSettingsTabInvitationsFragment$key = {
|
||||||
readonly " $data"?: SettingsPageInvitationsFragment$data;
|
readonly " $data"?: MembersSettingsTabInvitationsFragment$data;
|
||||||
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageInvitationsFragment">;
|
readonly " $fragmentSpreads": FragmentRefs<"MembersSettingsTabInvitationsFragment">;
|
||||||
};
|
};
|
||||||
|
|
||||||
import SettingsInvitationsRefetchQuery_graphql from './SettingsInvitationsRefetchQuery.graphql';
|
import MembersSettingsTabInvitationsRefetchQuery_graphql from './MembersSettingsTabInvitationsRefetchQuery.graphql';
|
||||||
|
|
||||||
const node: ReaderFragment = (function(){
|
const node: ReaderFragment = (function(){
|
||||||
var v0 = [
|
var v0 = [
|
||||||
@@ -105,28 +105,18 @@ return {
|
|||||||
"fragmentPathInResult": [
|
"fragmentPathInResult": [
|
||||||
"node"
|
"node"
|
||||||
],
|
],
|
||||||
"operation": SettingsInvitationsRefetchQuery_graphql,
|
"operation": MembersSettingsTabInvitationsRefetchQuery_graphql,
|
||||||
"identifierInfo": {
|
"identifierInfo": {
|
||||||
"identifierField": "id",
|
"identifierField": "id",
|
||||||
"identifierQueryVariableName": "id"
|
"identifierQueryVariableName": "id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"name": "SettingsPageInvitationsFragment",
|
"name": "MembersSettingsTabInvitationsFragment",
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
"alias": "invitations",
|
"alias": "invitations",
|
||||||
"args": [
|
"args": [
|
||||||
{
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "filter",
|
|
||||||
"value": {
|
|
||||||
"statuses": [
|
|
||||||
"PENDING",
|
|
||||||
"EXPIRED"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"kind": "Variable",
|
"kind": "Variable",
|
||||||
"name": "orderBy",
|
"name": "orderBy",
|
||||||
@@ -135,7 +125,7 @@ return {
|
|||||||
],
|
],
|
||||||
"concreteType": "InvitationConnection",
|
"concreteType": "InvitationConnection",
|
||||||
"kind": "LinkedField",
|
"kind": "LinkedField",
|
||||||
"name": "__SettingsPageInvitations_invitations_connection",
|
"name": "__MembersSettingsTabInvitations_invitations_connection",
|
||||||
"plural": false,
|
"plural": false,
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
@@ -166,14 +156,14 @@ return {
|
|||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "email",
|
"name": "fullName",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "fullName",
|
"name": "email",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -190,6 +180,13 @@ return {
|
|||||||
"name": "status",
|
"name": "status",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "createdAt",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -204,13 +201,6 @@ return {
|
|||||||
"name": "acceptedAt",
|
"name": "acceptedAt",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "createdAt",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -292,6 +282,6 @@ return {
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "b56157db731d3968bb825dd36375ddb7";
|
(node as any).hash = "632fb80f7f536c576adaef2ec4007588";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<39e11ee9cf99dc541aa5a8df259e8438>>
|
* @generated SignedSource<<24e16bb5ea83a195376f3356801ecdd6>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -16,7 +16,7 @@ export type InvitationOrder = {
|
|||||||
direction: OrderDirection;
|
direction: OrderDirection;
|
||||||
field: InvitationOrderField;
|
field: InvitationOrderField;
|
||||||
};
|
};
|
||||||
export type SettingsInvitationsRefetchQuery$variables = {
|
export type MembersSettingsTabInvitationsRefetchQuery$variables = {
|
||||||
after?: any | null | undefined;
|
after?: any | null | undefined;
|
||||||
before?: any | null | undefined;
|
before?: any | null | undefined;
|
||||||
first?: number | null | undefined;
|
first?: number | null | undefined;
|
||||||
@@ -24,14 +24,14 @@ export type SettingsInvitationsRefetchQuery$variables = {
|
|||||||
last?: number | null | undefined;
|
last?: number | null | undefined;
|
||||||
order?: InvitationOrder | null | undefined;
|
order?: InvitationOrder | null | undefined;
|
||||||
};
|
};
|
||||||
export type SettingsInvitationsRefetchQuery$data = {
|
export type MembersSettingsTabInvitationsRefetchQuery$data = {
|
||||||
readonly node: {
|
readonly node: {
|
||||||
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageInvitationsFragment">;
|
readonly " $fragmentSpreads": FragmentRefs<"MembersSettingsTabInvitationsFragment">;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type SettingsInvitationsRefetchQuery = {
|
export type MembersSettingsTabInvitationsRefetchQuery = {
|
||||||
response: SettingsInvitationsRefetchQuery$data;
|
response: MembersSettingsTabInvitationsRefetchQuery$data;
|
||||||
variables: SettingsInvitationsRefetchQuery$variables;
|
variables: MembersSettingsTabInvitationsRefetchQuery$variables;
|
||||||
};
|
};
|
||||||
|
|
||||||
const node: ConcreteRequest = (function(){
|
const node: ConcreteRequest = (function(){
|
||||||
@@ -112,16 +112,6 @@ v12 = {
|
|||||||
v13 = [
|
v13 = [
|
||||||
(v7/*: any*/),
|
(v7/*: any*/),
|
||||||
(v8/*: any*/),
|
(v8/*: any*/),
|
||||||
{
|
|
||||||
"kind": "Literal",
|
|
||||||
"name": "filter",
|
|
||||||
"value": {
|
|
||||||
"statuses": [
|
|
||||||
"PENDING",
|
|
||||||
"EXPIRED"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(v9/*: any*/),
|
(v9/*: any*/),
|
||||||
(v10/*: any*/),
|
(v10/*: any*/),
|
||||||
{
|
{
|
||||||
@@ -142,7 +132,7 @@ return {
|
|||||||
],
|
],
|
||||||
"kind": "Fragment",
|
"kind": "Fragment",
|
||||||
"metadata": null,
|
"metadata": null,
|
||||||
"name": "SettingsInvitationsRefetchQuery",
|
"name": "MembersSettingsTabInvitationsRefetchQuery",
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
@@ -165,7 +155,7 @@ return {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"kind": "FragmentSpread",
|
"kind": "FragmentSpread",
|
||||||
"name": "SettingsPageInvitationsFragment"
|
"name": "MembersSettingsTabInvitationsFragment"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
@@ -185,7 +175,7 @@ return {
|
|||||||
(v3/*: any*/)
|
(v3/*: any*/)
|
||||||
],
|
],
|
||||||
"kind": "Operation",
|
"kind": "Operation",
|
||||||
"name": "SettingsInvitationsRefetchQuery",
|
"name": "MembersSettingsTabInvitationsRefetchQuery",
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
@@ -236,14 +226,14 @@ return {
|
|||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "email",
|
"name": "fullName",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "fullName",
|
"name": "email",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -260,6 +250,13 @@ return {
|
|||||||
"name": "status",
|
"name": "status",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "createdAt",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -274,13 +271,6 @@ return {
|
|||||||
"name": "acceptedAt",
|
"name": "acceptedAt",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "createdAt",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
(v11/*: any*/)
|
(v11/*: any*/)
|
||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
@@ -353,11 +343,10 @@ return {
|
|||||||
"alias": null,
|
"alias": null,
|
||||||
"args": (v13/*: any*/),
|
"args": (v13/*: any*/),
|
||||||
"filters": [
|
"filters": [
|
||||||
"orderBy",
|
"orderBy"
|
||||||
"filter"
|
|
||||||
],
|
],
|
||||||
"handle": "connection",
|
"handle": "connection",
|
||||||
"key": "SettingsPageInvitations_invitations",
|
"key": "MembersSettingsTabInvitations_invitations",
|
||||||
"kind": "LinkedHandle",
|
"kind": "LinkedHandle",
|
||||||
"name": "invitations"
|
"name": "invitations"
|
||||||
}
|
}
|
||||||
@@ -371,16 +360,16 @@ return {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "24b42a44e783a7f8499a568de2c8f9f4",
|
"cacheID": "e6105642c2d4bc7c1023e4456cebff8e",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"name": "SettingsInvitationsRefetchQuery",
|
"name": "MembersSettingsTabInvitationsRefetchQuery",
|
||||||
"operationKind": "query",
|
"operationKind": "query",
|
||||||
"text": "query SettingsInvitationsRefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: InvitationOrder = {direction: ASC, field: CREATED_AT}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...SettingsPageInvitationsFragment_16fISc\n id\n }\n}\n\nfragment SettingsPageInvitationsFragment_16fISc on Organization {\n invitations(first: $first, after: $after, last: $last, before: $before, orderBy: $order, filter: {statuses: [PENDING, EXPIRED]}) {\n totalCount\n edges {\n node {\n id\n email\n fullName\n role\n status\n expiresAt\n acceptedAt\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
|
"text": "query MembersSettingsTabInvitationsRefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: InvitationOrder = {direction: ASC, field: CREATED_AT}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...MembersSettingsTabInvitationsFragment_16fISc\n id\n }\n}\n\nfragment MembersSettingsTabInvitationsFragment_16fISc on Organization {\n invitations(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n totalCount\n edges {\n node {\n id\n fullName\n email\n role\n status\n createdAt\n expiresAt\n acceptedAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "b56157db731d3968bb825dd36375ddb7";
|
(node as any).hash = "632fb80f7f536c576adaef2ec4007588";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<778a6585d3f3d9b346273c20319ce96d>>
|
* @generated SignedSource<<f4c0ddd2099746a005858cecc39d2a23>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -9,13 +9,15 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
import { ReaderFragment } from 'relay-runtime';
|
import { ReaderFragment } from 'relay-runtime';
|
||||||
|
export type UserAuthMethod = "PASSWORD" | "SAML";
|
||||||
import { FragmentRefs } from "relay-runtime";
|
import { FragmentRefs } from "relay-runtime";
|
||||||
export type SettingsPageMembershipsFragment$data = {
|
export type MembersSettingsTabMembershipsFragment$data = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly memberships: {
|
readonly memberships: {
|
||||||
readonly __id: string;
|
readonly __id: string;
|
||||||
readonly edges: ReadonlyArray<{
|
readonly edges: ReadonlyArray<{
|
||||||
readonly node: {
|
readonly node: {
|
||||||
|
readonly authMethod: UserAuthMethod;
|
||||||
readonly createdAt: any;
|
readonly createdAt: any;
|
||||||
readonly emailAddress: string;
|
readonly emailAddress: string;
|
||||||
readonly fullName: string;
|
readonly fullName: string;
|
||||||
@@ -25,14 +27,14 @@ export type SettingsPageMembershipsFragment$data = {
|
|||||||
}>;
|
}>;
|
||||||
readonly totalCount: number;
|
readonly totalCount: number;
|
||||||
};
|
};
|
||||||
readonly " $fragmentType": "SettingsPageMembershipsFragment";
|
readonly " $fragmentType": "MembersSettingsTabMembershipsFragment";
|
||||||
};
|
};
|
||||||
export type SettingsPageMembershipsFragment$key = {
|
export type MembersSettingsTabMembershipsFragment$key = {
|
||||||
readonly " $data"?: SettingsPageMembershipsFragment$data;
|
readonly " $data"?: MembersSettingsTabMembershipsFragment$data;
|
||||||
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageMembershipsFragment">;
|
readonly " $fragmentSpreads": FragmentRefs<"MembersSettingsTabMembershipsFragment">;
|
||||||
};
|
};
|
||||||
|
|
||||||
import SettingsMembershipsRefetchQuery_graphql from './SettingsMembershipsRefetchQuery.graphql';
|
import MembersSettingsTabMembershipsRefetchQuery_graphql from './MembersSettingsTabMembershipsRefetchQuery.graphql';
|
||||||
|
|
||||||
const node: ReaderFragment = (function(){
|
const node: ReaderFragment = (function(){
|
||||||
var v0 = [
|
var v0 = [
|
||||||
@@ -101,14 +103,14 @@ return {
|
|||||||
"fragmentPathInResult": [
|
"fragmentPathInResult": [
|
||||||
"node"
|
"node"
|
||||||
],
|
],
|
||||||
"operation": SettingsMembershipsRefetchQuery_graphql,
|
"operation": MembersSettingsTabMembershipsRefetchQuery_graphql,
|
||||||
"identifierInfo": {
|
"identifierInfo": {
|
||||||
"identifierField": "id",
|
"identifierField": "id",
|
||||||
"identifierQueryVariableName": "id"
|
"identifierQueryVariableName": "id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"name": "SettingsPageMembershipsFragment",
|
"name": "MembersSettingsTabMembershipsFragment",
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
"alias": "memberships",
|
"alias": "memberships",
|
||||||
@@ -121,7 +123,7 @@ return {
|
|||||||
],
|
],
|
||||||
"concreteType": "MembershipConnection",
|
"concreteType": "MembershipConnection",
|
||||||
"kind": "LinkedField",
|
"kind": "LinkedField",
|
||||||
"name": "__SettingsPageMemberships_memberships_connection",
|
"name": "__MembersSettingsTabMemberships_memberships_connection",
|
||||||
"plural": false,
|
"plural": false,
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
@@ -169,6 +171,13 @@ return {
|
|||||||
"name": "role",
|
"name": "role",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "authMethod",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -257,6 +266,6 @@ return {
|
|||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "d28514cdb5181fc1023dc4ea5bddb4f2";
|
(node as any).hash = "c9e341e99052ba74299c5ddd0433d7c0";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<49986e7498d757f2074e93e6cf4747a5>>
|
* @generated SignedSource<<b99cb7c099e133b550b2467abbe270e8>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -16,7 +16,7 @@ export type MembershipOrder = {
|
|||||||
direction: OrderDirection;
|
direction: OrderDirection;
|
||||||
field: MembershipOrderField;
|
field: MembershipOrderField;
|
||||||
};
|
};
|
||||||
export type SettingsMembershipsRefetchQuery$variables = {
|
export type MembersSettingsTabMembershipsRefetchQuery$variables = {
|
||||||
after?: any | null | undefined;
|
after?: any | null | undefined;
|
||||||
before?: any | null | undefined;
|
before?: any | null | undefined;
|
||||||
first?: number | null | undefined;
|
first?: number | null | undefined;
|
||||||
@@ -24,14 +24,14 @@ export type SettingsMembershipsRefetchQuery$variables = {
|
|||||||
last?: number | null | undefined;
|
last?: number | null | undefined;
|
||||||
order?: MembershipOrder | null | undefined;
|
order?: MembershipOrder | null | undefined;
|
||||||
};
|
};
|
||||||
export type SettingsMembershipsRefetchQuery$data = {
|
export type MembersSettingsTabMembershipsRefetchQuery$data = {
|
||||||
readonly node: {
|
readonly node: {
|
||||||
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageMembershipsFragment">;
|
readonly " $fragmentSpreads": FragmentRefs<"MembersSettingsTabMembershipsFragment">;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type SettingsMembershipsRefetchQuery = {
|
export type MembersSettingsTabMembershipsRefetchQuery = {
|
||||||
response: SettingsMembershipsRefetchQuery$data;
|
response: MembersSettingsTabMembershipsRefetchQuery$data;
|
||||||
variables: SettingsMembershipsRefetchQuery$variables;
|
variables: MembersSettingsTabMembershipsRefetchQuery$variables;
|
||||||
};
|
};
|
||||||
|
|
||||||
const node: ConcreteRequest = (function(){
|
const node: ConcreteRequest = (function(){
|
||||||
@@ -132,7 +132,7 @@ return {
|
|||||||
],
|
],
|
||||||
"kind": "Fragment",
|
"kind": "Fragment",
|
||||||
"metadata": null,
|
"metadata": null,
|
||||||
"name": "SettingsMembershipsRefetchQuery",
|
"name": "MembersSettingsTabMembershipsRefetchQuery",
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
@@ -155,7 +155,7 @@ return {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"kind": "FragmentSpread",
|
"kind": "FragmentSpread",
|
||||||
"name": "SettingsPageMembershipsFragment"
|
"name": "MembersSettingsTabMembershipsFragment"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
@@ -175,7 +175,7 @@ return {
|
|||||||
(v3/*: any*/)
|
(v3/*: any*/)
|
||||||
],
|
],
|
||||||
"kind": "Operation",
|
"kind": "Operation",
|
||||||
"name": "SettingsMembershipsRefetchQuery",
|
"name": "MembersSettingsTabMembershipsRefetchQuery",
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
@@ -243,6 +243,13 @@ return {
|
|||||||
"name": "role",
|
"name": "role",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "authMethod",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -325,7 +332,7 @@ return {
|
|||||||
"orderBy"
|
"orderBy"
|
||||||
],
|
],
|
||||||
"handle": "connection",
|
"handle": "connection",
|
||||||
"key": "SettingsPageMemberships_memberships",
|
"key": "MembersSettingsTabMemberships_memberships",
|
||||||
"kind": "LinkedHandle",
|
"kind": "LinkedHandle",
|
||||||
"name": "memberships"
|
"name": "memberships"
|
||||||
}
|
}
|
||||||
@@ -339,16 +346,16 @@ return {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "57c4ca08006166b58c1fb2407f091704",
|
"cacheID": "27627ac3e1ea017ee7783fcfd502c0f8",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"name": "SettingsMembershipsRefetchQuery",
|
"name": "MembersSettingsTabMembershipsRefetchQuery",
|
||||||
"operationKind": "query",
|
"operationKind": "query",
|
||||||
"text": "query SettingsMembershipsRefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: MembershipOrder = {direction: ASC, field: CREATED_AT}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...SettingsPageMembershipsFragment_16fISc\n id\n }\n}\n\nfragment SettingsPageMembershipsFragment_16fISc on Organization {\n memberships(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n totalCount\n edges {\n node {\n id\n fullName\n emailAddress\n role\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
|
"text": "query MembersSettingsTabMembershipsRefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: MembershipOrder = {direction: ASC, field: CREATED_AT}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...MembersSettingsTabMembershipsFragment_16fISc\n id\n }\n}\n\nfragment MembersSettingsTabMembershipsFragment_16fISc on Organization {\n memberships(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n totalCount\n edges {\n node {\n id\n fullName\n emailAddress\n role\n authMethod\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "d28514cdb5181fc1023dc4ea5bddb4f2";
|
(node as any).hash = "c9e341e99052ba74299c5ddd0433d7c0";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<f058193b755f9fed3efe4910aae9a7e3>>
|
* @generated SignedSource<<057a86325a80ac7377b18a50896f01e1>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -12,18 +12,18 @@ import { ConcreteRequest } from 'relay-runtime';
|
|||||||
export type DeleteInvitationInput = {
|
export type DeleteInvitationInput = {
|
||||||
invitationId: string;
|
invitationId: string;
|
||||||
};
|
};
|
||||||
export type SettingsPage_DeleteInvitationMutation$variables = {
|
export type MembersSettingsTab_DeleteInvitationMutation$variables = {
|
||||||
connections: ReadonlyArray<string>;
|
connections: ReadonlyArray<string>;
|
||||||
input: DeleteInvitationInput;
|
input: DeleteInvitationInput;
|
||||||
};
|
};
|
||||||
export type SettingsPage_DeleteInvitationMutation$data = {
|
export type MembersSettingsTab_DeleteInvitationMutation$data = {
|
||||||
readonly deleteInvitation: {
|
readonly deleteInvitation: {
|
||||||
readonly deletedInvitationId: string;
|
readonly deletedInvitationId: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type SettingsPage_DeleteInvitationMutation = {
|
export type MembersSettingsTab_DeleteInvitationMutation = {
|
||||||
response: SettingsPage_DeleteInvitationMutation$data;
|
response: MembersSettingsTab_DeleteInvitationMutation$data;
|
||||||
variables: SettingsPage_DeleteInvitationMutation$variables;
|
variables: MembersSettingsTab_DeleteInvitationMutation$variables;
|
||||||
};
|
};
|
||||||
|
|
||||||
const node: ConcreteRequest = (function(){
|
const node: ConcreteRequest = (function(){
|
||||||
@@ -59,7 +59,7 @@ return {
|
|||||||
],
|
],
|
||||||
"kind": "Fragment",
|
"kind": "Fragment",
|
||||||
"metadata": null,
|
"metadata": null,
|
||||||
"name": "SettingsPage_DeleteInvitationMutation",
|
"name": "MembersSettingsTab_DeleteInvitationMutation",
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
@@ -84,7 +84,7 @@ return {
|
|||||||
(v0/*: any*/)
|
(v0/*: any*/)
|
||||||
],
|
],
|
||||||
"kind": "Operation",
|
"kind": "Operation",
|
||||||
"name": "SettingsPage_DeleteInvitationMutation",
|
"name": "MembersSettingsTab_DeleteInvitationMutation",
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
@@ -117,16 +117,16 @@ return {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "1c362c5db7a985d7548166b2b1eb42c9",
|
"cacheID": "c995f13c967dab141f64f9ad6314f3a2",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"name": "SettingsPage_DeleteInvitationMutation",
|
"name": "MembersSettingsTab_DeleteInvitationMutation",
|
||||||
"operationKind": "mutation",
|
"operationKind": "mutation",
|
||||||
"text": "mutation SettingsPage_DeleteInvitationMutation(\n $input: DeleteInvitationInput!\n) {\n deleteInvitation(input: $input) {\n deletedInvitationId\n }\n}\n"
|
"text": "mutation MembersSettingsTab_DeleteInvitationMutation(\n $input: DeleteInvitationInput!\n) {\n deleteInvitation(input: $input) {\n deletedInvitationId\n }\n}\n"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "3c484508ba04b5a75eca62fa6afeb16d";
|
(node as any).hash = "ad47509295919c7f0e6ff7895777231f";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<d210161405099a17aa25d06ca4563634>>
|
* @generated SignedSource<<b5471d97fe00df7ef4c16669ebc89916>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -13,18 +13,18 @@ export type RemoveMemberInput = {
|
|||||||
memberId: string;
|
memberId: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
};
|
};
|
||||||
export type SettingsPage_RemoveMemberMutation$variables = {
|
export type MembersSettingsTab_RemoveMemberMutation$variables = {
|
||||||
connections: ReadonlyArray<string>;
|
connections: ReadonlyArray<string>;
|
||||||
input: RemoveMemberInput;
|
input: RemoveMemberInput;
|
||||||
};
|
};
|
||||||
export type SettingsPage_RemoveMemberMutation$data = {
|
export type MembersSettingsTab_RemoveMemberMutation$data = {
|
||||||
readonly removeMember: {
|
readonly removeMember: {
|
||||||
readonly deletedMemberId: string;
|
readonly deletedMemberId: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export type SettingsPage_RemoveMemberMutation = {
|
export type MembersSettingsTab_RemoveMemberMutation = {
|
||||||
response: SettingsPage_RemoveMemberMutation$data;
|
response: MembersSettingsTab_RemoveMemberMutation$data;
|
||||||
variables: SettingsPage_RemoveMemberMutation$variables;
|
variables: MembersSettingsTab_RemoveMemberMutation$variables;
|
||||||
};
|
};
|
||||||
|
|
||||||
const node: ConcreteRequest = (function(){
|
const node: ConcreteRequest = (function(){
|
||||||
@@ -60,7 +60,7 @@ return {
|
|||||||
],
|
],
|
||||||
"kind": "Fragment",
|
"kind": "Fragment",
|
||||||
"metadata": null,
|
"metadata": null,
|
||||||
"name": "SettingsPage_RemoveMemberMutation",
|
"name": "MembersSettingsTab_RemoveMemberMutation",
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
@@ -85,7 +85,7 @@ return {
|
|||||||
(v0/*: any*/)
|
(v0/*: any*/)
|
||||||
],
|
],
|
||||||
"kind": "Operation",
|
"kind": "Operation",
|
||||||
"name": "SettingsPage_RemoveMemberMutation",
|
"name": "MembersSettingsTab_RemoveMemberMutation",
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
@@ -118,16 +118,16 @@ return {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "e2dd0f4d7327ce3bc97754c85d3f700d",
|
"cacheID": "6ccac45c6bedfbfe98b6c6344ea5df28",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"name": "SettingsPage_RemoveMemberMutation",
|
"name": "MembersSettingsTab_RemoveMemberMutation",
|
||||||
"operationKind": "mutation",
|
"operationKind": "mutation",
|
||||||
"text": "mutation SettingsPage_RemoveMemberMutation(\n $input: RemoveMemberInput!\n) {\n removeMember(input: $input) {\n deletedMemberId\n }\n}\n"
|
"text": "mutation MembersSettingsTab_RemoveMemberMutation(\n $input: RemoveMemberInput!\n) {\n removeMember(input: $input) {\n deletedMemberId\n }\n}\n"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "9909a8b95f8d8621ffdf02da34ec8da2";
|
(node as any).hash = "97f72349476066a0de4e580d2e4e1b0e";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
229
apps/console/src/pages/organizations/settings/__generated__/SAMLSettingsTabFragment.graphql.ts
generated
Normal file
229
apps/console/src/pages/organizations/settings/__generated__/SAMLSettingsTabFragment.graphql.ts
generated
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* @generated SignedSource<<0b3ea1127a3a6388e6d0802abea0307c>>
|
||||||
|
* @lightSyntaxTransform
|
||||||
|
* @nogrep
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
import { ReaderFragment } from 'relay-runtime';
|
||||||
|
export type SAMLEnforcementPolicy = "OFF" | "OPTIONAL" | "REQUIRED";
|
||||||
|
import { FragmentRefs } from "relay-runtime";
|
||||||
|
export type SAMLSettingsTabFragment$data = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly samlConfigurations: ReadonlyArray<{
|
||||||
|
readonly attributeEmail: string;
|
||||||
|
readonly attributeFirstname: string;
|
||||||
|
readonly attributeLastname: string;
|
||||||
|
readonly attributeRole: string;
|
||||||
|
readonly autoSignupEnabled: boolean;
|
||||||
|
readonly defaultRole: string;
|
||||||
|
readonly domainVerificationToken: string | null | undefined;
|
||||||
|
readonly domainVerified: boolean;
|
||||||
|
readonly domainVerifiedAt: any | null | undefined;
|
||||||
|
readonly emailDomain: string;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly enforcementPolicy: SAMLEnforcementPolicy;
|
||||||
|
readonly id: string;
|
||||||
|
readonly idpCertificate: string;
|
||||||
|
readonly idpEntityId: string;
|
||||||
|
readonly idpMetadataUrl: string | null | undefined;
|
||||||
|
readonly idpSsoUrl: string;
|
||||||
|
readonly spAcsUrl: string;
|
||||||
|
readonly spEntityId: string;
|
||||||
|
readonly spMetadataUrl: string;
|
||||||
|
readonly testLoginUrl: string;
|
||||||
|
}>;
|
||||||
|
readonly " $fragmentType": "SAMLSettingsTabFragment";
|
||||||
|
};
|
||||||
|
export type SAMLSettingsTabFragment$key = {
|
||||||
|
readonly " $data"?: SAMLSettingsTabFragment$data;
|
||||||
|
readonly " $fragmentSpreads": FragmentRefs<"SAMLSettingsTabFragment">;
|
||||||
|
};
|
||||||
|
|
||||||
|
const node: ReaderFragment = (function(){
|
||||||
|
var v0 = {
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "id",
|
||||||
|
"storageKey": null
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
"argumentDefinitions": [],
|
||||||
|
"kind": "Fragment",
|
||||||
|
"metadata": null,
|
||||||
|
"name": "SAMLSettingsTabFragment",
|
||||||
|
"selections": [
|
||||||
|
(v0/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "name",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"concreteType": "SAMLConfiguration",
|
||||||
|
"kind": "LinkedField",
|
||||||
|
"name": "samlConfigurations",
|
||||||
|
"plural": true,
|
||||||
|
"selections": [
|
||||||
|
(v0/*: any*/),
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "enabled",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "emailDomain",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "enforcementPolicy",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "domainVerified",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "domainVerificationToken",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "domainVerifiedAt",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "spEntityId",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "spAcsUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "spMetadataUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "testLoginUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpEntityId",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpSsoUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpCertificate",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "idpMetadataUrl",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeEmail",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeFirstname",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeLastname",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "attributeRole",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "defaultRole",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "autoSignupEnabled",
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"storageKey": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"type": "Organization",
|
||||||
|
"abstractKey": null
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
(node as any).hash = "691298e053f77bcb6ef13a5869a86579";
|
||||||
|
|
||||||
|
export default node;
|
||||||
@@ -23,6 +23,27 @@ export class InternalServerError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AuthenticationRequiredError extends Error {
|
||||||
|
public redirectUrl: string;
|
||||||
|
public requiresSaml: boolean;
|
||||||
|
public organizationId: string;
|
||||||
|
public samlConfigId?: string;
|
||||||
|
|
||||||
|
constructor(extensions: {
|
||||||
|
redirectUrl: string;
|
||||||
|
requiresSaml: boolean;
|
||||||
|
organizationId: string;
|
||||||
|
samlConfigId?: string;
|
||||||
|
}) {
|
||||||
|
super("AUTHENTICATION_REQUIRED");
|
||||||
|
this.name = "AuthenticationRequiredError";
|
||||||
|
this.redirectUrl = extensions.redirectUrl;
|
||||||
|
this.requiresSaml = extensions.requiresSaml;
|
||||||
|
this.organizationId = extensions.organizationId;
|
||||||
|
this.samlConfigId = extensions.samlConfigId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildEndpoint(path: string): string {
|
export function buildEndpoint(path: string): string {
|
||||||
const host = import.meta.env.VITE_API_URL;
|
const host = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
@@ -47,6 +68,9 @@ export function buildEndpoint(path: string): string {
|
|||||||
const hasUnauthenticatedError = (error: GraphQLError) =>
|
const hasUnauthenticatedError = (error: GraphQLError) =>
|
||||||
error.extensions?.code == "UNAUTHENTICATED";
|
error.extensions?.code == "UNAUTHENTICATED";
|
||||||
|
|
||||||
|
const hasAuthenticationRequiredError = (error: GraphQLError) =>
|
||||||
|
error.extensions?.code == "AUTHENTICATION_REQUIRED";
|
||||||
|
|
||||||
const fetchRelay: FetchFunction = async (
|
const fetchRelay: FetchFunction = async (
|
||||||
request,
|
request,
|
||||||
variables,
|
variables,
|
||||||
@@ -117,6 +141,20 @@ const fetchRelay: FetchFunction = async (
|
|||||||
throw new UnAuthenticatedError();
|
throw new UnAuthenticatedError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for authentication required errors
|
||||||
|
const authRequiredError = errors.find(hasAuthenticationRequiredError);
|
||||||
|
if (authRequiredError?.extensions) {
|
||||||
|
const { redirectUrl, requiresSaml, organizationId, samlConfigId } = authRequiredError.extensions;
|
||||||
|
|
||||||
|
// Throw the error with all the redirect information
|
||||||
|
throw new AuthenticationRequiredError({
|
||||||
|
redirectUrl: redirectUrl as string,
|
||||||
|
requiresSaml: requiresSaml as boolean,
|
||||||
|
organizationId: organizationId as string,
|
||||||
|
samlConfigId: samlConfigId as string | undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Error fetching GraphQL query '${
|
`Error fetching GraphQL query '${
|
||||||
request.name
|
request.name
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function ErrorBoundary({ error: propsError }: { error?: string }) {
|
|||||||
const error = useRouteError() ?? propsError;
|
const error = useRouteError() ?? propsError;
|
||||||
|
|
||||||
if (error instanceof UnAuthenticatedError) {
|
if (error instanceof UnAuthenticatedError) {
|
||||||
return <Navigate to="/auth/login" />;
|
return <Navigate to="/authentication/login" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PageError error={error?.toString()} />;
|
return <PageError error={error?.toString()} />;
|
||||||
@@ -58,7 +58,7 @@ function ErrorBoundary({ error: propsError }: { error?: string }) {
|
|||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: "/auth",
|
path: "/authentication",
|
||||||
Component: AuthLayout,
|
Component: AuthLayout,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -131,6 +131,30 @@ const routes = [
|
|||||||
organizationId,
|
organizationId,
|
||||||
}),
|
}),
|
||||||
Component: lazy(() => import("./pages/organizations/SettingsPage")),
|
Component: lazy(() => import("./pages/organizations/SettingsPage")),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
loader: () => {
|
||||||
|
throw redirect("general");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "general",
|
||||||
|
Component: lazy(() => import("./pages/organizations/settings/GeneralSettingsTab")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "members",
|
||||||
|
Component: lazy(() => import("./pages/organizations/settings/MembersSettingsTab")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "domain",
|
||||||
|
Component: lazy(() => import("./pages/organizations/settings/DomainSettingsTab")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "saml-sso",
|
||||||
|
Component: lazy(() => import("./pages/organizations/settings/SAMLSettingsTab")),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
...riskRoutes,
|
...riskRoutes,
|
||||||
...measureRoutes,
|
...measureRoutes,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ unit:
|
|||||||
max-queue-size: 2048
|
max-queue-size: 2048
|
||||||
|
|
||||||
probod:
|
probod:
|
||||||
hostname: "localhost:8080"
|
hostname: "https://gearnode.probo.engineering"
|
||||||
encryption-key: "thisisnotasecretAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
encryption-key: "thisisnotasecretAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||||
chrome-dp-addr: "localhost:9222"
|
chrome-dp-addr: "localhost:9222"
|
||||||
|
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.83.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.83.0
|
||||||
github.com/chromedp/cdproto v0.0.0-20250630014756-b7288190f53c
|
github.com/chromedp/cdproto v0.0.0-20250630014756-b7288190f53c
|
||||||
github.com/chromedp/chromedp v0.13.7
|
github.com/chromedp/chromedp v0.13.7
|
||||||
|
github.com/crewjam/saml v0.5.1
|
||||||
github.com/go-chi/chi/v5 v5.2.2
|
github.com/go-chi/chi/v5 v5.2.2
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/jackc/pgx/v5 v5.7.5
|
github.com/jackc/pgx/v5 v5.7.5
|
||||||
@@ -45,6 +46,7 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect
|
||||||
github.com/aws/smithy-go v1.22.4 // indirect
|
github.com/aws/smithy-go v1.22.4 // indirect
|
||||||
|
github.com/beevik/etree v1.5.0 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.2 // indirect
|
||||||
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
|
||||||
@@ -71,6 +73,8 @@ require (
|
|||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
|
||||||
|
github.com/jonboulle/clockwork v0.2.2 // indirect
|
||||||
|
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/olekukonko/tablewriter v1.0.7 // indirect
|
github.com/olekukonko/tablewriter v1.0.7 // indirect
|
||||||
@@ -82,6 +86,7 @@ require (
|
|||||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/russellhaering/goxmldsig v1.4.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
github.com/sosodev/duration v1.3.1 // indirect
|
github.com/sosodev/duration v1.3.1 // indirect
|
||||||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
|
||||||
|
|||||||
28
go.sum
28
go.sum
@@ -36,6 +36,9 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.83.0 h1:5Y75q0RPQoAbieyOuGLhjV9P3txvY
|
|||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.83.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU=
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.83.0/go.mod h1:kUklwasNoCn5YpyAqC/97r6dzTA1SRKJfKq16SXeoDU=
|
||||||
github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw=
|
github.com/aws/smithy-go v1.22.4 h1:uqXzVZNuNexwc/xrh6Tb56u89WDlJY6HS+KC0S4QSjw=
|
||||||
github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
github.com/aws/smithy-go v1.22.4/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||||
|
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||||
|
github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs=
|
||||||
|
github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
|
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
|
||||||
@@ -52,6 +55,9 @@ github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipw
|
|||||||
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI=
|
||||||
|
github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -80,6 +86,8 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
|||||||
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
|
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
@@ -110,16 +118,25 @@ github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQykt
|
|||||||
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||||
github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw=
|
github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw=
|
||||||
github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs=
|
github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs=
|
||||||
|
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
|
||||||
|
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
|
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
|
||||||
|
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
|
||||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
@@ -133,6 +150,7 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA
|
|||||||
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
github.com/pdfcpu/pdfcpu v0.11.0 h1:mL18Y3hSHzSezmnrzA21TqlayBOXuAx7BUzzZyroLGM=
|
github.com/pdfcpu/pdfcpu v0.11.0 h1:mL18Y3hSHzSezmnrzA21TqlayBOXuAx7BUzzZyroLGM=
|
||||||
github.com/pdfcpu/pdfcpu v0.11.0/go.mod h1:F1ca4GIVFdPtmgvIdvXAycAm88noyNxZwzr9CpTy+Mw=
|
github.com/pdfcpu/pdfcpu v0.11.0/go.mod h1:F1ca4GIVFdPtmgvIdvXAycAm88noyNxZwzr9CpTy+Mw=
|
||||||
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -153,8 +171,12 @@ github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTK
|
|||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
|
github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys=
|
||||||
|
github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
@@ -167,6 +189,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
|||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
@@ -254,12 +277,17 @@ google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7E
|
|||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||||
|
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||||
sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ=
|
sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ=
|
||||||
sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4=
|
sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4=
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export function AuthLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-screen text-txt-primary">
|
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-screen text-txt-primary">
|
||||||
<div className="bg-level-0 flex flex-col items-center justify-center">
|
<div className="bg-level-0 flex flex-col items-center justify-center">
|
||||||
<div className="max-w-112">
|
<div className="w-full max-w-md px-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
113
pkg/auth/saml_cleanup.go
Normal file
113
pkg/auth/saml_cleanup.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultCleanupInterval = 1 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
Cleaner struct {
|
||||||
|
pg *pg.Client
|
||||||
|
interval time.Duration
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCleaner(
|
||||||
|
pg *pg.Client,
|
||||||
|
interval time.Duration,
|
||||||
|
logger *log.Logger,
|
||||||
|
) *Cleaner {
|
||||||
|
if interval == 0 {
|
||||||
|
interval = DefaultCleanupInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Cleaner{
|
||||||
|
pg: pg,
|
||||||
|
interval: interval,
|
||||||
|
logger: logger.Named("saml.cleaner"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cleaner) Run(ctx context.Context) error {
|
||||||
|
c.logger.InfoCtx(ctx, "SAML cleaner starting", log.Duration("interval", c.interval))
|
||||||
|
|
||||||
|
if err := c.cleanup(ctx); err != nil {
|
||||||
|
c.logger.ErrorCtx(ctx, "initial cleanup failed", log.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(c.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
c.logger.InfoCtx(ctx, "SAML cleaner shutting down")
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.cleanup(ctx); err != nil {
|
||||||
|
c.logger.ErrorCtx(ctx, "periodic cleanup failed", log.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cleaner) cleanup(ctx context.Context) error {
|
||||||
|
var assertionsDeleted, requestsDeleted, relayStatesDeleted int64
|
||||||
|
|
||||||
|
err := c.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||||
|
count, err := CleanupExpiredAssertions(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
assertionsDeleted = count
|
||||||
|
|
||||||
|
count, err = CleanupExpiredRequests(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
requestsDeleted = count
|
||||||
|
|
||||||
|
count, err = CleanupExpiredRelayStates(ctx, conn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
relayStatesDeleted = count
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if assertionsDeleted > 0 || requestsDeleted > 0 || relayStatesDeleted > 0 {
|
||||||
|
c.logger.InfoCtx(ctx, "cleaned up expired SAML data",
|
||||||
|
log.Int64("assertions", assertionsDeleted),
|
||||||
|
log.Int64("requests", requestsDeleted),
|
||||||
|
log.Int64("relay_states", relayStatesDeleted))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
127
pkg/auth/saml_config_validator.go
Normal file
127
pkg/auth/saml_config_validator.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ValidationError struct {
|
||||||
|
Field string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ValidationError) Error() string {
|
||||||
|
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateIdPConfiguration validates only the IdP (Identity Provider) configuration.
|
||||||
|
// This validates user-provided data from the IdP.
|
||||||
|
// SP (Service Provider) configuration is generated by the application and doesn't need validation.
|
||||||
|
func ValidateIdPConfiguration(
|
||||||
|
idpEntityID string,
|
||||||
|
idpSsoURL string,
|
||||||
|
idpCertificate string,
|
||||||
|
) []ValidationError {
|
||||||
|
var errors []ValidationError
|
||||||
|
|
||||||
|
// Validate IdP Entity ID
|
||||||
|
if idpEntityID == "" {
|
||||||
|
errors = append(errors, ValidationError{
|
||||||
|
Field: "idp_entity_id",
|
||||||
|
Message: "IdP Entity ID cannot be empty",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IdP SSO URL - accept both HTTP and HTTPS
|
||||||
|
if err := validateURL(idpSsoURL, "idp_sso_url"); err != nil {
|
||||||
|
errors = append(errors, *err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate IdP certificate
|
||||||
|
if err := validateCertificate(idpCertificate); err != nil {
|
||||||
|
errors = append(errors, ValidationError{
|
||||||
|
Field: "idp_certificate",
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateURL(urlStr string, fieldName string) *ValidationError {
|
||||||
|
if urlStr == "" {
|
||||||
|
return &ValidationError{
|
||||||
|
Field: fieldName,
|
||||||
|
Message: "URL cannot be empty",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedURL, err := url.Parse(urlStr)
|
||||||
|
if err != nil {
|
||||||
|
return &ValidationError{
|
||||||
|
Field: fieldName,
|
||||||
|
Message: fmt.Sprintf("invalid URL format: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsedURL.Scheme == "" {
|
||||||
|
return &ValidationError{
|
||||||
|
Field: fieldName,
|
||||||
|
Message: "URL must have a scheme (http or https)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||||
|
return &ValidationError{
|
||||||
|
Field: fieldName,
|
||||||
|
Message: "URL scheme must be http or https (found: " + parsedURL.Scheme + ")",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsedURL.Host == "" {
|
||||||
|
return &ValidationError{
|
||||||
|
Field: fieldName,
|
||||||
|
Message: "URL must have a host",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCertificate(certPEM string) error {
|
||||||
|
if certPEM == "" {
|
||||||
|
return fmt.Errorf("certificate cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
block, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if block == nil {
|
||||||
|
return fmt.Errorf("failed to parse certificate PEM")
|
||||||
|
}
|
||||||
|
|
||||||
|
if block.Type != "CERTIFICATE" {
|
||||||
|
return fmt.Errorf("PEM block type must be CERTIFICATE (found: %s)", block.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse X.509 certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
304
pkg/auth/saml_configuration_service.go
Normal file
304
pkg/auth/saml_configuration_service.go
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/getprobo/probo/pkg/coredata"
|
||||||
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
CreateSAMLConfigurationRequest struct {
|
||||||
|
OrganizationID gid.GID
|
||||||
|
EmailDomain string
|
||||||
|
EnforcementPolicy coredata.SAMLEnforcementPolicy
|
||||||
|
IdPEntityID string
|
||||||
|
IdPSsoURL string
|
||||||
|
IdPCertificate string
|
||||||
|
IdPMetadataURL *string
|
||||||
|
AttributeEmail string
|
||||||
|
AttributeFirstname string
|
||||||
|
AttributeLastname string
|
||||||
|
AttributeRole string
|
||||||
|
DefaultRole string
|
||||||
|
AutoSignupEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateSAMLConfigurationRequest struct {
|
||||||
|
ID gid.GID
|
||||||
|
Enabled *bool
|
||||||
|
EnforcementPolicy *coredata.SAMLEnforcementPolicy
|
||||||
|
IdPEntityID *string
|
||||||
|
IdPSsoURL *string
|
||||||
|
IdPCertificate *string
|
||||||
|
IdPMetadataURL *string
|
||||||
|
AttributeEmail *string
|
||||||
|
AttributeFirstname *string
|
||||||
|
AttributeLastname *string
|
||||||
|
AttributeRole *string
|
||||||
|
DefaultRole *string
|
||||||
|
AutoSignupEnabled *bool
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s TenantAuthService) CreateSAMLConfiguration(
|
||||||
|
ctx context.Context,
|
||||||
|
req CreateSAMLConfigurationRequest,
|
||||||
|
) (*coredata.SAMLConfiguration, error) {
|
||||||
|
// Validate only the IdP configuration (user-provided data)
|
||||||
|
validationErrors := ValidateIdPConfiguration(
|
||||||
|
req.IdPEntityID,
|
||||||
|
req.IdPSsoURL,
|
||||||
|
req.IdPCertificate,
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(validationErrors) > 0 {
|
||||||
|
var errMsgs []string
|
||||||
|
for _, err := range validationErrors {
|
||||||
|
errMsgs = append(errMsgs, err.Error())
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("SAML configuration validation failed: %s", strings.Join(errMsgs, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
var config *coredata.SAMLConfiguration
|
||||||
|
|
||||||
|
err := s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
now := time.Now()
|
||||||
|
tenantID := s.scope.GetTenantID()
|
||||||
|
|
||||||
|
var org coredata.Organization
|
||||||
|
if err := org.LoadByID(ctx, tx, s.scope, req.OrganizationID); err != nil {
|
||||||
|
return fmt.Errorf("organization not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config = &coredata.SAMLConfiguration{
|
||||||
|
ID: gid.New(tenantID, coredata.SAMLConfigurationEntityType),
|
||||||
|
OrganizationID: org.ID,
|
||||||
|
EmailDomain: req.EmailDomain,
|
||||||
|
EnforcementPolicy: req.EnforcementPolicy,
|
||||||
|
Enabled: false,
|
||||||
|
IdPEntityID: req.IdPEntityID,
|
||||||
|
IdPSsoURL: req.IdPSsoURL,
|
||||||
|
IdPCertificate: req.IdPCertificate,
|
||||||
|
IdPMetadataURL: req.IdPMetadataURL,
|
||||||
|
AttributeEmail: req.AttributeEmail,
|
||||||
|
AttributeFirstname: req.AttributeFirstname,
|
||||||
|
AttributeLastname: req.AttributeLastname,
|
||||||
|
AttributeRole: req.AttributeRole,
|
||||||
|
DefaultRole: req.DefaultRole,
|
||||||
|
AutoSignupEnabled: req.AutoSignupEnabled,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Insert(ctx, tx, s.scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot insert saml configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s TenantAuthService) UpdateSAMLConfiguration(
|
||||||
|
ctx context.Context,
|
||||||
|
req UpdateSAMLConfigurationRequest,
|
||||||
|
) (*coredata.SAMLConfiguration, error) {
|
||||||
|
var config *coredata.SAMLConfiguration
|
||||||
|
|
||||||
|
err := s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
var cfg coredata.SAMLConfiguration
|
||||||
|
if err := cfg.LoadByID(ctx, tx, s.scope, req.ID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load saml configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Enabled != nil {
|
||||||
|
cfg.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
if req.EnforcementPolicy != nil {
|
||||||
|
cfg.EnforcementPolicy = *req.EnforcementPolicy
|
||||||
|
}
|
||||||
|
if req.IdPEntityID != nil {
|
||||||
|
cfg.IdPEntityID = *req.IdPEntityID
|
||||||
|
}
|
||||||
|
if req.IdPSsoURL != nil {
|
||||||
|
cfg.IdPSsoURL = *req.IdPSsoURL
|
||||||
|
}
|
||||||
|
if req.IdPCertificate != nil {
|
||||||
|
cfg.IdPCertificate = *req.IdPCertificate
|
||||||
|
}
|
||||||
|
if req.IdPMetadataURL != nil {
|
||||||
|
cfg.IdPMetadataURL = req.IdPMetadataURL
|
||||||
|
}
|
||||||
|
if req.AttributeEmail != nil {
|
||||||
|
cfg.AttributeEmail = *req.AttributeEmail
|
||||||
|
}
|
||||||
|
if req.AttributeFirstname != nil {
|
||||||
|
cfg.AttributeFirstname = *req.AttributeFirstname
|
||||||
|
}
|
||||||
|
if req.AttributeLastname != nil {
|
||||||
|
cfg.AttributeLastname = *req.AttributeLastname
|
||||||
|
}
|
||||||
|
if req.AttributeRole != nil {
|
||||||
|
cfg.AttributeRole = *req.AttributeRole
|
||||||
|
}
|
||||||
|
if req.DefaultRole != nil {
|
||||||
|
cfg.DefaultRole = *req.DefaultRole
|
||||||
|
}
|
||||||
|
if req.AutoSignupEnabled != nil {
|
||||||
|
cfg.AutoSignupEnabled = *req.AutoSignupEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := cfg.Update(ctx, tx, s.scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot update saml configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config = &cfg
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s TenantAuthService) DeleteSAMLConfiguration(
|
||||||
|
ctx context.Context,
|
||||||
|
configID gid.GID,
|
||||||
|
) error {
|
||||||
|
return s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
var config coredata.SAMLConfiguration
|
||||||
|
if err := config.LoadByID(ctx, tx, s.scope, configID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load saml configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Delete(ctx, tx, s.scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete saml configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s TenantAuthService) EnableSAMLConfiguration(
|
||||||
|
ctx context.Context,
|
||||||
|
configID gid.GID,
|
||||||
|
) (*coredata.SAMLConfiguration, error) {
|
||||||
|
enabled := true
|
||||||
|
return s.UpdateSAMLConfiguration(ctx, UpdateSAMLConfigurationRequest{
|
||||||
|
ID: configID,
|
||||||
|
Enabled: &enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s TenantAuthService) DisableSAMLConfiguration(
|
||||||
|
ctx context.Context,
|
||||||
|
configID gid.GID,
|
||||||
|
) (*coredata.SAMLConfiguration, error) {
|
||||||
|
disabled := false
|
||||||
|
return s.UpdateSAMLConfiguration(ctx, UpdateSAMLConfigurationRequest{
|
||||||
|
ID: configID,
|
||||||
|
Enabled: &disabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s TenantAuthService) GetSAMLConfigurationByID(
|
||||||
|
ctx context.Context,
|
||||||
|
configID gid.GID,
|
||||||
|
) (*coredata.SAMLConfiguration, error) {
|
||||||
|
var config coredata.SAMLConfiguration
|
||||||
|
|
||||||
|
err := s.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(conn pg.Conn) error {
|
||||||
|
return config.LoadByID(ctx, conn, s.scope, configID)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot load saml configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s TenantAuthService) GetSAMLConfigurationsByOrganizationID(
|
||||||
|
ctx context.Context,
|
||||||
|
organizationID gid.GID,
|
||||||
|
) ([]*coredata.SAMLConfiguration, error) {
|
||||||
|
var configs []*coredata.SAMLConfiguration
|
||||||
|
|
||||||
|
err := s.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(conn pg.Conn) error {
|
||||||
|
var err error
|
||||||
|
configs, err = coredata.LoadSAMLConfigurationsByOrganizationID(ctx, conn, s.scope, organizationID)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot load saml configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Service) CheckSSOAvailabilityByEmail(
|
||||||
|
ctx context.Context,
|
||||||
|
email string,
|
||||||
|
) ([]*coredata.SAMLConfiguration, error) {
|
||||||
|
// Extract domain from email
|
||||||
|
parts := strings.Split(email, "@")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, fmt.Errorf("invalid email format")
|
||||||
|
}
|
||||||
|
domain := parts[1]
|
||||||
|
|
||||||
|
var configs []*coredata.SAMLConfiguration
|
||||||
|
err := s.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(conn pg.Conn) error {
|
||||||
|
var err error
|
||||||
|
configs, err = coredata.LoadAllEnabledSAMLConfigurationsByEmailDomain(ctx, conn, domain)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot load saml configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
147
pkg/auth/saml_mapper.go
Normal file
147
pkg/auth/saml_mapper.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/crewjam/saml"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExtractAttributeValue(assertion *saml.Assertion, attributeName string) (string, error) {
|
||||||
|
if len(assertion.AttributeStatements) == 0 {
|
||||||
|
return "", fmt.Errorf("no attribute statement in assertion")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, attr := range assertion.AttributeStatements[0].Attributes {
|
||||||
|
if attr.Name == attributeName {
|
||||||
|
if len(attr.Values) == 0 {
|
||||||
|
return "", fmt.Errorf("attribute %q has no values", attributeName)
|
||||||
|
}
|
||||||
|
return attr.Values[0].Value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("attribute %q not found in assertion", attributeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractEmailFromAssertion(assertion *saml.Assertion) (string, error) {
|
||||||
|
commonEmailAttributes := []string{
|
||||||
|
"email",
|
||||||
|
"Email",
|
||||||
|
"emailAddress",
|
||||||
|
"mail",
|
||||||
|
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||||
|
"http://schemas.xmlsoap.org/claims/EmailAddress",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, attrName := range commonEmailAttributes {
|
||||||
|
email, err := ExtractAttributeValue(assertion, attrName)
|
||||||
|
if err == nil && email != "" {
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if assertion.Subject != nil && assertion.Subject.NameID != nil && assertion.Subject.NameID.Value != "" {
|
||||||
|
return assertion.Subject.NameID.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("could not extract email from assertion")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractEmailDomain(email string) (string, error) {
|
||||||
|
parts := strings.Split(email, "@")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid email address: %s", email)
|
||||||
|
}
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(parts[1]))
|
||||||
|
if domain == "" {
|
||||||
|
return "", fmt.Errorf("empty domain in email address: %s", email)
|
||||||
|
}
|
||||||
|
return domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MapSAMLRoleToSystemRole(samlRole string, defaultRole string) (string, error) {
|
||||||
|
if samlRole != "" && isValidRole(samlRole) {
|
||||||
|
return samlRole, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidRole(defaultRole) {
|
||||||
|
return "", fmt.Errorf("invalid default role %q", defaultRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultRole, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidRole(role string) bool {
|
||||||
|
switch role {
|
||||||
|
case "OWNER", "ADMIN", "MEMBER", "VIEWER":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExtractUserAttributes(
|
||||||
|
assertion *saml.Assertion,
|
||||||
|
attributeEmail, attributeFirstname, attributeLastname, attributeRole string,
|
||||||
|
) (email, fullname, role string, err error) {
|
||||||
|
if len(assertion.AttributeStatements) == 0 {
|
||||||
|
if assertion.Subject != nil && assertion.Subject.NameID != nil {
|
||||||
|
email = assertion.Subject.NameID.Value
|
||||||
|
fullname = email
|
||||||
|
role = ""
|
||||||
|
return email, fullname, role, nil
|
||||||
|
}
|
||||||
|
return "", "", "", fmt.Errorf("no attribute statement and no NameID in assertion")
|
||||||
|
}
|
||||||
|
|
||||||
|
email, err = ExtractAttributeValue(assertion, attributeEmail)
|
||||||
|
if err != nil {
|
||||||
|
if assertion.Subject != nil && assertion.Subject.NameID != nil {
|
||||||
|
email = assertion.Subject.NameID.Value
|
||||||
|
} else {
|
||||||
|
return "", "", "", fmt.Errorf("failed to extract email: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
firstname, err := ExtractAttributeValue(assertion, attributeFirstname)
|
||||||
|
if err != nil {
|
||||||
|
firstname = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lastname, err := ExtractAttributeValue(assertion, attributeLastname)
|
||||||
|
if err != nil {
|
||||||
|
lastname = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if firstname != "" && lastname != "" {
|
||||||
|
fullname = strings.TrimSpace(firstname + " " + lastname)
|
||||||
|
} else if firstname != "" {
|
||||||
|
fullname = firstname
|
||||||
|
} else if lastname != "" {
|
||||||
|
fullname = lastname
|
||||||
|
} else {
|
||||||
|
fullname = email
|
||||||
|
}
|
||||||
|
|
||||||
|
role, err = ExtractAttributeValue(assertion, attributeRole)
|
||||||
|
if err != nil {
|
||||||
|
role = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return email, fullname, role, nil
|
||||||
|
}
|
||||||
204
pkg/auth/saml_metadata.go
Normal file
204
pkg/auth/saml_metadata.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/crewjam/saml"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateServiceProviderMetadata(
|
||||||
|
entityID string,
|
||||||
|
acsURL string,
|
||||||
|
spCert *x509.Certificate,
|
||||||
|
) ([]byte, error) {
|
||||||
|
certData := base64.StdEncoding.EncodeToString(spCert.Raw)
|
||||||
|
|
||||||
|
trueVal := true
|
||||||
|
|
||||||
|
metadata := &saml.EntityDescriptor{
|
||||||
|
EntityID: entityID,
|
||||||
|
SPSSODescriptors: []saml.SPSSODescriptor{
|
||||||
|
{
|
||||||
|
SSODescriptor: saml.SSODescriptor{
|
||||||
|
RoleDescriptor: saml.RoleDescriptor{
|
||||||
|
ProtocolSupportEnumeration: "urn:oasis:names:tc:SAML:2.0:protocol",
|
||||||
|
KeyDescriptors: []saml.KeyDescriptor{
|
||||||
|
{
|
||||||
|
Use: "signing",
|
||||||
|
KeyInfo: saml.KeyInfo{
|
||||||
|
X509Data: saml.X509Data{
|
||||||
|
X509Certificates: []saml.X509Certificate{
|
||||||
|
{Data: certData},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Use: "encryption",
|
||||||
|
KeyInfo: saml.KeyInfo{
|
||||||
|
X509Data: saml.X509Data{
|
||||||
|
X509Certificates: []saml.X509Certificate{
|
||||||
|
{Data: certData},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AuthnRequestsSigned: &trueVal,
|
||||||
|
WantAssertionsSigned: &trueVal,
|
||||||
|
AssertionConsumerServices: []saml.IndexedEndpoint{
|
||||||
|
{
|
||||||
|
Binding: saml.HTTPPostBinding,
|
||||||
|
Location: acsURL,
|
||||||
|
Index: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
xmlBytes, err := xml.MarshalIndent(metadata, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal SP metadata to XML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return xmlBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseIdPCertificate(certPEM string) (*x509.Certificate, error) {
|
||||||
|
block, _ := pem.Decode([]byte(certPEM))
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode PEM block from IdP certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse X.509 certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cert, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type IdPMetadata struct {
|
||||||
|
EntityID string
|
||||||
|
SsoURL string
|
||||||
|
Certificate string
|
||||||
|
MetadataURL *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseIdPMetadata(metadataXML string) (*IdPMetadata, error) {
|
||||||
|
var entityDescriptor saml.EntityDescriptor
|
||||||
|
if err := xml.Unmarshal([]byte(metadataXML), &entityDescriptor); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse IdP metadata XML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entityDescriptor.IDPSSODescriptors) == 0 {
|
||||||
|
return nil, fmt.Errorf("no IDPSSODescriptor found in metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
idpDescriptor := entityDescriptor.IDPSSODescriptors[0]
|
||||||
|
|
||||||
|
var ssoURL string
|
||||||
|
for _, sso := range idpDescriptor.SingleSignOnServices {
|
||||||
|
if sso.Binding == saml.HTTPPostBinding || sso.Binding == saml.HTTPRedirectBinding {
|
||||||
|
ssoURL = sso.Location
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ssoURL == "" && len(idpDescriptor.SingleSignOnServices) > 0 {
|
||||||
|
ssoURL = idpDescriptor.SingleSignOnServices[0].Location
|
||||||
|
}
|
||||||
|
if ssoURL == "" {
|
||||||
|
return nil, fmt.Errorf("no SingleSignOnService found in metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
var certPEM string
|
||||||
|
for _, keyDescriptor := range idpDescriptor.KeyDescriptors {
|
||||||
|
if keyDescriptor.Use == "signing" || keyDescriptor.Use == "" {
|
||||||
|
if len(keyDescriptor.KeyInfo.X509Data.X509Certificates) > 0 {
|
||||||
|
certData := keyDescriptor.KeyInfo.X509Data.X509Certificates[0].Data
|
||||||
|
certDER, err := base64.StdEncoding.DecodeString(certData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode certificate: %w", err)
|
||||||
|
}
|
||||||
|
certPEM = string(pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
Bytes: certDER,
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if certPEM == "" {
|
||||||
|
return nil, fmt.Errorf("no signing certificate found in metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &IdPMetadata{
|
||||||
|
EntityID: entityDescriptor.EntityID,
|
||||||
|
SsoURL: ssoURL,
|
||||||
|
Certificate: certPEM,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateSelfSignedCertificate(entityID string) (*x509.Certificate, *rsa.PrivateKey, error) {
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to generate RSA private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||||
|
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to generate serial number: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
template := x509.Certificate{
|
||||||
|
SerialNumber: serialNumber,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: entityID,
|
||||||
|
Organization: []string{"Probo"},
|
||||||
|
},
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to create certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := x509.ParseCertificate(certDER)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse created certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cert, privateKey, nil
|
||||||
|
}
|
||||||
585
pkg/auth/saml_service.go
Normal file
585
pkg/auth/saml_service.go
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/crewjam/saml"
|
||||||
|
"github.com/getprobo/probo/pkg/coredata"
|
||||||
|
"github.com/getprobo/probo/pkg/crypto/cipher"
|
||||||
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
SAMLService struct {
|
||||||
|
pg *pg.Client
|
||||||
|
encryptionKey cipher.EncryptionKey
|
||||||
|
baseURL string
|
||||||
|
sessionDuration time.Duration
|
||||||
|
cookieName string
|
||||||
|
cookieSecret string
|
||||||
|
certificate *x509.Certificate
|
||||||
|
privateKey *rsa.PrivateKey
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrSPCertificateNotConfigured struct{}
|
||||||
|
|
||||||
|
ErrSAMLConfigurationNotFound struct {
|
||||||
|
OrganizationID gid.GID
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrSAMLDisabled struct {
|
||||||
|
OrganizationID gid.GID
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrInvalidIdPCertificate struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrInvalidURL struct {
|
||||||
|
Field string
|
||||||
|
URL string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrCannotCreateServiceProvider struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrCannotCreateAuthRequest struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrCannotGenerateRedirectURL struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrCannotParseSAMLResponse struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrCannotValidateAssertion struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrCannotExtractUserAttributes struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrCannotMapRole struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrReplayAttackDetected struct {
|
||||||
|
AssertionID string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e ErrSPCertificateNotConfigured) Error() string {
|
||||||
|
return "SP certificate and private key are not configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrSAMLConfigurationNotFound) Error() string {
|
||||||
|
return fmt.Sprintf("SAML configuration not found for organization %s", e.OrganizationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrSAMLDisabled) Error() string {
|
||||||
|
return fmt.Sprintf("SAML is disabled for organization %s", e.OrganizationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrInvalidIdPCertificate) Error() string {
|
||||||
|
return fmt.Sprintf("cannot parse IdP certificate: %v", e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrInvalidURL) Error() string {
|
||||||
|
return fmt.Sprintf("cannot parse %s URL %q: %v", e.Field, e.URL, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrCannotCreateServiceProvider) Error() string {
|
||||||
|
return fmt.Sprintf("cannot create service provider: %v", e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrCannotCreateAuthRequest) Error() string {
|
||||||
|
return fmt.Sprintf("cannot create AuthnRequest: %v", e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrCannotGenerateRedirectURL) Error() string {
|
||||||
|
return fmt.Sprintf("cannot generate redirect URL: %v", e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrCannotParseSAMLResponse) Error() string {
|
||||||
|
return fmt.Sprintf("cannot parse SAML response: %v", e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrCannotValidateAssertion) Error() string {
|
||||||
|
return fmt.Sprintf("cannot validate assertion: %v", e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrCannotExtractUserAttributes) Error() string {
|
||||||
|
return fmt.Sprintf("cannot extract user attributes: %v", e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrCannotMapRole) Error() string {
|
||||||
|
return fmt.Sprintf("cannot map role: %v", e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrReplayAttackDetected) Error() string {
|
||||||
|
return fmt.Sprintf("replay attack detected for assertion %s: %v", e.AssertionID, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSAMLService(
|
||||||
|
pg *pg.Client,
|
||||||
|
encryptionKey cipher.EncryptionKey,
|
||||||
|
baseURL string,
|
||||||
|
sessionDuration time.Duration,
|
||||||
|
cookieName string,
|
||||||
|
cookieSecret string,
|
||||||
|
certificatePEM string,
|
||||||
|
privateKeyPEM string,
|
||||||
|
logger *log.Logger,
|
||||||
|
) (*SAMLService, error) {
|
||||||
|
var certificate *x509.Certificate
|
||||||
|
var privateKey *rsa.PrivateKey
|
||||||
|
|
||||||
|
if certificatePEM != "" {
|
||||||
|
block, _ := pem.Decode([]byte(certificatePEM))
|
||||||
|
if block == nil || block.Type != "CERTIFICATE" {
|
||||||
|
return nil, fmt.Errorf("invalid certificate PEM format")
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse certificate: %w", err)
|
||||||
|
}
|
||||||
|
certificate = cert
|
||||||
|
}
|
||||||
|
|
||||||
|
if privateKeyPEM != "" {
|
||||||
|
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("invalid private key PEM format")
|
||||||
|
}
|
||||||
|
|
||||||
|
var key *rsa.PrivateKey
|
||||||
|
var err error
|
||||||
|
switch block.Type {
|
||||||
|
case "RSA PRIVATE KEY":
|
||||||
|
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse PKCS1 private key: %w", err)
|
||||||
|
}
|
||||||
|
case "PRIVATE KEY":
|
||||||
|
parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse PKCS8 private key: %w", err)
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
key, ok = parsedKey.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("private key is not RSA")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported private key type: %s", block.Type)
|
||||||
|
}
|
||||||
|
privateKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SAMLService{
|
||||||
|
pg: pg,
|
||||||
|
encryptionKey: encryptionKey,
|
||||||
|
baseURL: baseURL,
|
||||||
|
sessionDuration: sessionDuration,
|
||||||
|
cookieName: cookieName,
|
||||||
|
cookieSecret: cookieSecret,
|
||||||
|
certificate: certificate,
|
||||||
|
privateKey: privateKey,
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLService) GetEntityID() string {
|
||||||
|
return fmt.Sprintf("%s/auth/saml/metadata", s.baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLService) GetAcsURL() string {
|
||||||
|
return fmt.Sprintf("%s/auth/saml/consume", s.baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRawSAMLResponse(encodedResponse string) (*saml.Assertion, error) {
|
||||||
|
rawResponseBuf, err := base64.StdEncoding.DecodeString(encodedResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot decode base64: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response saml.Response
|
||||||
|
if err := xml.Unmarshal(rawResponseBuf, &response); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.Assertion == nil {
|
||||||
|
if response.EncryptedAssertion != nil {
|
||||||
|
return nil, fmt.Errorf("response contains encrypted assertion which cannot be parsed without SP private key")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("response contains no assertion")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Assertion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLService) GetServiceProvider(
|
||||||
|
ctx context.Context,
|
||||||
|
config *coredata.SAMLConfiguration,
|
||||||
|
) (*saml.ServiceProvider, error) {
|
||||||
|
if s.certificate == nil || s.privateKey == nil {
|
||||||
|
return nil, ErrSPCertificateNotConfigured{}
|
||||||
|
}
|
||||||
|
|
||||||
|
idpCert, err := ParseIdPCertificate(config.IdPCertificate)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidIdPCertificate{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
acsURL, err := url.Parse(s.GetAcsURL())
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidURL{Field: "ACS", URL: s.GetAcsURL(), Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
idpSSOURL, err := url.Parse(config.IdPSsoURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidURL{Field: "IdP SSO", URL: config.IdPSsoURL, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := &saml.ServiceProvider{
|
||||||
|
EntityID: s.GetEntityID(),
|
||||||
|
Key: s.privateKey,
|
||||||
|
Certificate: s.certificate,
|
||||||
|
MetadataURL: *acsURL,
|
||||||
|
AcsURL: *acsURL,
|
||||||
|
SloURL: *acsURL,
|
||||||
|
IDPMetadata: &saml.EntityDescriptor{
|
||||||
|
EntityID: config.IdPEntityID,
|
||||||
|
IDPSSODescriptors: []saml.IDPSSODescriptor{
|
||||||
|
{
|
||||||
|
SSODescriptor: saml.SSODescriptor{
|
||||||
|
RoleDescriptor: saml.RoleDescriptor{
|
||||||
|
ProtocolSupportEnumeration: "urn:oasis:names:tc:SAML:2.0:protocol",
|
||||||
|
KeyDescriptors: []saml.KeyDescriptor{
|
||||||
|
{
|
||||||
|
Use: "signing",
|
||||||
|
KeyInfo: saml.KeyInfo{
|
||||||
|
X509Data: saml.X509Data{
|
||||||
|
X509Certificates: []saml.X509Certificate{
|
||||||
|
{Data: base64.StdEncoding.EncodeToString(idpCert.Raw)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SingleSignOnServices: []saml.Endpoint{
|
||||||
|
{
|
||||||
|
Binding: saml.HTTPRedirectBinding,
|
||||||
|
Location: idpSSOURL.String(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return sp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLService) InitiateSAMLLogin(
|
||||||
|
ctx context.Context,
|
||||||
|
organizationID gid.GID,
|
||||||
|
tenantID gid.TenantID,
|
||||||
|
emailDomain string,
|
||||||
|
) (string, error) {
|
||||||
|
var config coredata.SAMLConfiguration
|
||||||
|
scope := coredata.NewScope(tenantID)
|
||||||
|
|
||||||
|
err := s.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(conn pg.Conn) error {
|
||||||
|
return config.LoadByOrganizationIDAndEmailDomain(ctx, conn, scope, organizationID, emailDomain)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrSAMLConfigurationNotFound{OrganizationID: organizationID}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.Enabled {
|
||||||
|
return "", ErrSAMLDisabled{OrganizationID: organizationID}
|
||||||
|
}
|
||||||
|
|
||||||
|
sp, err := s.GetServiceProvider(ctx, &config)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrCannotCreateServiceProvider{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
authReq, err := sp.MakeAuthenticationRequest(
|
||||||
|
config.IdPSsoURL,
|
||||||
|
saml.HTTPRedirectBinding,
|
||||||
|
saml.HTTPPostBinding,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrCannotCreateAuthRequest{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
relayStateToken, err := coredata.GenerateSecureToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot generate relay state token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
requestExpiry := now.Add(10 * time.Minute)
|
||||||
|
relayStateExpiry := now.Add(15 * time.Minute)
|
||||||
|
|
||||||
|
err = s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
samlRequest := coredata.SAMLRequest{
|
||||||
|
ID: authReq.ID,
|
||||||
|
OrganizationID: organizationID,
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: requestExpiry,
|
||||||
|
}
|
||||||
|
if err := samlRequest.Insert(ctx, tx, scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot store SAML request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
relayState := coredata.SAMLRelayState{
|
||||||
|
Token: relayStateToken,
|
||||||
|
OrganizationID: organizationID,
|
||||||
|
SAMLConfigID: config.ID,
|
||||||
|
RequestID: authReq.ID,
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: relayStateExpiry,
|
||||||
|
}
|
||||||
|
if err := relayState.Insert(ctx, tx, scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot store relay state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURL, err := authReq.Redirect(relayStateToken, sp)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrCannotGenerateRedirectURL{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirectURL.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SAMLUserInfo struct {
|
||||||
|
Email string
|
||||||
|
FullName string
|
||||||
|
Role string
|
||||||
|
SAMLSubject string
|
||||||
|
OrganizationID gid.GID
|
||||||
|
TenantID gid.TenantID
|
||||||
|
SAMLConfigID gid.GID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLService) HandleSAMLAssertion(
|
||||||
|
ctx context.Context,
|
||||||
|
req *http.Request,
|
||||||
|
) (*SAMLUserInfo, error) {
|
||||||
|
relayStateToken := req.FormValue("RelayState")
|
||||||
|
if relayStateToken == "" {
|
||||||
|
return nil, fmt.Errorf("missing RelayState in SAML response")
|
||||||
|
}
|
||||||
|
|
||||||
|
var relayState coredata.SAMLRelayState
|
||||||
|
var samlRequest coredata.SAMLRequest
|
||||||
|
var config coredata.SAMLConfiguration
|
||||||
|
var org coredata.Organization
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
err := s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
if err := relayState.Load(ctx, tx, relayStateToken); err != nil {
|
||||||
|
return fmt.Errorf("invalid relay state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if relayState.IsExpired(now) {
|
||||||
|
return coredata.ErrRelayStateExpired{Token: relayStateToken, ExpiresAt: relayState.ExpiresAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := samlRequest.Load(ctx, tx, relayState.RequestID, relayState.OrganizationID); err != nil {
|
||||||
|
return fmt.Errorf("invalid SAML request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if samlRequest.IsExpired(now) {
|
||||||
|
return coredata.ErrSAMLRequestExpired{RequestID: relayState.RequestID, ExpiresAt: samlRequest.ExpiresAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := org.LoadByID(ctx, tx, coredata.NewNoScope(), relayState.OrganizationID); err != nil {
|
||||||
|
return fmt.Errorf("organization not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := relayState.Delete(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete relay state: %w", err)
|
||||||
|
}
|
||||||
|
if err := samlRequest.Delete(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("cannot delete SAML request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
samlResponseEncoded := req.FormValue("SAMLResponse")
|
||||||
|
if samlResponseEncoded == "" {
|
||||||
|
return nil, fmt.Errorf("missing SAMLResponse in request")
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScope(org.TenantID)
|
||||||
|
err = s.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(conn pg.Conn) error {
|
||||||
|
return config.LoadByID(ctx, conn, scope, relayState.SAMLConfigID)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrSAMLConfigurationNotFound{OrganizationID: relayState.OrganizationID}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.Enabled {
|
||||||
|
return nil, ErrSAMLDisabled{OrganizationID: relayState.OrganizationID}
|
||||||
|
}
|
||||||
|
|
||||||
|
sp, err := s.GetServiceProvider(ctx, &config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrCannotCreateServiceProvider{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.URL.Scheme == "" {
|
||||||
|
req.URL.Scheme = "https"
|
||||||
|
}
|
||||||
|
if req.URL.Host == "" {
|
||||||
|
req.URL.Host = req.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
possibleRequestIDs := []string{samlRequest.ID}
|
||||||
|
assertion, err := sp.ParseResponse(req, possibleRequestIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse SAML response (SP EntityID: %s, IdP EntityID: %s): %w",
|
||||||
|
s.GetEntityID(), config.IdPEntityID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ValidateAssertion(assertion, s.GetEntityID(), now); err != nil {
|
||||||
|
return nil, ErrCannotValidateAssertion{Err: err}
|
||||||
|
}
|
||||||
|
if assertion.ID != "" {
|
||||||
|
var expiresAt time.Time
|
||||||
|
if assertion.Conditions != nil && !assertion.Conditions.NotOnOrAfter.IsZero() {
|
||||||
|
expiresAt = assertion.Conditions.NotOnOrAfter
|
||||||
|
} else {
|
||||||
|
expiresAt = now.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := coredata.NewScope(org.TenantID)
|
||||||
|
err = s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
return PreventReplayAttack(ctx, tx, scope, assertion.ID, relayState.OrganizationID, expiresAt)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrReplayAttackDetected{AssertionID: assertion.ID, Err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
email, fullname, samlRole, err := ExtractUserAttributes(
|
||||||
|
assertion,
|
||||||
|
config.AttributeEmail,
|
||||||
|
config.AttributeFirstname,
|
||||||
|
config.AttributeLastname,
|
||||||
|
config.AttributeRole,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrCannotExtractUserAttributes{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
actualEmailDomain, err := ExtractEmailDomain(email)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrCannotExtractUserAttributes{Err: fmt.Errorf("cannot extract domain from email: %w", err)}
|
||||||
|
}
|
||||||
|
if actualEmailDomain != config.EmailDomain {
|
||||||
|
return nil, fmt.Errorf("email domain mismatch: assertion contains email with domain %s but SAML config is for domain %s", actualEmailDomain, config.EmailDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemRole, err := MapSAMLRoleToSystemRole(samlRole, config.DefaultRole)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrCannotMapRole{Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
samlSubject := ""
|
||||||
|
if assertion.Subject != nil && assertion.Subject.NameID != nil {
|
||||||
|
samlSubject = assertion.Subject.NameID.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SAMLUserInfo{
|
||||||
|
Email: email,
|
||||||
|
FullName: fullname,
|
||||||
|
Role: systemRole,
|
||||||
|
SAMLSubject: samlSubject,
|
||||||
|
OrganizationID: relayState.OrganizationID,
|
||||||
|
TenantID: org.TenantID,
|
||||||
|
SAMLConfigID: relayState.SAMLConfigID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLService) GetMetadataURL(organizationID gid.GID) string {
|
||||||
|
return fmt.Sprintf("%s/auth/saml/metadata/%s", s.baseURL, organizationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLService) GenerateMetadata() ([]byte, error) {
|
||||||
|
if s.certificate == nil {
|
||||||
|
return nil, ErrSPCertificateNotConfigured{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GenerateServiceProviderMetadata(
|
||||||
|
s.GetEntityID(),
|
||||||
|
s.GetAcsURL(),
|
||||||
|
s.certificate,
|
||||||
|
)
|
||||||
|
}
|
||||||
110
pkg/auth/saml_validator.go
Normal file
110
pkg/auth/saml_validator.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/crewjam/saml"
|
||||||
|
"github.com/getprobo/probo/pkg/coredata"
|
||||||
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PreventReplayAttack(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope coredata.Scoper,
|
||||||
|
assertionID string,
|
||||||
|
organizationID gid.GID,
|
||||||
|
expiresAt time.Time,
|
||||||
|
) error {
|
||||||
|
var assertion coredata.SAMLAssertion
|
||||||
|
exists, err := assertion.CheckExists(ctx, conn, assertionID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check assertion ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return coredata.ErrAssertionAlreadyUsed{AssertionID: assertionID}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
assertion = coredata.SAMLAssertion{
|
||||||
|
ID: assertionID,
|
||||||
|
OrganizationID: organizationID,
|
||||||
|
UsedAt: now,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := assertion.Insert(ctx, conn, scope); err != nil {
|
||||||
|
return fmt.Errorf("failed to store assertion ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateAssertion(
|
||||||
|
assertion *saml.Assertion,
|
||||||
|
expectedAudience string,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
|
const clockSkewTolerance = 5 * time.Minute
|
||||||
|
|
||||||
|
if assertion.Conditions != nil && !assertion.Conditions.NotBefore.IsZero() {
|
||||||
|
if now.Add(clockSkewTolerance).Before(assertion.Conditions.NotBefore) {
|
||||||
|
return fmt.Errorf("assertion not yet valid (NotBefore: %v, now: %v, tolerance: %v)",
|
||||||
|
assertion.Conditions.NotBefore, now, clockSkewTolerance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if assertion.Conditions != nil && !assertion.Conditions.NotOnOrAfter.IsZero() {
|
||||||
|
if now.Add(-clockSkewTolerance).After(assertion.Conditions.NotOnOrAfter) ||
|
||||||
|
now.Add(-clockSkewTolerance).Equal(assertion.Conditions.NotOnOrAfter) {
|
||||||
|
return fmt.Errorf("assertion expired (NotOnOrAfter: %v, now: %v, tolerance: %v)",
|
||||||
|
assertion.Conditions.NotOnOrAfter, now, clockSkewTolerance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if assertion.Conditions != nil && len(assertion.Conditions.AudienceRestrictions) > 0 {
|
||||||
|
audienceValid := false
|
||||||
|
for _, restriction := range assertion.Conditions.AudienceRestrictions {
|
||||||
|
if restriction.Audience.Value == expectedAudience {
|
||||||
|
audienceValid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !audienceValid {
|
||||||
|
return fmt.Errorf("assertion audience restriction does not match expected audience %q", expectedAudience)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanupExpiredAssertions(ctx context.Context, conn pg.Conn) (int64, error) {
|
||||||
|
return coredata.DeleteExpiredSAMLAssertions(ctx, conn, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanupExpiredRequests(ctx context.Context, conn pg.Conn) (int64, error) {
|
||||||
|
return coredata.DeleteExpiredSAMLRequests(ctx, conn, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanupExpiredRelayStates(ctx context.Context, conn pg.Conn) (int64, error) {
|
||||||
|
return coredata.DeleteExpiredSAMLRelayStates(ctx, conn, time.Now())
|
||||||
|
}
|
||||||
@@ -16,17 +16,22 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/getprobo/probo/packages/emails"
|
"github.com/getprobo/probo/packages/emails"
|
||||||
"github.com/getprobo/probo/pkg/coredata"
|
"github.com/getprobo/probo/pkg/coredata"
|
||||||
|
"github.com/getprobo/probo/pkg/crypto/cipher"
|
||||||
"github.com/getprobo/probo/pkg/crypto/passwdhash"
|
"github.com/getprobo/probo/pkg/crypto/passwdhash"
|
||||||
"github.com/getprobo/probo/pkg/gid"
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
"github.com/getprobo/probo/pkg/statelesstoken"
|
"github.com/getprobo/probo/pkg/statelesstoken"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,13 +40,26 @@ type (
|
|||||||
// No organization-related logic - that belongs to authz service
|
// No organization-related logic - that belongs to authz service
|
||||||
Service struct {
|
Service struct {
|
||||||
pg *pg.Client
|
pg *pg.Client
|
||||||
|
encryptionKey cipher.EncryptionKey
|
||||||
hp *passwdhash.Profile
|
hp *passwdhash.Profile
|
||||||
hostname string
|
hostname string
|
||||||
|
baseURL string
|
||||||
tokenSecret string
|
tokenSecret string
|
||||||
disableSignup bool
|
disableSignup bool
|
||||||
invitationTokenValidity time.Duration
|
invitationTokenValidity time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TenantAuthService handles tenant-scoped authentication operations
|
||||||
|
TenantAuthService struct {
|
||||||
|
pg *pg.Client
|
||||||
|
encryptionKey cipher.EncryptionKey
|
||||||
|
hp *passwdhash.Profile
|
||||||
|
hostname string
|
||||||
|
baseURL string
|
||||||
|
tokenSecret string
|
||||||
|
scope coredata.Scoper
|
||||||
|
}
|
||||||
|
|
||||||
ErrInvalidCredentials struct {
|
ErrInvalidCredentials struct {
|
||||||
message string
|
message string
|
||||||
}
|
}
|
||||||
@@ -137,22 +155,38 @@ func (e ErrSignupDisabled) Error() string {
|
|||||||
func NewService(
|
func NewService(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
pgClient *pg.Client,
|
pgClient *pg.Client,
|
||||||
|
encryptionKey cipher.EncryptionKey,
|
||||||
hp *passwdhash.Profile,
|
hp *passwdhash.Profile,
|
||||||
tokenSecret string,
|
tokenSecret string,
|
||||||
hostname string,
|
hostname string,
|
||||||
|
baseURL string,
|
||||||
disableSignup bool,
|
disableSignup bool,
|
||||||
invitationTokenValidity time.Duration,
|
invitationTokenValidity time.Duration,
|
||||||
) (*Service, error) {
|
) (*Service, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
pg: pgClient,
|
pg: pgClient,
|
||||||
|
encryptionKey: encryptionKey,
|
||||||
hp: hp,
|
hp: hp,
|
||||||
hostname: hostname,
|
hostname: hostname,
|
||||||
|
baseURL: baseURL,
|
||||||
tokenSecret: tokenSecret,
|
tokenSecret: tokenSecret,
|
||||||
disableSignup: disableSignup,
|
disableSignup: disableSignup,
|
||||||
invitationTokenValidity: invitationTokenValidity,
|
invitationTokenValidity: invitationTokenValidity,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthService {
|
||||||
|
return &TenantAuthService{
|
||||||
|
pg: s.pg,
|
||||||
|
encryptionKey: s.encryptionKey,
|
||||||
|
hp: s.hp,
|
||||||
|
hostname: s.hostname,
|
||||||
|
baseURL: s.baseURL,
|
||||||
|
tokenSecret: s.tokenSecret,
|
||||||
|
scope: coredata.NewScope(tenantID),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s Service) ForgetPassword(
|
func (s Service) ForgetPassword(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
email string,
|
email string,
|
||||||
@@ -265,7 +299,7 @@ func (s Service) SignUp(
|
|||||||
err = s.pg.WithTx(
|
err = s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(tx pg.Conn) error {
|
func(tx pg.Conn) error {
|
||||||
if err := user.Insert(ctx, tx); err != nil {
|
if err := user.Insert(ctx, tx, coredata.NewNoScope()); err != nil {
|
||||||
var errUserAlreadyExists *coredata.ErrUserAlreadyExists
|
var errUserAlreadyExists *coredata.ErrUserAlreadyExists
|
||||||
if errors.As(err, &errUserAlreadyExists) {
|
if errors.As(err, &errUserAlreadyExists) {
|
||||||
return &ErrUserAlreadyExists{errUserAlreadyExists.Error()}
|
return &ErrUserAlreadyExists{errUserAlreadyExists.Error()}
|
||||||
@@ -328,6 +362,116 @@ func (s Service) SignUp(
|
|||||||
return user, session, nil
|
return user, session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s Service) CreateOrGetSAMLUser(
|
||||||
|
ctx context.Context,
|
||||||
|
emailAddress string,
|
||||||
|
fullName string,
|
||||||
|
samlSubject string,
|
||||||
|
) (*coredata.User, error) {
|
||||||
|
if _, err := mail.ParseAddress(emailAddress); err != nil {
|
||||||
|
return nil, &ErrInvalidEmail{emailAddress}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fullName == "" {
|
||||||
|
return nil, &ErrInvalidFullName{fullName}
|
||||||
|
}
|
||||||
|
|
||||||
|
if samlSubject == "" {
|
||||||
|
return nil, fmt.Errorf("SAML subject cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user coredata.User
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
err := s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
// Try to load existing user by email
|
||||||
|
if err := user.LoadByEmail(ctx, tx, emailAddress); err == nil {
|
||||||
|
// User exists - update SAML subject and full name if needed
|
||||||
|
needsUpdate := false
|
||||||
|
|
||||||
|
if user.SAMLSubject == nil || *user.SAMLSubject != samlSubject {
|
||||||
|
user.SAMLSubject = &samlSubject
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
if user.FullName != fullName {
|
||||||
|
user.FullName = fullName
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
if !user.EmailAddressVerified {
|
||||||
|
user.EmailAddressVerified = true
|
||||||
|
needsUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsUpdate {
|
||||||
|
user.UpdatedAt = now
|
||||||
|
if err := user.Update(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("cannot update user: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No existing user, create new user (all users are global now)
|
||||||
|
user = coredata.User{
|
||||||
|
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
|
||||||
|
EmailAddress: emailAddress,
|
||||||
|
HashedPassword: nil, // SAML users don't have passwords initially
|
||||||
|
EmailAddressVerified: true, // SAML users are verified by IdP
|
||||||
|
FullName: fullName,
|
||||||
|
SAMLSubject: &samlSubject,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := user.Insert(ctx, tx, coredata.NewNoScope()); err != nil {
|
||||||
|
return fmt.Errorf("cannot insert SAML user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Service) CreateSessionForUser(
|
||||||
|
ctx context.Context,
|
||||||
|
userID gid.GID,
|
||||||
|
sessionDuration time.Duration,
|
||||||
|
) (*coredata.Session, error) {
|
||||||
|
now := time.Now()
|
||||||
|
session := &coredata.Session{
|
||||||
|
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
||||||
|
UserID: userID,
|
||||||
|
Data: coredata.SessionData{},
|
||||||
|
ExpiredAt: now.Add(sessionDuration),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
if err := session.Insert(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("cannot insert session: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s Service) SignIn(
|
func (s Service) SignIn(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
emailAddress string,
|
emailAddress string,
|
||||||
@@ -340,6 +484,69 @@ func (s Service) SignIn(
|
|||||||
user := &coredata.User{}
|
user := &coredata.User{}
|
||||||
session := &coredata.Session{}
|
session := &coredata.Session{}
|
||||||
|
|
||||||
|
err := s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
// Load user by email (all users are global now)
|
||||||
|
if err := user.LoadByEmail(ctx, tx, emailAddress); err != nil {
|
||||||
|
var errUserNotFound *coredata.ErrUserNotFound
|
||||||
|
if errors.As(err, &errUserNotFound) {
|
||||||
|
return &ErrInvalidCredentials{"invalid email or password"}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cannot load user by email: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
match, err := s.hp.ComparePasswordAndHash([]byte(password), user.HashedPassword)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot verify password: %w", err)
|
||||||
|
}
|
||||||
|
if !match {
|
||||||
|
return &ErrInvalidCredentials{"invalid email or password"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new session with password authentication flag set
|
||||||
|
now := time.Now()
|
||||||
|
session = &coredata.Session{
|
||||||
|
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
||||||
|
UserID: user.ID,
|
||||||
|
Data: coredata.SessionData{
|
||||||
|
PasswordAuthenticated: true,
|
||||||
|
SAMLAuthenticatedOrgs: make(map[string]coredata.SAMLAuthInfo),
|
||||||
|
},
|
||||||
|
ExpiredAt: now.Add(24 * time.Hour * 7), // 7 days
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Insert(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("cannot insert session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return session, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Service) SignInWithExistingSession(
|
||||||
|
ctx context.Context,
|
||||||
|
emailAddress string,
|
||||||
|
password string,
|
||||||
|
existingSession *coredata.Session,
|
||||||
|
) (*coredata.Session, *coredata.User, error) {
|
||||||
|
if _, err := mail.ParseAddress(emailAddress); err != nil {
|
||||||
|
return nil, nil, &ErrInvalidCredentials{"invalid email or password"}
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &coredata.User{}
|
||||||
|
session := &coredata.Session{}
|
||||||
|
|
||||||
err := s.pg.WithTx(
|
err := s.pg.WithTx(
|
||||||
ctx,
|
ctx,
|
||||||
func(tx pg.Conn) error {
|
func(tx pg.Conn) error {
|
||||||
@@ -359,18 +566,38 @@ func (s Service) SignIn(
|
|||||||
return &ErrInvalidCredentials{"invalid email or password"}
|
return &ErrInvalidCredentials{"invalid email or password"}
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
if existingSession != nil && existingSession.UserID == user.ID {
|
||||||
session = &coredata.Session{
|
session = &coredata.Session{}
|
||||||
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
if err := session.LoadByID(ctx, tx, existingSession.ID); err != nil {
|
||||||
UserID: user.ID,
|
return fmt.Errorf("cannot load session: %w", err)
|
||||||
Data: coredata.SessionData{},
|
}
|
||||||
ExpiredAt: now.Add(24 * time.Hour * 7), // 7 days
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := session.Insert(ctx, tx); err != nil {
|
session.Data.PasswordAuthenticated = true
|
||||||
return fmt.Errorf("cannot insert session: %w", err)
|
if session.Data.SAMLAuthenticatedOrgs == nil {
|
||||||
|
session.Data.SAMLAuthenticatedOrgs = make(map[string]coredata.SAMLAuthInfo)
|
||||||
|
}
|
||||||
|
session.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
if err := session.Update(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("cannot update session: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
now := time.Now()
|
||||||
|
session = &coredata.Session{
|
||||||
|
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
|
||||||
|
UserID: user.ID,
|
||||||
|
Data: coredata.SessionData{
|
||||||
|
PasswordAuthenticated: true,
|
||||||
|
SAMLAuthenticatedOrgs: make(map[string]coredata.SAMLAuthInfo),
|
||||||
|
},
|
||||||
|
ExpiredAt: now.Add(24 * time.Hour * 7),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Insert(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("cannot insert session: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -510,6 +737,30 @@ func (s Service) UpdateSession(ctx context.Context, sessionID gid.GID) (*coredat
|
|||||||
return session, nil
|
return session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s Service) UpdateSessionData(ctx context.Context, sessionID gid.GID, data coredata.SessionData) error {
|
||||||
|
return s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
session := &coredata.Session{}
|
||||||
|
if err := session.LoadByID(ctx, tx, sessionID); err != nil {
|
||||||
|
return &ErrSessionNotFound{"session not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(session.ExpiredAt) {
|
||||||
|
return &ErrSessionExpired{"session expired"}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Data = data
|
||||||
|
session.UpdatedAt = time.Now()
|
||||||
|
if err := session.Update(ctx, tx); err != nil {
|
||||||
|
return fmt.Errorf("cannot update session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (s Service) ConfirmEmail(ctx context.Context, tokenString string) error {
|
func (s Service) ConfirmEmail(ctx context.Context, tokenString string) error {
|
||||||
payload, err := statelesstoken.ValidateToken[EmailConfirmationData](
|
payload, err := statelesstoken.ValidateToken[EmailConfirmationData](
|
||||||
s.tokenSecret,
|
s.tokenSecret,
|
||||||
@@ -655,7 +906,7 @@ func (s Service) SignupFromInvitation(
|
|||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := user.Insert(ctx, tx); err != nil {
|
if err := user.Insert(ctx, tx, coredata.NewNoScope()); err != nil {
|
||||||
var errUserAlreadyExists *coredata.ErrUserAlreadyExists
|
var errUserAlreadyExists *coredata.ErrUserAlreadyExists
|
||||||
if errors.As(err, &errUserAlreadyExists) {
|
if errors.As(err, &errUserAlreadyExists) {
|
||||||
return &ErrUserAlreadyExists{errUserAlreadyExists.Error()}
|
return &ErrUserAlreadyExists{errUserAlreadyExists.Error()}
|
||||||
@@ -686,3 +937,342 @@ func (s Service) SignupFromInvitation(
|
|||||||
|
|
||||||
return user, session, nil
|
return user, session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsTenantUser removed - all users are now global (no tenant distinction)
|
||||||
|
|
||||||
|
func (s Service) GetUserAuthMethod(
|
||||||
|
ctx context.Context,
|
||||||
|
scope coredata.Scoper,
|
||||||
|
userID gid.GID,
|
||||||
|
organizationID gid.GID,
|
||||||
|
session *coredata.Session,
|
||||||
|
) (coredata.UserAuthMethod, error) {
|
||||||
|
// Load the user to check their email and SAML subject
|
||||||
|
user := &coredata.User{}
|
||||||
|
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||||
|
return user.LoadByID(ctx, conn, userID)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot load user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user doesn't have a SAML subject, they only use password auth
|
||||||
|
if user.SAMLSubject == nil || *user.SAMLSubject == "" {
|
||||||
|
return coredata.UserAuthMethodPassword, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// User has SAML subject - check if there's SAML config for this org + user's domain
|
||||||
|
// Extract domain from user email
|
||||||
|
emailParts := []byte(user.EmailAddress)
|
||||||
|
atIndex := -1
|
||||||
|
for i, b := range emailParts {
|
||||||
|
if b == '@' {
|
||||||
|
atIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if atIndex == -1 {
|
||||||
|
return coredata.UserAuthMethodPassword, nil
|
||||||
|
}
|
||||||
|
domain := string(emailParts[atIndex+1:])
|
||||||
|
|
||||||
|
// Check if SAML is configured for this org + domain
|
||||||
|
var samlConfig coredata.SAMLConfiguration
|
||||||
|
orgScope := coredata.NewScope(organizationID.TenantID())
|
||||||
|
err = s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||||
|
err := samlConfig.LoadByOrganizationIDAndEmailDomain(ctx, conn, orgScope, organizationID, domain)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil // No SAML config for this org+domain
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot check SAML configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If SAML config exists for this org+domain, user enrolled via SAML
|
||||||
|
if samlConfig.ID != (gid.GID{}) {
|
||||||
|
return coredata.UserAuthMethodSAML, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No SAML config for this org, user uses password
|
||||||
|
return coredata.UserAuthMethodPassword, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organization Access Control
|
||||||
|
|
||||||
|
type (
|
||||||
|
// ErrSAMLAuthRequired indicates user must authenticate via SAML to access org
|
||||||
|
ErrSAMLAuthRequired struct {
|
||||||
|
ConfigID gid.GID
|
||||||
|
OrganizationID gid.GID
|
||||||
|
RedirectURL string // SAML IdP login URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrPasswordAuthRequired indicates user must authenticate with password to access org
|
||||||
|
ErrPasswordAuthRequired struct {
|
||||||
|
OrganizationID gid.GID
|
||||||
|
RedirectURL string // Password login page URL
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e ErrSAMLAuthRequired) Error() string {
|
||||||
|
return "SAML authentication required for this organization"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrPasswordAuthRequired) Error() string {
|
||||||
|
return "password authentication required for this organization"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckOrganizationAccess determines if a user can access an organization
|
||||||
|
// based on SAML configuration and session authentication state
|
||||||
|
func (s Service) CheckOrganizationAccess(
|
||||||
|
ctx context.Context,
|
||||||
|
user *coredata.User,
|
||||||
|
organizationID gid.GID,
|
||||||
|
session *coredata.Session,
|
||||||
|
) error {
|
||||||
|
// Extract domain from user email
|
||||||
|
emailParts := []byte(user.EmailAddress)
|
||||||
|
atIndex := -1
|
||||||
|
for i, b := range emailParts {
|
||||||
|
if b == '@' {
|
||||||
|
atIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if atIndex == -1 {
|
||||||
|
return fmt.Errorf("invalid email address format")
|
||||||
|
}
|
||||||
|
domain := string(emailParts[atIndex+1:])
|
||||||
|
|
||||||
|
// Find SAML configuration for this organization and domain
|
||||||
|
var samlConfig coredata.SAMLConfiguration
|
||||||
|
scope := coredata.NewScope(organizationID.TenantID())
|
||||||
|
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||||
|
err := samlConfig.LoadByOrganizationIDAndEmailDomain(ctx, conn, scope, organizationID, domain)
|
||||||
|
if err != nil {
|
||||||
|
// If no SAML config found for this organization and domain, that's okay - not an error
|
||||||
|
// Just means this organization doesn't have SAML configured for this domain
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot check SAML configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SAML is configured and enabled for this domain and organization
|
||||||
|
if samlConfig.ID != (gid.GID{}) && samlConfig.Enabled && samlConfig.DomainVerified {
|
||||||
|
// SAML config exists for this org - check enforcement policy
|
||||||
|
if samlConfig.EnforcementPolicy == coredata.SAMLEnforcementPolicyRequired {
|
||||||
|
// SAML is REQUIRED - check if user has SAML-authenticated for this org
|
||||||
|
authInfo, hasSAMLAuth := session.Data.SAMLAuthenticatedOrgs[organizationID.String()]
|
||||||
|
if !hasSAMLAuth {
|
||||||
|
// Build SAML login URL
|
||||||
|
samlLoginURL := fmt.Sprintf("%s/auth/saml/login/%s", s.baseURL, samlConfig.ID)
|
||||||
|
return ErrSAMLAuthRequired{
|
||||||
|
ConfigID: samlConfig.ID,
|
||||||
|
OrganizationID: organizationID,
|
||||||
|
RedirectURL: samlLoginURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Check if SAML auth is still recent (not too old)
|
||||||
|
// For now, we trust the session lifetime
|
||||||
|
_ = authInfo
|
||||||
|
} else {
|
||||||
|
// SAML is OPTIONAL or OFF - allow either password OR SAML auth for this specific org
|
||||||
|
hasSAMLAuth := false
|
||||||
|
if _, ok := session.Data.SAMLAuthenticatedOrgs[organizationID.String()]; ok {
|
||||||
|
hasSAMLAuth = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !session.Data.PasswordAuthenticated && !hasSAMLAuth {
|
||||||
|
// User needs to authenticate - offer SAML as option
|
||||||
|
samlLoginURL := fmt.Sprintf("%s/auth/saml/login/%s", s.baseURL, samlConfig.ID)
|
||||||
|
return ErrSAMLAuthRequired{
|
||||||
|
ConfigID: samlConfig.ID,
|
||||||
|
OrganizationID: organizationID,
|
||||||
|
RedirectURL: samlLoginURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No SAML configuration for this org+domain combination
|
||||||
|
// Require password authentication for password-only organizations
|
||||||
|
if !session.Data.PasswordAuthenticated {
|
||||||
|
// User hasn't authenticated with password - require password authentication
|
||||||
|
loginURL := fmt.Sprintf("%s/authentication/login?method=password", s.baseURL)
|
||||||
|
return ErrPasswordAuthRequired{
|
||||||
|
OrganizationID: organizationID,
|
||||||
|
RedirectURL: loginURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil // Access granted
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitiateDomainVerification creates a SAML configuration with unverified domain and generates verification token
|
||||||
|
func (s Service) InitiateDomainVerification(
|
||||||
|
ctx context.Context,
|
||||||
|
tenantID gid.TenantID,
|
||||||
|
organizationID gid.GID,
|
||||||
|
emailDomain string,
|
||||||
|
) (*coredata.SAMLConfiguration, error) {
|
||||||
|
token, err := GenerateDomainVerificationToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot generate verification token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var config *coredata.SAMLConfiguration
|
||||||
|
|
||||||
|
err = s.pg.WithTx(ctx, func(tx pg.Conn) error {
|
||||||
|
now := time.Now()
|
||||||
|
scope := coredata.NewScope(tenantID)
|
||||||
|
|
||||||
|
config = &coredata.SAMLConfiguration{
|
||||||
|
ID: gid.New(tenantID, coredata.SAMLConfigurationEntityType),
|
||||||
|
OrganizationID: organizationID,
|
||||||
|
EmailDomain: emailDomain,
|
||||||
|
Enabled: false,
|
||||||
|
EnforcementPolicy: coredata.SAMLEnforcementPolicyOff,
|
||||||
|
DomainVerified: false,
|
||||||
|
DomainVerificationToken: &token,
|
||||||
|
// Default IdP values (placeholders until configured)
|
||||||
|
IdPEntityID: "not-configured",
|
||||||
|
IdPSsoURL: "not-configured",
|
||||||
|
IdPCertificate: "not-configured",
|
||||||
|
// Default attribute mappings
|
||||||
|
AttributeEmail: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
||||||
|
AttributeFirstname: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
|
||||||
|
AttributeLastname: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
|
||||||
|
AttributeRole: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role",
|
||||||
|
DefaultRole: "MEMBER",
|
||||||
|
AutoSignupEnabled: false,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := config.Insert(ctx, tx, scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot insert SAML configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyDomain checks DNS TXT record and marks domain as verified if found
|
||||||
|
func (s Service) VerifyDomain(
|
||||||
|
ctx context.Context,
|
||||||
|
tenantID gid.TenantID,
|
||||||
|
configID gid.GID,
|
||||||
|
) (*coredata.SAMLConfiguration, bool, error) {
|
||||||
|
var config *coredata.SAMLConfiguration
|
||||||
|
var verified bool
|
||||||
|
|
||||||
|
err := s.pg.WithTx(ctx, func(tx pg.Conn) error {
|
||||||
|
scope := coredata.NewScope(tenantID)
|
||||||
|
|
||||||
|
// Load config
|
||||||
|
config = &coredata.SAMLConfiguration{}
|
||||||
|
if err := config.LoadByID(ctx, tx, scope, configID); err != nil {
|
||||||
|
return fmt.Errorf("cannot load SAML configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.DomainVerificationToken == nil {
|
||||||
|
return fmt.Errorf("no verification token found for this configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.DomainVerified {
|
||||||
|
verified = true
|
||||||
|
return nil // Already verified
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check DNS TXT record
|
||||||
|
isVerified, err := VerifyDomainOwnership(ctx, config.EmailDomain, *config.DomainVerificationToken)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot verify domain ownership: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verified = isVerified
|
||||||
|
|
||||||
|
if isVerified {
|
||||||
|
now := time.Now()
|
||||||
|
config.DomainVerified = true
|
||||||
|
config.DomainVerifiedAt = &now
|
||||||
|
config.UpdatedAt = now
|
||||||
|
|
||||||
|
if err := config.Update(ctx, tx, scope); err != nil {
|
||||||
|
return fmt.Errorf("cannot update SAML configuration: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, verified, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain Verification Methods
|
||||||
|
|
||||||
|
// GenerateDomainVerificationToken generates a random 32-character hex token for domain verification
|
||||||
|
func GenerateDomainVerificationToken() (string, error) {
|
||||||
|
bytes := make([]byte, 16) // 16 bytes = 32 hex characters
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", fmt.Errorf("cannot generate domain verification token: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDomainVerificationRecord returns the DNS TXT record string that should be added to the domain
|
||||||
|
func GetDomainVerificationRecord(token string) string {
|
||||||
|
return fmt.Sprintf("probo-verification=%s", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyDomainOwnership performs DNS lookup to verify domain ownership via TXT record
|
||||||
|
func VerifyDomainOwnership(ctx context.Context, domain, expectedToken string) (bool, error) {
|
||||||
|
// Use net package for DNS TXT record lookup
|
||||||
|
var txtRecords []string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Create a DNS resolver with timeout from context
|
||||||
|
resolver := &net.Resolver{
|
||||||
|
PreferGo: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
txtRecords, err = resolver.LookupTXT(ctx, domain)
|
||||||
|
if err != nil {
|
||||||
|
// DNS lookup errors are expected if the domain doesn't exist or has no TXT records
|
||||||
|
// We return false (not verified) but not an error, as this is a normal case
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any TXT record matches our verification token
|
||||||
|
expectedRecord := GetDomainVerificationRecord(expectedToken)
|
||||||
|
for _, record := range txtRecords {
|
||||||
|
if record == expectedRecord {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token not found in DNS records
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -261,6 +261,61 @@ func (s *Service) AcceptInvitationByID(
|
|||||||
return acceptedInvitation, nil
|
return acceptedInvitation, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureSAMLMembership creates or updates a user's membership in an organization.
|
||||||
|
// This is used during SAML authentication to ensure the user has the correct role.
|
||||||
|
// This method is on Service (not TenantAuthzService) because SAML authentication
|
||||||
|
// happens before the user has tenant access.
|
||||||
|
func (s *Service) EnsureSAMLMembership(
|
||||||
|
ctx context.Context,
|
||||||
|
tenantID gid.TenantID,
|
||||||
|
userID gid.GID,
|
||||||
|
organizationID gid.GID,
|
||||||
|
role string,
|
||||||
|
) error {
|
||||||
|
scope := coredata.NewScope(tenantID)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
return s.pg.WithTx(
|
||||||
|
ctx,
|
||||||
|
func(tx pg.Conn) error {
|
||||||
|
var membership coredata.Membership
|
||||||
|
|
||||||
|
// Try to load existing membership
|
||||||
|
err := membership.LoadByUserAndOrg(ctx, tx, scope, userID, organizationID)
|
||||||
|
if err != nil {
|
||||||
|
// Membership doesn't exist, create it
|
||||||
|
membershipID := gid.New(tenantID, coredata.MembershipEntityType)
|
||||||
|
membership = coredata.Membership{
|
||||||
|
ID: membershipID,
|
||||||
|
UserID: userID,
|
||||||
|
OrganizationID: organizationID,
|
||||||
|
Role: role,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := membership.Create(ctx, tx, scope); err != nil {
|
||||||
|
return fmt.Errorf("failed to create membership: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Membership exists, update role if changed
|
||||||
|
if membership.Role != role {
|
||||||
|
membership.Role = role
|
||||||
|
membership.UpdatedAt = now
|
||||||
|
|
||||||
|
if err := membership.Update(ctx, tx, scope); err != nil {
|
||||||
|
return fmt.Errorf("failed to update membership role: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// This method is on Service (not TenantAuthzService) because the user viewing
|
// This method is on Service (not TenantAuthzService) because the user viewing
|
||||||
// their invitations doesn't have tenant access yet, and it operates across multiple tenants.
|
// their invitations doesn't have tenant access yet, and it operates across multiple tenants.
|
||||||
func (s *Service) GetUserInvitations(
|
func (s *Service) GetUserInvitations(
|
||||||
|
|||||||
@@ -63,4 +63,5 @@ const (
|
|||||||
MembershipEntityType
|
MembershipEntityType
|
||||||
SlackMessageEntityType
|
SlackMessageEntityType
|
||||||
TrustCenterFileEntityType
|
TrustCenterFileEntityType
|
||||||
|
SAMLConfigurationEntityType
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -132,22 +132,33 @@ func (m *Membership) LoadByID(
|
|||||||
membershipID gid.GID,
|
membershipID gid.GID,
|
||||||
) error {
|
) error {
|
||||||
query := `
|
query := `
|
||||||
|
WITH mbr AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
organization_id,
|
||||||
|
role,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
authz_memberships
|
||||||
|
WHERE
|
||||||
|
id = @membership_id
|
||||||
|
AND %s
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
m.id,
|
mbr.id,
|
||||||
m.user_id,
|
mbr.user_id,
|
||||||
m.organization_id,
|
mbr.organization_id,
|
||||||
m.role,
|
mbr.role,
|
||||||
u.fullname as full_name,
|
u.fullname as full_name,
|
||||||
u.email_address,
|
u.email_address,
|
||||||
m.created_at,
|
mbr.created_at,
|
||||||
m.updated_at
|
mbr.updated_at
|
||||||
FROM
|
FROM
|
||||||
authz_memberships m
|
mbr
|
||||||
JOIN
|
JOIN
|
||||||
users u ON m.user_id = u.id
|
users u ON mbr.user_id = u.id
|
||||||
WHERE
|
|
||||||
m.id = @membership_id
|
|
||||||
AND %s
|
|
||||||
`
|
`
|
||||||
|
|
||||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||||
@@ -182,23 +193,34 @@ func (m *Membership) LoadByUserAndOrg(
|
|||||||
orgID gid.GID,
|
orgID gid.GID,
|
||||||
) error {
|
) error {
|
||||||
query := `
|
query := `
|
||||||
|
WITH mbr AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
organization_id,
|
||||||
|
role,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
authz_memberships
|
||||||
|
WHERE
|
||||||
|
user_id = @user_id
|
||||||
|
AND organization_id = @organization_id
|
||||||
|
AND %s
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
m.id,
|
mbr.id,
|
||||||
m.user_id,
|
mbr.user_id,
|
||||||
m.organization_id,
|
mbr.organization_id,
|
||||||
m.role,
|
mbr.role,
|
||||||
u.fullname as full_name,
|
u.fullname as full_name,
|
||||||
u.email_address,
|
u.email_address,
|
||||||
m.created_at,
|
mbr.created_at,
|
||||||
m.updated_at
|
mbr.updated_at
|
||||||
FROM
|
FROM
|
||||||
authz_memberships m
|
mbr
|
||||||
JOIN
|
JOIN
|
||||||
users u ON m.user_id = u.id
|
users u ON mbr.user_id = u.id
|
||||||
WHERE
|
|
||||||
m.user_id = @user_id
|
|
||||||
AND m.organization_id = @organization_id
|
|
||||||
AND %s
|
|
||||||
`
|
`
|
||||||
|
|
||||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||||
@@ -294,24 +316,35 @@ func (m *Memberships) LoadByUserID(
|
|||||||
userID gid.GID,
|
userID gid.GID,
|
||||||
) error {
|
) error {
|
||||||
query := `
|
query := `
|
||||||
|
WITH mbr AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
organization_id,
|
||||||
|
role,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
authz_memberships
|
||||||
|
WHERE
|
||||||
|
user_id = @user_id
|
||||||
|
AND %s
|
||||||
|
ORDER BY
|
||||||
|
created_at DESC
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
m.id,
|
mbr.id,
|
||||||
m.user_id,
|
mbr.user_id,
|
||||||
m.organization_id,
|
mbr.organization_id,
|
||||||
m.role,
|
mbr.role,
|
||||||
u.fullname as full_name,
|
u.fullname as full_name,
|
||||||
u.email_address,
|
u.email_address,
|
||||||
m.created_at,
|
mbr.created_at,
|
||||||
m.updated_at
|
mbr.updated_at
|
||||||
FROM
|
FROM
|
||||||
authz_memberships m
|
mbr
|
||||||
JOIN
|
JOIN
|
||||||
users u ON m.user_id = u.id
|
users u ON mbr.user_id = u.id
|
||||||
WHERE
|
|
||||||
m.user_id = @user_id
|
|
||||||
AND %s
|
|
||||||
ORDER BY
|
|
||||||
m.created_at DESC
|
|
||||||
`
|
`
|
||||||
|
|
||||||
query = fmt.Sprintf(query, scope.SQLFragment())
|
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||||
@@ -343,23 +376,45 @@ func (m *Memberships) LoadByOrganizationID(
|
|||||||
cursor *page.Cursor[MembershipOrderField],
|
cursor *page.Cursor[MembershipOrderField],
|
||||||
) error {
|
) error {
|
||||||
query := `
|
query := `
|
||||||
|
WITH mbr AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
organization_id,
|
||||||
|
role,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
authz_memberships
|
||||||
|
WHERE
|
||||||
|
organization_id = @organization_id
|
||||||
|
AND %s
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
m.id,
|
id,
|
||||||
m.user_id,
|
user_id,
|
||||||
m.organization_id,
|
organization_id,
|
||||||
m.role,
|
role,
|
||||||
u.fullname as full_name,
|
full_name,
|
||||||
u.email_address,
|
email_address,
|
||||||
m.created_at,
|
created_at,
|
||||||
m.updated_at
|
updated_at
|
||||||
FROM
|
FROM (
|
||||||
authz_memberships m
|
SELECT
|
||||||
JOIN
|
mbr.id,
|
||||||
users u ON m.user_id = u.id
|
mbr.user_id,
|
||||||
WHERE
|
mbr.organization_id,
|
||||||
m.organization_id = @organization_id
|
mbr.role,
|
||||||
AND %s
|
u.fullname as full_name,
|
||||||
AND %s
|
u.email_address,
|
||||||
|
mbr.created_at,
|
||||||
|
mbr.updated_at
|
||||||
|
FROM
|
||||||
|
mbr
|
||||||
|
JOIN
|
||||||
|
users u ON mbr.user_id = u.id
|
||||||
|
) AS membership_with_user
|
||||||
|
WHERE %s
|
||||||
`
|
`
|
||||||
|
|
||||||
query = fmt.Sprintf(query, scope.SQLFragment(), cursor.SQLFragment())
|
query = fmt.Sprintf(query, scope.SQLFragment(), cursor.SQLFragment())
|
||||||
@@ -411,3 +466,67 @@ WHERE
|
|||||||
}
|
}
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LoadUserIDsByOrganizationID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
organizationID gid.GID,
|
||||||
|
) ([]gid.GID, error) {
|
||||||
|
query := `
|
||||||
|
SELECT user_id
|
||||||
|
FROM authz_memberships
|
||||||
|
WHERE organization_id = @organization_id AND %s
|
||||||
|
`
|
||||||
|
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||||
|
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, query, args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot query memberships: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var userIDs []gid.GID
|
||||||
|
for rows.Next() {
|
||||||
|
var userID gid.GID
|
||||||
|
if err := rows.Scan(&userID); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, fmt.Errorf("cannot scan user_id: %w", err)
|
||||||
|
}
|
||||||
|
userIDs = append(userIDs, userID)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
return userIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateMembershipUserID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
oldUserID gid.GID,
|
||||||
|
newUserID gid.GID,
|
||||||
|
organizationID gid.GID,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
UPDATE authz_memberships
|
||||||
|
SET user_id = @new_user_id, updated_at = @updated_at
|
||||||
|
WHERE user_id = @old_user_id AND organization_id = @organization_id AND %s
|
||||||
|
`
|
||||||
|
query = fmt.Sprintf(query, scope.SQLFragment())
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"new_user_id": newUserID,
|
||||||
|
"old_user_id": oldUserID,
|
||||||
|
"organization_id": organizationID,
|
||||||
|
"updated_at": time.Now(),
|
||||||
|
}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx, query, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot update membership: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ const (
|
|||||||
func (p MembershipOrderField) Column() string {
|
func (p MembershipOrderField) Column() string {
|
||||||
switch p {
|
switch p {
|
||||||
case MembershipOrderFieldFullName:
|
case MembershipOrderFieldFullName:
|
||||||
return "u.fullname"
|
return "full_name"
|
||||||
case MembershipOrderFieldEmailAddress:
|
case MembershipOrderFieldEmailAddress:
|
||||||
return "u.email_address"
|
return "email_address"
|
||||||
case MembershipOrderFieldRole:
|
case MembershipOrderFieldRole:
|
||||||
return "m.role"
|
return "role"
|
||||||
case MembershipOrderFieldCreatedAt:
|
case MembershipOrderFieldCreatedAt:
|
||||||
return "m.created_at"
|
return "created_at"
|
||||||
}
|
}
|
||||||
return string(p)
|
return string(p)
|
||||||
}
|
}
|
||||||
|
|||||||
155
pkg/coredata/migrations/20251018T194142Z.sql
Normal file
155
pkg/coredata/migrations/20251018T194142Z.sql
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
-- Add SAML authentication support
|
||||||
|
-- This migration adds SAML SSO functionality including:
|
||||||
|
-- - SAML configurations per organization
|
||||||
|
-- - SAML request/assertion tracking for security
|
||||||
|
-- - Domain verification
|
||||||
|
-- - User SAML subject tracking
|
||||||
|
|
||||||
|
-- Create ENUM for SAML enforcement policies
|
||||||
|
CREATE TYPE saml_enforcement_policy AS ENUM (
|
||||||
|
'OFF', -- SAML disabled, must use password
|
||||||
|
'OPTIONAL', -- SAML available but not required (default)
|
||||||
|
'REQUIRED' -- Everyone must use SAML
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create auth_saml_configurations table
|
||||||
|
CREATE TABLE auth_saml_configurations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
organization_id TEXT NOT NULL,
|
||||||
|
email_domain TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- SAML enabled flag
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- Enforcement policy for this SAML configuration
|
||||||
|
enforcement_policy saml_enforcement_policy NOT NULL,
|
||||||
|
|
||||||
|
-- Identity Provider (IdP) configuration
|
||||||
|
idp_entity_id TEXT NOT NULL,
|
||||||
|
idp_sso_url TEXT NOT NULL,
|
||||||
|
idp_certificate TEXT NOT NULL, -- X.509 certificate (PEM format)
|
||||||
|
idp_metadata_url TEXT, -- Optional: for auto-refresh
|
||||||
|
|
||||||
|
-- Attribute mapping configuration (using WS-Federation Claims)
|
||||||
|
attribute_email TEXT NOT NULL DEFAULT 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
|
||||||
|
attribute_firstname TEXT NOT NULL DEFAULT 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
|
||||||
|
attribute_lastname TEXT NOT NULL DEFAULT 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
|
||||||
|
attribute_role TEXT NOT NULL DEFAULT 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role',
|
||||||
|
|
||||||
|
-- Default role if mapping fails or attribute missing
|
||||||
|
default_role TEXT NOT NULL DEFAULT 'MEMBER',
|
||||||
|
|
||||||
|
-- Auto-signup settings
|
||||||
|
auto_signup_enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
-- Domain verification fields
|
||||||
|
domain_verified BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
domain_verification_token TEXT,
|
||||||
|
domain_verified_at TIMESTAMP,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
updated_at TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT fk_auth_saml_configurations_organization FOREIGN KEY (organization_id)
|
||||||
|
REFERENCES organizations(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast organization lookup
|
||||||
|
CREATE INDEX idx_auth_saml_configurations_organization_id
|
||||||
|
ON auth_saml_configurations(organization_id);
|
||||||
|
|
||||||
|
-- Index for tenant scoping
|
||||||
|
CREATE INDEX idx_auth_saml_configurations_tenant_id
|
||||||
|
ON auth_saml_configurations(tenant_id);
|
||||||
|
|
||||||
|
-- Unique constraint scoped to organization
|
||||||
|
-- This allows the same domain in different organizations
|
||||||
|
-- while preventing duplicates within the same organization
|
||||||
|
CREATE UNIQUE INDEX idx_saml_config_domain_org_unique
|
||||||
|
ON auth_saml_configurations(organization_id, email_domain)
|
||||||
|
WHERE enabled = true AND domain_verified = true;
|
||||||
|
|
||||||
|
-- Index for fast domain lookup (for email-based SAML discovery)
|
||||||
|
CREATE INDEX idx_saml_config_email_domain
|
||||||
|
ON auth_saml_configurations(email_domain)
|
||||||
|
WHERE enabled = true AND domain_verified = true;
|
||||||
|
|
||||||
|
-- Add SAML subject to users table for tracking SAML NameID
|
||||||
|
ALTER TABLE users ADD COLUMN saml_subject TEXT;
|
||||||
|
|
||||||
|
-- Unique constraint: one SAML subject globally
|
||||||
|
CREATE UNIQUE INDEX idx_users_saml_subject
|
||||||
|
ON users(saml_subject)
|
||||||
|
WHERE saml_subject IS NOT NULL;
|
||||||
|
|
||||||
|
-- Make hashed_password nullable for SAML users
|
||||||
|
-- SAML users authenticate via IdP and don't have passwords
|
||||||
|
ALTER TABLE users ALTER COLUMN hashed_password DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Create auth_saml_assertions table for replay attack prevention
|
||||||
|
CREATE TABLE auth_saml_assertions (
|
||||||
|
id TEXT PRIMARY KEY, -- SAML Assertion ID
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
organization_id TEXT NOT NULL,
|
||||||
|
used_at TIMESTAMP NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for cleanup of expired assertions
|
||||||
|
CREATE INDEX idx_auth_saml_assertions_expires_at
|
||||||
|
ON auth_saml_assertions(expires_at);
|
||||||
|
|
||||||
|
-- Index for organization lookup
|
||||||
|
CREATE INDEX idx_auth_saml_assertions_organization_id
|
||||||
|
ON auth_saml_assertions(organization_id);
|
||||||
|
|
||||||
|
-- Index for tenant scoping
|
||||||
|
CREATE INDEX idx_auth_saml_assertions_tenant_id
|
||||||
|
ON auth_saml_assertions(tenant_id);
|
||||||
|
|
||||||
|
-- Create auth_saml_requests table for proper InResponseTo validation
|
||||||
|
-- This prevents replay attacks and validates the SAML authentication flow
|
||||||
|
CREATE TABLE auth_saml_requests (
|
||||||
|
id TEXT PRIMARY KEY, -- SAML Request ID generated by SP
|
||||||
|
organization_id TEXT NOT NULL,
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT fk_auth_saml_requests_organization FOREIGN KEY (organization_id)
|
||||||
|
REFERENCES organizations(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast request ID lookup during SAML callback
|
||||||
|
CREATE INDEX idx_auth_saml_requests_id_org ON auth_saml_requests(id, organization_id);
|
||||||
|
|
||||||
|
-- Index for cleanup of expired requests
|
||||||
|
CREATE INDEX idx_auth_saml_requests_expires_at ON auth_saml_requests(expires_at);
|
||||||
|
|
||||||
|
-- Create auth_saml_relay_states table for secure RelayState management
|
||||||
|
-- This prevents organization hijacking attacks
|
||||||
|
CREATE TABLE auth_saml_relay_states (
|
||||||
|
token TEXT PRIMARY KEY, -- Cryptographically secure random token
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
organization_id TEXT NOT NULL,
|
||||||
|
request_id TEXT NOT NULL, -- Links to auth_saml_requests.id
|
||||||
|
saml_config_id TEXT NOT NULL, -- Links to auth_saml_configurations.id
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT fk_auth_saml_relay_states_organization FOREIGN KEY (organization_id)
|
||||||
|
REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_auth_saml_relay_states_saml_config FOREIGN KEY (saml_config_id)
|
||||||
|
REFERENCES auth_saml_configurations(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for fast token lookup during callback
|
||||||
|
CREATE INDEX idx_auth_saml_relay_states_token ON auth_saml_relay_states(token);
|
||||||
|
|
||||||
|
-- Index for cleanup of expired relay states
|
||||||
|
CREATE INDEX idx_auth_saml_relay_states_expires_at ON auth_saml_relay_states(expires_at);
|
||||||
|
|
||||||
|
-- Index for tenant scoping
|
||||||
|
CREATE INDEX idx_auth_saml_relay_states_tenant_id ON auth_saml_relay_states(tenant_id);
|
||||||
108
pkg/coredata/saml_assertion.go
Normal file
108
pkg/coredata/saml_assertion.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package coredata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SAMLAssertion struct {
|
||||||
|
ID string `db:"id"`
|
||||||
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
|
UsedAt time.Time `db:"used_at"`
|
||||||
|
ExpiresAt time.Time `db:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrAssertionAlreadyUsed struct {
|
||||||
|
AssertionID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrAssertionAlreadyUsed) Error() string {
|
||||||
|
return fmt.Sprintf("assertion ID %q has already been used (replay attack)", e.AssertionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLAssertion) CheckExists(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
assertionID string,
|
||||||
|
) (bool, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id
|
||||||
|
FROM auth_saml_assertions
|
||||||
|
WHERE id = @id
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, query, pgx.NamedArgs{"id": assertionID})
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("cannot query saml_assertions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = pgx.CollectOneRow(rows, pgx.RowTo[string])
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, fmt.Errorf("cannot collect saml_assertion: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLAssertion) Insert(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO auth_saml_assertions (id, tenant_id, organization_id, used_at, expires_at)
|
||||||
|
VALUES (@id, @tenant_id, @organization_id, @used_at, @expires_at)
|
||||||
|
`
|
||||||
|
|
||||||
|
args := pgx.NamedArgs{
|
||||||
|
"id": s.ID,
|
||||||
|
"tenant_id": scope.GetTenantID(),
|
||||||
|
"organization_id": s.OrganizationID,
|
||||||
|
"used_at": s.UsedAt,
|
||||||
|
"expires_at": s.ExpiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx, query, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot insert saml_assertion: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteExpiredSAMLAssertions(ctx context.Context, conn pg.Conn, now time.Time) (int64, error) {
|
||||||
|
query := `
|
||||||
|
DELETE FROM auth_saml_assertions
|
||||||
|
WHERE expires_at < @now
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := conn.Exec(ctx, query, pgx.NamedArgs{"now": now})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("cannot delete expired saml_assertions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
453
pkg/coredata/saml_configuration.go
Normal file
453
pkg/coredata/saml_configuration.go
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package coredata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SAMLConfiguration struct {
|
||||||
|
ID gid.GID `db:"id"`
|
||||||
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
|
EmailDomain string `db:"email_domain"`
|
||||||
|
Enabled bool `db:"enabled"`
|
||||||
|
EnforcementPolicy SAMLEnforcementPolicy `db:"enforcement_policy"`
|
||||||
|
IdPEntityID string `db:"idp_entity_id"`
|
||||||
|
IdPSsoURL string `db:"idp_sso_url"`
|
||||||
|
IdPCertificate string `db:"idp_certificate"`
|
||||||
|
IdPMetadataURL *string `db:"idp_metadata_url"`
|
||||||
|
AttributeEmail string `db:"attribute_email"`
|
||||||
|
AttributeFirstname string `db:"attribute_firstname"`
|
||||||
|
AttributeLastname string `db:"attribute_lastname"`
|
||||||
|
AttributeRole string `db:"attribute_role"`
|
||||||
|
DefaultRole string `db:"default_role"`
|
||||||
|
AutoSignupEnabled bool `db:"auto_signup_enabled"`
|
||||||
|
DomainVerified bool `db:"domain_verified"`
|
||||||
|
DomainVerificationToken *string `db:"domain_verification_token"`
|
||||||
|
DomainVerifiedAt *time.Time `db:"domain_verified_at"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLConfiguration) LoadByOrganizationIDAndEmailDomain(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
organizationID gid.GID,
|
||||||
|
emailDomain string,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
email_domain,
|
||||||
|
enabled,
|
||||||
|
enforcement_policy,
|
||||||
|
idp_entity_id,
|
||||||
|
idp_sso_url,
|
||||||
|
idp_certificate,
|
||||||
|
idp_metadata_url,
|
||||||
|
attribute_email,
|
||||||
|
attribute_firstname,
|
||||||
|
attribute_lastname,
|
||||||
|
attribute_role,
|
||||||
|
default_role,
|
||||||
|
auto_signup_enabled,
|
||||||
|
domain_verified,
|
||||||
|
domain_verification_token,
|
||||||
|
domain_verified_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
auth_saml_configurations
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND organization_id = @organization_id
|
||||||
|
AND email_domain = @email_domain
|
||||||
|
LIMIT 1;
|
||||||
|
`
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"organization_id": organizationID,
|
||||||
|
"email_domain": emailDomain,
|
||||||
|
}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot query auth_saml_configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SAMLConfiguration])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot collect saml_configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*s = config
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLConfiguration) LoadByID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
configID gid.GID,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
email_domain,
|
||||||
|
enabled,
|
||||||
|
enforcement_policy,
|
||||||
|
idp_entity_id,
|
||||||
|
idp_sso_url,
|
||||||
|
idp_certificate,
|
||||||
|
idp_metadata_url,
|
||||||
|
attribute_email,
|
||||||
|
attribute_firstname,
|
||||||
|
attribute_lastname,
|
||||||
|
attribute_role,
|
||||||
|
default_role,
|
||||||
|
auto_signup_enabled,
|
||||||
|
domain_verified,
|
||||||
|
domain_verification_token,
|
||||||
|
domain_verified_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
auth_saml_configurations
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND id = @id
|
||||||
|
LIMIT 1;
|
||||||
|
`
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{"id": configID}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot query auth_saml_configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SAMLConfiguration])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot collect saml_configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*s = config
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLConfiguration) Insert(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
INSERT INTO auth_saml_configurations (
|
||||||
|
id,
|
||||||
|
tenant_id,
|
||||||
|
organization_id,
|
||||||
|
email_domain,
|
||||||
|
enabled,
|
||||||
|
enforcement_policy,
|
||||||
|
idp_entity_id,
|
||||||
|
idp_sso_url,
|
||||||
|
idp_certificate,
|
||||||
|
idp_metadata_url,
|
||||||
|
attribute_email,
|
||||||
|
attribute_firstname,
|
||||||
|
attribute_lastname,
|
||||||
|
attribute_role,
|
||||||
|
default_role,
|
||||||
|
auto_signup_enabled,
|
||||||
|
domain_verified,
|
||||||
|
domain_verification_token,
|
||||||
|
domain_verified_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (
|
||||||
|
@id,
|
||||||
|
@tenant_id,
|
||||||
|
@organization_id,
|
||||||
|
@email_domain,
|
||||||
|
@enabled,
|
||||||
|
@enforcement_policy,
|
||||||
|
@idp_entity_id,
|
||||||
|
@idp_sso_url,
|
||||||
|
@idp_certificate,
|
||||||
|
@idp_metadata_url,
|
||||||
|
@attribute_email,
|
||||||
|
@attribute_firstname,
|
||||||
|
@attribute_lastname,
|
||||||
|
@attribute_role,
|
||||||
|
@default_role,
|
||||||
|
@auto_signup_enabled,
|
||||||
|
@domain_verified,
|
||||||
|
@domain_verification_token,
|
||||||
|
@domain_verified_at,
|
||||||
|
@created_at,
|
||||||
|
@updated_at
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"id": s.ID,
|
||||||
|
"tenant_id": scope.GetTenantID(),
|
||||||
|
"organization_id": s.OrganizationID,
|
||||||
|
"email_domain": s.EmailDomain,
|
||||||
|
"enabled": s.Enabled,
|
||||||
|
"enforcement_policy": s.EnforcementPolicy,
|
||||||
|
"idp_entity_id": s.IdPEntityID,
|
||||||
|
"idp_sso_url": s.IdPSsoURL,
|
||||||
|
"idp_certificate": s.IdPCertificate,
|
||||||
|
"idp_metadata_url": s.IdPMetadataURL,
|
||||||
|
"attribute_email": s.AttributeEmail,
|
||||||
|
"attribute_firstname": s.AttributeFirstname,
|
||||||
|
"attribute_lastname": s.AttributeLastname,
|
||||||
|
"attribute_role": s.AttributeRole,
|
||||||
|
"default_role": s.DefaultRole,
|
||||||
|
"auto_signup_enabled": s.AutoSignupEnabled,
|
||||||
|
"domain_verified": s.DomainVerified,
|
||||||
|
"domain_verification_token": s.DomainVerificationToken,
|
||||||
|
"domain_verified_at": s.DomainVerifiedAt,
|
||||||
|
"created_at": s.CreatedAt,
|
||||||
|
"updated_at": s.UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot insert saml_configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLConfiguration) Update(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
UPDATE auth_saml_configurations
|
||||||
|
SET
|
||||||
|
enabled = @enabled,
|
||||||
|
enforcement_policy = @enforcement_policy,
|
||||||
|
idp_entity_id = @idp_entity_id,
|
||||||
|
idp_sso_url = @idp_sso_url,
|
||||||
|
idp_certificate = @idp_certificate,
|
||||||
|
idp_metadata_url = @idp_metadata_url,
|
||||||
|
attribute_email = @attribute_email,
|
||||||
|
attribute_firstname = @attribute_firstname,
|
||||||
|
attribute_lastname = @attribute_lastname,
|
||||||
|
attribute_role = @attribute_role,
|
||||||
|
default_role = @default_role,
|
||||||
|
auto_signup_enabled = @auto_signup_enabled,
|
||||||
|
domain_verified = @domain_verified,
|
||||||
|
domain_verification_token = @domain_verification_token,
|
||||||
|
domain_verified_at = @domain_verified_at,
|
||||||
|
updated_at = @updated_at
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND id = @id
|
||||||
|
`
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"id": s.ID,
|
||||||
|
"enabled": s.Enabled,
|
||||||
|
"enforcement_policy": s.EnforcementPolicy,
|
||||||
|
"idp_entity_id": s.IdPEntityID,
|
||||||
|
"idp_sso_url": s.IdPSsoURL,
|
||||||
|
"idp_certificate": s.IdPCertificate,
|
||||||
|
"idp_metadata_url": s.IdPMetadataURL,
|
||||||
|
"attribute_email": s.AttributeEmail,
|
||||||
|
"attribute_firstname": s.AttributeFirstname,
|
||||||
|
"attribute_lastname": s.AttributeLastname,
|
||||||
|
"attribute_role": s.AttributeRole,
|
||||||
|
"default_role": s.DefaultRole,
|
||||||
|
"auto_signup_enabled": s.AutoSignupEnabled,
|
||||||
|
"domain_verified": s.DomainVerified,
|
||||||
|
"domain_verification_token": s.DomainVerificationToken,
|
||||||
|
"domain_verified_at": s.DomainVerifiedAt,
|
||||||
|
"updated_at": s.UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot update saml_configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLConfiguration) Delete(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
DELETE FROM auth_saml_configurations
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND id = @id
|
||||||
|
`
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{"id": s.ID}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot delete saml_configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadSAMLConfigurationsByOrganizationID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
organizationID gid.GID,
|
||||||
|
) ([]*SAMLConfiguration, error) {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
email_domain,
|
||||||
|
enabled,
|
||||||
|
enforcement_policy,
|
||||||
|
idp_entity_id,
|
||||||
|
idp_sso_url,
|
||||||
|
idp_certificate,
|
||||||
|
idp_metadata_url,
|
||||||
|
attribute_email,
|
||||||
|
attribute_firstname,
|
||||||
|
attribute_lastname,
|
||||||
|
attribute_role,
|
||||||
|
default_role,
|
||||||
|
auto_signup_enabled,
|
||||||
|
domain_verified,
|
||||||
|
domain_verification_token,
|
||||||
|
domain_verified_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
auth_saml_configurations
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND organization_id = @organization_id
|
||||||
|
ORDER BY email_domain ASC;
|
||||||
|
`
|
||||||
|
|
||||||
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||||
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot query auth_saml_configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, err := pgx.CollectRows(rows, pgx.RowToStructByName[SAMLConfiguration])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot collect saml_configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*SAMLConfiguration, len(configs))
|
||||||
|
for i := range configs {
|
||||||
|
result[i] = &configs[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAllEnabledSAMLConfigurationsByEmailDomain loads all enabled SAML configurations for a given email domain
|
||||||
|
// This is used for SSO login detection when multiple organizations may have SAML configured for the same domain
|
||||||
|
func LoadAllEnabledSAMLConfigurationsByEmailDomain(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
emailDomain string,
|
||||||
|
) ([]*SAMLConfiguration, error) {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
organization_id,
|
||||||
|
email_domain,
|
||||||
|
enabled,
|
||||||
|
enforcement_policy,
|
||||||
|
idp_entity_id,
|
||||||
|
idp_sso_url,
|
||||||
|
idp_certificate,
|
||||||
|
idp_metadata_url,
|
||||||
|
attribute_email,
|
||||||
|
attribute_firstname,
|
||||||
|
attribute_lastname,
|
||||||
|
attribute_role,
|
||||||
|
default_role,
|
||||||
|
auto_signup_enabled,
|
||||||
|
domain_verified,
|
||||||
|
domain_verification_token,
|
||||||
|
domain_verified_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
auth_saml_configurations
|
||||||
|
WHERE
|
||||||
|
email_domain = $1
|
||||||
|
AND enabled = true
|
||||||
|
AND domain_verified = true
|
||||||
|
ORDER BY created_at ASC;
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, q, emailDomain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot query auth_saml_configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, err := pgx.CollectRows(rows, pgx.RowToStructByName[SAMLConfiguration])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot collect saml_configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*SAMLConfiguration, len(configs))
|
||||||
|
for i := range configs {
|
||||||
|
result[i] = &configs[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
60
pkg/coredata/saml_enforcement_policy.go
Normal file
60
pkg/coredata/saml_enforcement_policy.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package coredata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SAMLEnforcementPolicy string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SAMLEnforcementPolicyOff SAMLEnforcementPolicy = "OFF"
|
||||||
|
SAMLEnforcementPolicyOptional SAMLEnforcementPolicy = "OPTIONAL"
|
||||||
|
SAMLEnforcementPolicyRequired SAMLEnforcementPolicy = "REQUIRED"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (sep SAMLEnforcementPolicy) String() string {
|
||||||
|
return string(sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sep *SAMLEnforcementPolicy) Scan(value any) error {
|
||||||
|
var s string
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
s = v
|
||||||
|
case []byte:
|
||||||
|
s = string(v)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported type for SAMLEnforcementPolicy: %T", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch s {
|
||||||
|
case "OFF":
|
||||||
|
*sep = SAMLEnforcementPolicyOff
|
||||||
|
case "OPTIONAL":
|
||||||
|
*sep = SAMLEnforcementPolicyOptional
|
||||||
|
case "REQUIRED":
|
||||||
|
*sep = SAMLEnforcementPolicyRequired
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid SAMLEnforcementPolicy value: %q", s)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sep SAMLEnforcementPolicy) Value() (driver.Value, error) {
|
||||||
|
return sep.String(), nil
|
||||||
|
}
|
||||||
156
pkg/coredata/saml_relay_state.go
Normal file
156
pkg/coredata/saml_relay_state.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package coredata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SAMLRelayState struct {
|
||||||
|
Token string `db:"token"`
|
||||||
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
|
SAMLConfigID gid.GID `db:"saml_config_id"`
|
||||||
|
RequestID string `db:"request_id"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
ExpiresAt time.Time `db:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrRelayStateNotFound struct {
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrRelayStateNotFound) Error() string {
|
||||||
|
return "relay state token not found or invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrRelayStateExpired struct {
|
||||||
|
Token string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrRelayStateExpired) Error() string {
|
||||||
|
return fmt.Sprintf("relay state token expired at %v", e.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateSecureToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot generate random token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := base64.URLEncoding.EncodeToString(b)
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLRelayState) Insert(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO auth_saml_relay_states (token, tenant_id, organization_id, saml_config_id, request_id, created_at, expires_at)
|
||||||
|
VALUES (@token, @tenant_id, @organization_id, @saml_config_id, @request_id, @created_at, @expires_at)
|
||||||
|
`
|
||||||
|
|
||||||
|
args := pgx.NamedArgs{
|
||||||
|
"token": s.Token,
|
||||||
|
"tenant_id": scope.GetTenantID(),
|
||||||
|
"organization_id": s.OrganizationID,
|
||||||
|
"saml_config_id": s.SAMLConfigID,
|
||||||
|
"request_id": s.RequestID,
|
||||||
|
"created_at": s.CreatedAt,
|
||||||
|
"expires_at": s.ExpiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx, query, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot insert saml_relay_state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLRelayState) Load(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
token string,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
SELECT token, organization_id, saml_config_id, request_id, created_at, expires_at
|
||||||
|
FROM auth_saml_relay_states
|
||||||
|
WHERE token = @token
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, query, pgx.NamedArgs{"token": token})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot query saml_relay_states: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[SAMLRelayState])
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return ErrRelayStateNotFound{Token: token}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot collect saml_relay_state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*s = state
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLRelayState) IsExpired(now time.Time) bool {
|
||||||
|
return now.After(s.ExpiresAt) || now.Equal(s.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLRelayState) Delete(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM auth_saml_relay_states
|
||||||
|
WHERE token = @token
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx, query, pgx.NamedArgs{"token": s.Token})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot delete saml_relay_state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteExpiredSAMLRelayStates(ctx context.Context, conn pg.Conn, now time.Time) (int64, error) {
|
||||||
|
query := `
|
||||||
|
DELETE FROM auth_saml_relay_states
|
||||||
|
WHERE expires_at < @now
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := conn.Exec(ctx, query, pgx.NamedArgs{"now": now})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("cannot delete expired saml_relay_states: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
145
pkg/coredata/saml_request.go
Normal file
145
pkg/coredata/saml_request.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package coredata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SAMLRequest struct {
|
||||||
|
ID string `db:"id"`
|
||||||
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
ExpiresAt time.Time `db:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrSAMLRequestNotFound struct {
|
||||||
|
RequestID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrSAMLRequestNotFound) Error() string {
|
||||||
|
return fmt.Sprintf("SAML request ID %q not found", e.RequestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrSAMLRequestExpired struct {
|
||||||
|
RequestID string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ErrSAMLRequestExpired) Error() string {
|
||||||
|
return fmt.Sprintf("SAML request ID %q expired at %v", e.RequestID, e.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLRequest) Insert(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO auth_saml_requests (id, organization_id, tenant_id, created_at, expires_at)
|
||||||
|
VALUES (@id, @organization_id, @tenant_id, @created_at, @expires_at)
|
||||||
|
`
|
||||||
|
|
||||||
|
args := pgx.NamedArgs{
|
||||||
|
"id": s.ID,
|
||||||
|
"organization_id": s.OrganizationID,
|
||||||
|
"tenant_id": scope.GetTenantID(),
|
||||||
|
"created_at": s.CreatedAt,
|
||||||
|
"expires_at": s.ExpiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx, query, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot insert saml_request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLRequest) Load(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
requestID string,
|
||||||
|
organizationID gid.GID,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
SELECT id, organization_id, created_at, expires_at
|
||||||
|
FROM auth_saml_requests
|
||||||
|
WHERE id = @id AND organization_id = @organization_id
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
args := pgx.NamedArgs{
|
||||||
|
"id": requestID,
|
||||||
|
"organization_id": organizationID,
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, query, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot query saml_requests: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[SAMLRequest])
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
return ErrSAMLRequestNotFound{RequestID: requestID}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot collect saml_request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*s = req
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLRequest) IsExpired(now time.Time) bool {
|
||||||
|
return now.After(s.ExpiresAt) || now.Equal(s.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SAMLRequest) Delete(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
) error {
|
||||||
|
query := `
|
||||||
|
DELETE FROM auth_saml_requests
|
||||||
|
WHERE id = @id
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx, query, pgx.NamedArgs{"id": s.ID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot delete saml_request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeleteExpiredSAMLRequests(ctx context.Context, conn pg.Conn, now time.Time) (int64, error) {
|
||||||
|
query := `
|
||||||
|
DELETE FROM auth_saml_requests
|
||||||
|
WHERE expires_at < @now
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := conn.Exec(ctx, query, pgx.NamedArgs{"now": now})
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("cannot delete expired saml_requests: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
@@ -35,7 +35,30 @@ type (
|
|||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
SessionData struct{}
|
// SessionData stores authentication context for a user session
|
||||||
|
// Stored as JSONB in database
|
||||||
|
SessionData struct {
|
||||||
|
// PasswordAuthenticated indicates if user authenticated with email/password
|
||||||
|
// Required for accessing organizations without SAML
|
||||||
|
PasswordAuthenticated bool `json:"password_authenticated"`
|
||||||
|
|
||||||
|
// SAMLAuthenticatedOrgs tracks which organizations user has SAML-authenticated for
|
||||||
|
// Key: organization ID as string, Value: SAML authentication info
|
||||||
|
// Required for accessing organizations with SAML enforcement
|
||||||
|
SAMLAuthenticatedOrgs map[string]SAMLAuthInfo `json:"saml_authenticated_orgs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAMLAuthInfo stores SAML authentication details for an organization
|
||||||
|
SAMLAuthInfo struct {
|
||||||
|
// AuthenticatedAt is when the user SAML-
|
||||||
|
AuthenticatedAt time.Time `json:"authenticated_at"`
|
||||||
|
|
||||||
|
// SAMLConfigID is the SAML configuration used for authentication
|
||||||
|
SAMLConfigID gid.GID `json:"saml_config_id"`
|
||||||
|
|
||||||
|
// SAMLSubject is the NameID from the SAML assertion (email address)
|
||||||
|
SAMLSubject string `json:"saml_subject"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
|
func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
|
||||||
@@ -47,7 +70,6 @@ func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
|
|||||||
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tenant id scope is not applied because we want to access sessions across all tenants for authentication purposes.
|
|
||||||
func (s *Session) LoadByID(
|
func (s *Session) LoadByID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Conn,
|
conn pg.Conn,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type (
|
|||||||
HashedPassword []byte `db:"hashed_password"`
|
HashedPassword []byte `db:"hashed_password"`
|
||||||
FullName string `db:"fullname"`
|
FullName string `db:"fullname"`
|
||||||
EmailAddressVerified bool `db:"email_address_verified"`
|
EmailAddressVerified bool `db:"email_address_verified"`
|
||||||
|
SAMLSubject *string `db:"saml_subject"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -158,6 +159,7 @@ SELECT
|
|||||||
hashed_password,
|
hashed_password,
|
||||||
email_address_verified,
|
email_address_verified,
|
||||||
fullname,
|
fullname,
|
||||||
|
saml_subject,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -201,6 +203,7 @@ SELECT
|
|||||||
hashed_password,
|
hashed_password,
|
||||||
email_address_verified,
|
email_address_verified,
|
||||||
fullname,
|
fullname,
|
||||||
|
saml_subject,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
FROM
|
FROM
|
||||||
@@ -234,16 +237,18 @@ LIMIT 1;
|
|||||||
func (u *User) Insert(
|
func (u *User) Insert(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Conn,
|
conn pg.Conn,
|
||||||
|
scope Scoper,
|
||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
INSERT INTO
|
INSERT INTO
|
||||||
users (id, email_address, hashed_password, email_address_verified, fullname, created_at, updated_at)
|
users (id, email_address, hashed_password, email_address_verified, fullname, saml_subject, created_at, updated_at)
|
||||||
VALUES (
|
VALUES (
|
||||||
@user_id,
|
@user_id,
|
||||||
@email_address,
|
@email_address,
|
||||||
@hashed_password,
|
@hashed_password,
|
||||||
@email_address_verified,
|
@email_address_verified,
|
||||||
@fullname,
|
@fullname,
|
||||||
|
@saml_subject,
|
||||||
@created_at,
|
@created_at,
|
||||||
@updated_at
|
@updated_at
|
||||||
)
|
)
|
||||||
@@ -254,6 +259,7 @@ VALUES (
|
|||||||
"email_address": u.EmailAddress,
|
"email_address": u.EmailAddress,
|
||||||
"hashed_password": u.HashedPassword,
|
"hashed_password": u.HashedPassword,
|
||||||
"fullname": u.FullName,
|
"fullname": u.FullName,
|
||||||
|
"saml_subject": u.SAMLSubject,
|
||||||
"created_at": u.CreatedAt,
|
"created_at": u.CreatedAt,
|
||||||
"updated_at": u.UpdatedAt,
|
"updated_at": u.UpdatedAt,
|
||||||
"email_address_verified": u.EmailAddressVerified,
|
"email_address_verified": u.EmailAddressVerified,
|
||||||
@@ -341,3 +347,107 @@ WHERE
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *User) Update(ctx context.Context, conn pg.Conn) error {
|
||||||
|
q := `
|
||||||
|
UPDATE
|
||||||
|
users
|
||||||
|
SET
|
||||||
|
email_address = @email_address,
|
||||||
|
email_address_verified = @email_address_verified,
|
||||||
|
saml_subject = @saml_subject,
|
||||||
|
updated_at = @updated_at
|
||||||
|
WHERE
|
||||||
|
id = @user_id
|
||||||
|
`
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{
|
||||||
|
"user_id": u.ID,
|
||||||
|
"email_address": u.EmailAddress,
|
||||||
|
"email_address_verified": u.EmailAddressVerified,
|
||||||
|
"saml_subject": u.SAMLSubject,
|
||||||
|
"updated_at": u.UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot update user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadBySAMLSubject loads a user by their SAML subject (NameID)
|
||||||
|
func (u *User) LoadBySAMLSubject(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
samlSubject string,
|
||||||
|
) error {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
email_address,
|
||||||
|
hashed_password,
|
||||||
|
email_address_verified,
|
||||||
|
fullname,
|
||||||
|
saml_subject,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
users
|
||||||
|
WHERE
|
||||||
|
saml_subject = @saml_subject
|
||||||
|
LIMIT 1;
|
||||||
|
`
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{"saml_subject": samlSubject}
|
||||||
|
|
||||||
|
rows, err := conn.Query(ctx, q, args)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot query user by SAML subject: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return &ErrUserNotFound{Identifier: samlSubject}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("cannot collect user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
*u = user
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadByEmailAndTenant, LoadByEmailGlobal, and IsTenantUser methods removed
|
||||||
|
// All users are now global (no tenant_id distinction)
|
||||||
|
// Use LoadByEmail() for all email-based lookups
|
||||||
|
|
||||||
|
func (u *User) CountMemberships(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
) (int, error) {
|
||||||
|
q := `
|
||||||
|
SELECT
|
||||||
|
COUNT(*)
|
||||||
|
FROM
|
||||||
|
authz_memberships
|
||||||
|
WHERE
|
||||||
|
user_id = @user_id
|
||||||
|
`
|
||||||
|
|
||||||
|
args := pgx.StrictNamedArgs{"user_id": u.ID}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := conn.QueryRow(ctx, q, args).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("cannot count user memberships: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertToTenantUser method removed
|
||||||
|
// All users are now global (no tenant conversion needed)
|
||||||
|
|||||||
22
pkg/coredata/user_auth_method.go
Normal file
22
pkg/coredata/user_auth_method.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package coredata
|
||||||
|
|
||||||
|
type UserAuthMethod string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserAuthMethodPassword UserAuthMethod = "PASSWORD"
|
||||||
|
UserAuthMethodSAML UserAuthMethod = "SAML"
|
||||||
|
)
|
||||||
@@ -25,6 +25,7 @@ type (
|
|||||||
Password passwordConfig `json:"password"`
|
Password passwordConfig `json:"password"`
|
||||||
DisableSignup bool `json:"disable-signup"`
|
DisableSignup bool `json:"disable-signup"`
|
||||||
InvitationConfirmationTokenValidity int `json:"invitation-confirmation-token-validity"`
|
InvitationConfirmationTokenValidity int `json:"invitation-confirmation-token-validity"`
|
||||||
|
SAML samlConfig `json:"saml"`
|
||||||
}
|
}
|
||||||
|
|
||||||
trustAuthConfig struct {
|
trustAuthConfig struct {
|
||||||
|
|||||||
@@ -118,6 +118,10 @@ func New() *Implm {
|
|||||||
},
|
},
|
||||||
DisableSignup: false,
|
DisableSignup: false,
|
||||||
InvitationConfirmationTokenValidity: 3600,
|
InvitationConfirmationTokenValidity: 3600,
|
||||||
|
SAML: samlConfig{
|
||||||
|
SessionDuration: 604800,
|
||||||
|
CleanupIntervalSeconds: 86400,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
TrustAuth: trustAuthConfig{
|
TrustAuth: trustAuthConfig{
|
||||||
CookieName: "TCT",
|
CookieName: "TCT",
|
||||||
@@ -268,9 +272,11 @@ func (impl *Implm) Run(
|
|||||||
authService, err := auth.NewService(
|
authService, err := auth.NewService(
|
||||||
ctx,
|
ctx,
|
||||||
pgClient,
|
pgClient,
|
||||||
|
impl.cfg.EncryptionKey,
|
||||||
hp,
|
hp,
|
||||||
impl.cfg.Auth.Cookie.Secret,
|
impl.cfg.Auth.Cookie.Secret,
|
||||||
impl.cfg.Hostname,
|
impl.cfg.Hostname,
|
||||||
|
fmt.Sprintf("https://%s", impl.cfg.Hostname),
|
||||||
impl.cfg.Auth.DisableSignup,
|
impl.cfg.Auth.DisableSignup,
|
||||||
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
|
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
|
||||||
)
|
)
|
||||||
@@ -291,6 +297,21 @@ func (impl *Implm) Run(
|
|||||||
|
|
||||||
fileManagerService := filemanager.NewService(s3Client)
|
fileManagerService := filemanager.NewService(s3Client)
|
||||||
|
|
||||||
|
samlService, err := auth.NewSAMLService(
|
||||||
|
pgClient,
|
||||||
|
impl.cfg.EncryptionKey,
|
||||||
|
fmt.Sprintf("https://%s", impl.cfg.Hostname),
|
||||||
|
impl.cfg.Auth.SAML.SessionDurationTime(),
|
||||||
|
impl.cfg.Auth.Cookie.Name,
|
||||||
|
impl.cfg.Auth.Cookie.Secret,
|
||||||
|
impl.cfg.Auth.SAML.Certificate,
|
||||||
|
impl.cfg.Auth.SAML.PrivateKey,
|
||||||
|
l.Named("saml"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot create SAML service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var accountKey crypto.Signer
|
var accountKey crypto.Signer
|
||||||
if impl.cfg.CustomDomains.ACME.AccountKey != "" {
|
if impl.cfg.CustomDomains.ACME.AccountKey != "" {
|
||||||
accountKey, err = pem.DecodePrivateKey([]byte(impl.cfg.CustomDomains.ACME.AccountKey))
|
accountKey, err = pem.DecodePrivateKey([]byte(impl.cfg.CustomDomains.ACME.AccountKey))
|
||||||
@@ -368,10 +389,13 @@ func (impl *Implm) Run(
|
|||||||
Auth: authService,
|
Auth: authService,
|
||||||
Authz: authzService,
|
Authz: authzService,
|
||||||
Trust: trustService,
|
Trust: trustService,
|
||||||
|
SAML: samlService,
|
||||||
ConnectorRegistry: defaultConnectorRegistry,
|
ConnectorRegistry: defaultConnectorRegistry,
|
||||||
Agent: agent,
|
Agent: agent,
|
||||||
SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.Hostname},
|
SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.Hostname},
|
||||||
CustomDomainCname: impl.cfg.CustomDomains.CnameTarget,
|
CustomDomainCname: impl.cfg.CustomDomains.CnameTarget,
|
||||||
|
FileManager: fileManagerService,
|
||||||
|
PGClient: pgClient,
|
||||||
Logger: l.Named("http.server"),
|
Logger: l.Named("http.server"),
|
||||||
ConsoleAuth: api.ConsoleAuthConfig{
|
ConsoleAuth: api.ConsoleAuthConfig{
|
||||||
CookieName: impl.cfg.Auth.Cookie.Name,
|
CookieName: impl.cfg.Auth.Cookie.Name,
|
||||||
@@ -445,6 +469,20 @@ func (impl *Implm) Run(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
samlCleanerCtx, stopSAMLCleaner := context.WithCancel(context.Background())
|
||||||
|
samlCleaner := auth.NewCleaner(
|
||||||
|
pgClient,
|
||||||
|
impl.cfg.Auth.SAML.CleanupInterval(),
|
||||||
|
l.Named("saml-cleaner"),
|
||||||
|
)
|
||||||
|
wg.Go(
|
||||||
|
func() {
|
||||||
|
if err := samlCleaner.Run(samlCleanerCtx); err != nil {
|
||||||
|
cancel(fmt.Errorf("saml cleaner crashed: %w", err))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
trustCenterServerCtx, stopTrustCenterServer := context.WithCancel(context.Background())
|
trustCenterServerCtx, stopTrustCenterServer := context.WithCancel(context.Background())
|
||||||
defer stopTrustCenterServer()
|
defer stopTrustCenterServer()
|
||||||
wg.Go(
|
wg.Go(
|
||||||
@@ -460,6 +498,7 @@ func (impl *Implm) Run(
|
|||||||
stopMailer()
|
stopMailer()
|
||||||
stopSlackSender()
|
stopSlackSender()
|
||||||
stopExportJobExporter()
|
stopExportJobExporter()
|
||||||
|
stopSAMLCleaner()
|
||||||
stopApiServer()
|
stopApiServer()
|
||||||
stopTrustCenterServer()
|
stopTrustCenterServer()
|
||||||
|
|
||||||
|
|||||||
40
pkg/probod/saml_config.go
Normal file
40
pkg/probod/saml_config.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package probod
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type samlConfig struct {
|
||||||
|
SessionDuration int `json:"session-duration"`
|
||||||
|
CleanupIntervalSeconds int `json:"cleanup-interval-seconds"`
|
||||||
|
Certificate string `json:"certificate"`
|
||||||
|
PrivateKey string `json:"private-key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c samlConfig) SessionDurationTime() time.Duration {
|
||||||
|
if c.SessionDuration == 0 {
|
||||||
|
return 7 * 24 * time.Hour
|
||||||
|
}
|
||||||
|
return time.Duration(c.SessionDuration) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c samlConfig) CleanupInterval() time.Duration {
|
||||||
|
if c.CleanupIntervalSeconds == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Duration(c.CleanupIntervalSeconds) * time.Second
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@ func DefaultConfig(name, secret string) Config {
|
|||||||
MaxAge: 86400 * 30, // 30 days
|
MaxAge: 86400 * 30, // 30 days
|
||||||
Secure: true,
|
Secure: true,
|
||||||
HTTPOnly: true,
|
HTTPOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteNoneMode, // None mode required for SAML (cross-site POST from IdP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type (
|
|||||||
Auth *auth.Service
|
Auth *auth.Service
|
||||||
Authz *authz.Service
|
Authz *authz.Service
|
||||||
Trust *trust.Service
|
Trust *trust.Service
|
||||||
|
SAML *auth.SAMLService
|
||||||
ConsoleAuth ConsoleAuthConfig
|
ConsoleAuth ConsoleAuthConfig
|
||||||
TrustAuth TrustAuthConfig
|
TrustAuth TrustAuthConfig
|
||||||
ConnectorRegistry *connector.ConnectorRegistry
|
ConnectorRegistry *connector.ConnectorRegistry
|
||||||
@@ -194,6 +195,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.cfg.ConnectorRegistry,
|
s.cfg.ConnectorRegistry,
|
||||||
s.cfg.SafeRedirect,
|
s.cfg.SafeRedirect,
|
||||||
s.cfg.CustomDomainCname,
|
s.cfg.CustomDomainCname,
|
||||||
|
s.cfg.SAML,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -60,11 +60,17 @@ type (
|
|||||||
proboSvc *probo.Service
|
proboSvc *probo.Service
|
||||||
authSvc *auth.Service
|
authSvc *auth.Service
|
||||||
authzSvc *authz.Service
|
authzSvc *authz.Service
|
||||||
|
samlSvc *auth.SAMLService
|
||||||
authCfg AuthConfig
|
authCfg AuthConfig
|
||||||
customDomainCname string
|
customDomainCname string
|
||||||
}
|
}
|
||||||
|
|
||||||
ctxKey struct{ name string }
|
ctxKey struct{ name string }
|
||||||
|
|
||||||
|
userTenantAccess struct {
|
||||||
|
tenantIDs []gid.TenantID
|
||||||
|
authErrors map[gid.TenantID]error
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -92,6 +98,7 @@ func NewMux(
|
|||||||
connectorRegistry *connector.ConnectorRegistry,
|
connectorRegistry *connector.ConnectorRegistry,
|
||||||
safeRedirect *saferedirect.SafeRedirect,
|
safeRedirect *saferedirect.SafeRedirect,
|
||||||
customDomainCname string,
|
customDomainCname string,
|
||||||
|
samlSvc *auth.SAMLService,
|
||||||
) *chi.Mux {
|
) *chi.Mux {
|
||||||
r := chi.NewMux()
|
r := chi.NewMux()
|
||||||
|
|
||||||
@@ -211,13 +218,6 @@ func NewMux(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
r.Post("/auth/register", SignUpHandler(authSvc, authCfg))
|
|
||||||
r.Post("/auth/login", SignInHandler(authSvc, authCfg))
|
|
||||||
r.Delete("/auth/logout", SignOutHandler(authSvc, authCfg))
|
|
||||||
r.Post("/auth/signup-from-invitation", SignupFromInvitationHandler(authSvc, authCfg))
|
|
||||||
r.Post("/auth/forget-password", ForgetPasswordHandler(authSvc, authCfg))
|
|
||||||
r.Post("/auth/reset-password", ResetPasswordHandler(authSvc, authCfg))
|
|
||||||
|
|
||||||
r.Get("/connectors/initiate", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/connectors/initiate", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
|
||||||
provider := r.URL.Query().Get("provider")
|
provider := r.URL.Query().Get("provider")
|
||||||
if provider != "SLACK" {
|
if provider != "SLACK" {
|
||||||
@@ -295,12 +295,12 @@ func NewMux(
|
|||||||
})
|
})
|
||||||
|
|
||||||
r.Get("/", playground.Handler("GraphQL", "/api/console/v1/query"))
|
r.Get("/", playground.Handler("GraphQL", "/api/console/v1/query"))
|
||||||
r.Post("/query", graphqlHandler(logger, proboSvc, authSvc, authzSvc, authCfg, customDomainCname))
|
r.Post("/query", graphqlHandler(logger, proboSvc, authSvc, authzSvc, samlSvc, authCfg, customDomainCname))
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthConfig, customDomainCname string) http.HandlerFunc {
|
func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service, authzSvc *authz.Service, samlSvc *auth.SAMLService, authCfg AuthConfig, customDomainCname string) http.HandlerFunc {
|
||||||
var mb int64 = 1 << 20
|
var mb int64 = 1 << 20
|
||||||
|
|
||||||
es := schema.NewExecutableSchema(
|
es := schema.NewExecutableSchema(
|
||||||
@@ -309,6 +309,7 @@ func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.S
|
|||||||
proboSvc: proboSvc,
|
proboSvc: proboSvc,
|
||||||
authSvc: authSvc,
|
authSvc: authSvc,
|
||||||
authzSvc: authzSvc,
|
authzSvc: authzSvc,
|
||||||
|
samlSvc: samlSvc,
|
||||||
authCfg: authCfg,
|
authCfg: authCfg,
|
||||||
customDomainCname: customDomainCname,
|
customDomainCname: customDomainCname,
|
||||||
},
|
},
|
||||||
@@ -387,7 +388,10 @@ func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthCon
|
|||||||
|
|
||||||
ctx = context.WithValue(ctx, sessionContextKey, authResult.Session)
|
ctx = context.WithValue(ctx, sessionContextKey, authResult.Session)
|
||||||
ctx = context.WithValue(ctx, userContextKey, authResult.User)
|
ctx = context.WithValue(ctx, userContextKey, authResult.User)
|
||||||
ctx = context.WithValue(ctx, userTenantContextKey, &authResult.TenantIDs)
|
ctx = context.WithValue(ctx, userTenantContextKey, &userTenantAccess{
|
||||||
|
tenantIDs: authResult.TenantIDs,
|
||||||
|
authErrors: authResult.AuthErrors,
|
||||||
|
})
|
||||||
|
|
||||||
next(w, r.WithContext(ctx))
|
next(w, r.WithContext(ctx))
|
||||||
|
|
||||||
@@ -425,13 +429,19 @@ func GetTenantAuthzService(ctx context.Context, authzSvc *authz.Service, tenantI
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateTenantAccess(ctx context.Context, tenantID gid.TenantID) {
|
func validateTenantAccess(ctx context.Context, tenantID gid.TenantID) {
|
||||||
tenantIDs, _ := ctx.Value(userTenantContextKey).(*[]gid.TenantID)
|
access, _ := ctx.Value(userTenantContextKey).(*userTenantAccess)
|
||||||
|
|
||||||
if tenantIDs == nil {
|
if access == nil {
|
||||||
panic(fmt.Errorf("tenant not found"))
|
panic(fmt.Errorf("tenant not found"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(*tenantIDs, tenantID) {
|
if !slices.Contains(access.tenantIDs, tenantID) {
|
||||||
panic(fmt.Errorf("tenant not found"))
|
if access.authErrors != nil {
|
||||||
|
if authErr := access.authErrors[tenantID]; authErr != nil {
|
||||||
|
panic(authErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panic(fmt.Errorf("access denied to tenant"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,34 @@ enum AuditState
|
|||||||
@goEnum(value: "github.com/getprobo/probo/pkg/coredata.AuditStateOutdated")
|
@goEnum(value: "github.com/getprobo/probo/pkg/coredata.AuditStateOutdated")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SAMLEnforcementPolicy
|
||||||
|
@goModel(
|
||||||
|
model: "github.com/getprobo/probo/pkg/coredata.SAMLEnforcementPolicy"
|
||||||
|
) {
|
||||||
|
OFF
|
||||||
|
@goEnum(
|
||||||
|
value: "github.com/getprobo/probo/pkg/coredata.SAMLEnforcementPolicyOff"
|
||||||
|
)
|
||||||
|
OPTIONAL
|
||||||
|
@goEnum(
|
||||||
|
value: "github.com/getprobo/probo/pkg/coredata.SAMLEnforcementPolicyOptional"
|
||||||
|
)
|
||||||
|
REQUIRED
|
||||||
|
@goEnum(
|
||||||
|
value: "github.com/getprobo/probo/pkg/coredata.SAMLEnforcementPolicyRequired"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserAuthMethod
|
||||||
|
@goModel(model: "github.com/getprobo/probo/pkg/coredata.UserAuthMethod") {
|
||||||
|
PASSWORD
|
||||||
|
@goEnum(
|
||||||
|
value: "github.com/getprobo/probo/pkg/coredata.UserAuthMethodPassword"
|
||||||
|
)
|
||||||
|
SAML
|
||||||
|
@goEnum(value: "github.com/getprobo/probo/pkg/coredata.UserAuthMethodSAML")
|
||||||
|
}
|
||||||
|
|
||||||
enum TrustCenterVisibility
|
enum TrustCenterVisibility
|
||||||
@goModel(
|
@goModel(
|
||||||
model: "github.com/getprobo/probo/pkg/coredata.TrustCenterVisibility"
|
model: "github.com/getprobo/probo/pkg/coredata.TrustCenterVisibility"
|
||||||
@@ -1791,6 +1819,8 @@ type Organization implements Node {
|
|||||||
|
|
||||||
customDomain: CustomDomain @goField(forceResolver: true)
|
customDomain: CustomDomain @goField(forceResolver: true)
|
||||||
|
|
||||||
|
samlConfigurations: [SAMLConfiguration!]! @goField(forceResolver: true)
|
||||||
|
|
||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: Datetime!
|
updatedAt: Datetime!
|
||||||
}
|
}
|
||||||
@@ -1810,6 +1840,7 @@ type Membership implements Node {
|
|||||||
role: String!
|
role: String!
|
||||||
fullName: String!
|
fullName: String!
|
||||||
emailAddress: String!
|
emailAddress: String!
|
||||||
|
authMethod: UserAuthMethod! @goField(forceResolver: true)
|
||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: Datetime!
|
updatedAt: Datetime!
|
||||||
}
|
}
|
||||||
@@ -2693,7 +2724,6 @@ type VendorServiceEdge {
|
|||||||
node: VendorService!
|
node: VendorService!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
type VendorRiskAssessmentConnection {
|
type VendorRiskAssessmentConnection {
|
||||||
edges: [VendorRiskAssessmentEdge!]!
|
edges: [VendorRiskAssessmentEdge!]!
|
||||||
pageInfo: PageInfo!
|
pageInfo: PageInfo!
|
||||||
@@ -3174,6 +3204,28 @@ type Mutation {
|
|||||||
deleteCustomDomain(
|
deleteCustomDomain(
|
||||||
input: DeleteCustomDomainInput!
|
input: DeleteCustomDomainInput!
|
||||||
): DeleteCustomDomainPayload!
|
): DeleteCustomDomainPayload!
|
||||||
|
|
||||||
|
# SAML Configuration mutations (OWNER/ADMIN only)
|
||||||
|
# Step 1: Initiate domain verification (creates SAML config with unverified domain)
|
||||||
|
initiateDomainVerification(
|
||||||
|
input: InitiateDomainVerificationInput!
|
||||||
|
): InitiateDomainVerificationPayload!
|
||||||
|
|
||||||
|
# Step 2: Verify domain ownership via DNS TXT record
|
||||||
|
verifyDomain(input: VerifyDomainInput!): VerifyDomainPayload!
|
||||||
|
|
||||||
|
# Step 3: Configure SAML (only allowed after domain is verified)
|
||||||
|
createSAMLConfiguration(
|
||||||
|
input: CreateSAMLConfigurationInput!
|
||||||
|
): CreateSAMLConfigurationPayload!
|
||||||
|
updateSAMLConfiguration(
|
||||||
|
input: UpdateSAMLConfigurationInput!
|
||||||
|
): UpdateSAMLConfigurationPayload!
|
||||||
|
deleteSAMLConfiguration(
|
||||||
|
input: DeleteSAMLConfigurationInput!
|
||||||
|
): DeleteSAMLConfigurationPayload!
|
||||||
|
enableSAML(input: EnableSAMLInput!): EnableSAMLPayload!
|
||||||
|
disableSAML(input: DisableSAMLInput!): DisableSAMLPayload!
|
||||||
}
|
}
|
||||||
|
|
||||||
# Input Types
|
# Input Types
|
||||||
@@ -4794,3 +4846,167 @@ type CreateCustomDomainPayload {
|
|||||||
type DeleteCustomDomainPayload {
|
type DeleteCustomDomainPayload {
|
||||||
deletedCustomDomainId: ID!
|
deletedCustomDomainId: ID!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SAML Configuration Types
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
type SAMLConfiguration implements Node {
|
||||||
|
id: ID!
|
||||||
|
organization: Organization! @goField(forceResolver: true)
|
||||||
|
emailDomain: String!
|
||||||
|
enabled: Boolean!
|
||||||
|
enforcementPolicy: SAMLEnforcementPolicy!
|
||||||
|
|
||||||
|
# Domain verification (required before SAML can be configured)
|
||||||
|
domainVerified: Boolean!
|
||||||
|
domainVerificationToken: String
|
||||||
|
domainVerifiedAt: Datetime
|
||||||
|
|
||||||
|
# Service Provider metadata (read-only, auto-generated)
|
||||||
|
spEntityId: String!
|
||||||
|
spAcsUrl: String!
|
||||||
|
spMetadataUrl: String! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
# Identity Provider configuration
|
||||||
|
idpEntityId: String!
|
||||||
|
idpSsoUrl: String!
|
||||||
|
idpCertificate: String!
|
||||||
|
idpMetadataUrl: String
|
||||||
|
|
||||||
|
# Attribute mapping
|
||||||
|
attributeEmail: String!
|
||||||
|
attributeFirstname: String!
|
||||||
|
attributeLastname: String!
|
||||||
|
attributeRole: String!
|
||||||
|
|
||||||
|
# Default role for users when role attribute is missing or invalid
|
||||||
|
defaultRole: String!
|
||||||
|
|
||||||
|
# Auto-signup
|
||||||
|
autoSignupEnabled: Boolean!
|
||||||
|
|
||||||
|
# Test login URL for this configuration
|
||||||
|
testLoginUrl: String! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
createdAt: Datetime!
|
||||||
|
updatedAt: Datetime!
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SAML Configuration Inputs
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
input CreateSAMLConfigurationInput {
|
||||||
|
organizationId: ID!
|
||||||
|
|
||||||
|
# Email domain this config applies to
|
||||||
|
emailDomain: String!
|
||||||
|
|
||||||
|
# Enforcement policy for this SAML configuration
|
||||||
|
enforcementPolicy: SAMLEnforcementPolicy!
|
||||||
|
|
||||||
|
# SP configuration (optional - auto-generated if not provided)
|
||||||
|
spCertificate: String
|
||||||
|
spPrivateKey: String
|
||||||
|
|
||||||
|
# IdP configuration - Option 1: Provide metadata XML (recommended for Google Workspace)
|
||||||
|
# This will automatically extract entityId, ssoUrl, and certificate from the metadata
|
||||||
|
idpMetadataXml: String
|
||||||
|
|
||||||
|
# IdP configuration - Option 2: Provide individual fields manually
|
||||||
|
# Required if idpMetadataXml is not provided
|
||||||
|
idpEntityId: String
|
||||||
|
idpSsoUrl: String
|
||||||
|
idpCertificate: String
|
||||||
|
idpMetadataUrl: String
|
||||||
|
|
||||||
|
# Attribute mapping (optional, defaults provided)
|
||||||
|
attributeEmail: String
|
||||||
|
attributeFirstname: String
|
||||||
|
attributeLastname: String
|
||||||
|
attributeRole: String
|
||||||
|
|
||||||
|
defaultRole: String
|
||||||
|
autoSignupEnabled: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateSAMLConfigurationInput {
|
||||||
|
id: ID!
|
||||||
|
|
||||||
|
enabled: Boolean
|
||||||
|
enforcementPolicy: SAMLEnforcementPolicy
|
||||||
|
spCertificate: String
|
||||||
|
spPrivateKey: String
|
||||||
|
idpEntityId: String
|
||||||
|
idpSsoUrl: String
|
||||||
|
idpCertificate: String
|
||||||
|
idpMetadataUrl: String
|
||||||
|
attributeEmail: String
|
||||||
|
attributeFirstname: String
|
||||||
|
attributeLastname: String
|
||||||
|
attributeRole: String
|
||||||
|
defaultRole: String
|
||||||
|
autoSignupEnabled: Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Domain Verification Inputs
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
input InitiateDomainVerificationInput {
|
||||||
|
organizationId: ID!
|
||||||
|
emailDomain: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input VerifyDomainInput {
|
||||||
|
id: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input DeleteSAMLConfigurationInput {
|
||||||
|
id: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input EnableSAMLInput {
|
||||||
|
id: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
input DisableSAMLInput {
|
||||||
|
id: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# SAML Configuration Payloads
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
type InitiateDomainVerificationPayload {
|
||||||
|
samlConfiguration: SAMLConfiguration!
|
||||||
|
# The TXT record value that needs to be added to DNS
|
||||||
|
# Format: probo-verification={token}
|
||||||
|
dnsRecord: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyDomainPayload {
|
||||||
|
samlConfiguration: SAMLConfiguration!
|
||||||
|
verified: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateSAMLConfigurationPayload {
|
||||||
|
samlConfiguration: SAMLConfiguration!
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateSAMLConfigurationPayload {
|
||||||
|
samlConfiguration: SAMLConfiguration!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteSAMLConfigurationPayload {
|
||||||
|
deletedSAMLConfigurationId: ID!
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnableSAMLPayload {
|
||||||
|
samlConfiguration: SAMLConfiguration!
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisableSAMLPayload {
|
||||||
|
samlConfiguration: SAMLConfiguration!
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
45
pkg/server/api/console/v1/types/saml_configuration.go
Normal file
45
pkg/server/api/console/v1/types/saml_configuration.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/getprobo/probo/pkg/coredata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewSAMLConfigurationWithURLs(c *coredata.SAMLConfiguration, spEntityID, spAcsURL string) *SAMLConfiguration {
|
||||||
|
return &SAMLConfiguration{
|
||||||
|
ID: c.ID,
|
||||||
|
EmailDomain: c.EmailDomain,
|
||||||
|
Enabled: c.Enabled,
|
||||||
|
EnforcementPolicy: c.EnforcementPolicy,
|
||||||
|
DomainVerified: c.DomainVerified,
|
||||||
|
DomainVerificationToken: c.DomainVerificationToken,
|
||||||
|
DomainVerifiedAt: c.DomainVerifiedAt,
|
||||||
|
SpEntityID: spEntityID,
|
||||||
|
SpAcsURL: spAcsURL,
|
||||||
|
IdpEntityID: c.IdPEntityID,
|
||||||
|
IdpSsoURL: c.IdPSsoURL,
|
||||||
|
IdpCertificate: c.IdPCertificate,
|
||||||
|
IdpMetadataURL: c.IdPMetadataURL,
|
||||||
|
AttributeEmail: c.AttributeEmail,
|
||||||
|
AttributeFirstname: c.AttributeFirstname,
|
||||||
|
AttributeLastname: c.AttributeLastname,
|
||||||
|
AttributeRole: c.AttributeRole,
|
||||||
|
DefaultRole: c.DefaultRole,
|
||||||
|
AutoSignupEnabled: c.AutoSignupEnabled,
|
||||||
|
CreatedAt: c.CreatedAt,
|
||||||
|
UpdatedAt: c.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -505,6 +505,29 @@ type CreateRiskPayload struct {
|
|||||||
RiskEdge *RiskEdge `json:"riskEdge"`
|
RiskEdge *RiskEdge `json:"riskEdge"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateSAMLConfigurationInput struct {
|
||||||
|
OrganizationID gid.GID `json:"organizationId"`
|
||||||
|
EmailDomain string `json:"emailDomain"`
|
||||||
|
EnforcementPolicy coredata.SAMLEnforcementPolicy `json:"enforcementPolicy"`
|
||||||
|
SpCertificate *string `json:"spCertificate,omitempty"`
|
||||||
|
SpPrivateKey *string `json:"spPrivateKey,omitempty"`
|
||||||
|
IdpMetadataXML *string `json:"idpMetadataXml,omitempty"`
|
||||||
|
IdpEntityID *string `json:"idpEntityId,omitempty"`
|
||||||
|
IdpSsoURL *string `json:"idpSsoUrl,omitempty"`
|
||||||
|
IdpCertificate *string `json:"idpCertificate,omitempty"`
|
||||||
|
IdpMetadataURL *string `json:"idpMetadataUrl,omitempty"`
|
||||||
|
AttributeEmail *string `json:"attributeEmail,omitempty"`
|
||||||
|
AttributeFirstname *string `json:"attributeFirstname,omitempty"`
|
||||||
|
AttributeLastname *string `json:"attributeLastname,omitempty"`
|
||||||
|
AttributeRole *string `json:"attributeRole,omitempty"`
|
||||||
|
DefaultRole *string `json:"defaultRole,omitempty"`
|
||||||
|
AutoSignupEnabled *bool `json:"autoSignupEnabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateSAMLConfigurationPayload struct {
|
||||||
|
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
|
||||||
|
}
|
||||||
|
|
||||||
type CreateSnapshotInput struct {
|
type CreateSnapshotInput struct {
|
||||||
OrganizationID gid.GID `json:"organizationId"`
|
OrganizationID gid.GID `json:"organizationId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -904,6 +927,14 @@ type DeleteRiskPayload struct {
|
|||||||
DeletedRiskID gid.GID `json:"deletedRiskId"`
|
DeletedRiskID gid.GID `json:"deletedRiskId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeleteSAMLConfigurationInput struct {
|
||||||
|
ID gid.GID `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeleteSAMLConfigurationPayload struct {
|
||||||
|
DeletedSAMLConfigurationID gid.GID `json:"deletedSAMLConfigurationId"`
|
||||||
|
}
|
||||||
|
|
||||||
type DeleteSnapshotInput struct {
|
type DeleteSnapshotInput struct {
|
||||||
SnapshotID gid.GID `json:"snapshotId"`
|
SnapshotID gid.GID `json:"snapshotId"`
|
||||||
}
|
}
|
||||||
@@ -1000,6 +1031,14 @@ type DeleteVendorServicePayload struct {
|
|||||||
DeletedVendorServiceID gid.GID `json:"deletedVendorServiceId"`
|
DeletedVendorServiceID gid.GID `json:"deletedVendorServiceId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DisableSAMLInput struct {
|
||||||
|
ID gid.GID `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisableSAMLPayload struct {
|
||||||
|
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
|
||||||
|
}
|
||||||
|
|
||||||
type Document struct {
|
type Document struct {
|
||||||
ID gid.GID `json:"id"`
|
ID gid.GID `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -1094,6 +1133,14 @@ type DocumentVersionSignatureOrder struct {
|
|||||||
Direction page.OrderDirection `json:"direction"`
|
Direction page.OrderDirection `json:"direction"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EnableSAMLInput struct {
|
||||||
|
ID gid.GID `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnableSAMLPayload struct {
|
||||||
|
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
|
||||||
|
}
|
||||||
|
|
||||||
type Evidence struct {
|
type Evidence struct {
|
||||||
ID gid.GID `json:"id"`
|
ID gid.GID `json:"id"`
|
||||||
Size int `json:"size"`
|
Size int `json:"size"`
|
||||||
@@ -1216,6 +1263,16 @@ type ImportMeasurePayload struct {
|
|||||||
MeasureEdges []*MeasureEdge `json:"measureEdges"`
|
MeasureEdges []*MeasureEdge `json:"measureEdges"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InitiateDomainVerificationInput struct {
|
||||||
|
OrganizationID gid.GID `json:"organizationId"`
|
||||||
|
EmailDomain string `json:"emailDomain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InitiateDomainVerificationPayload struct {
|
||||||
|
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
|
||||||
|
DNSRecord string `json:"dnsRecord"`
|
||||||
|
}
|
||||||
|
|
||||||
type Invitation struct {
|
type Invitation struct {
|
||||||
ID gid.GID `json:"id"`
|
ID gid.GID `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -1284,14 +1341,15 @@ type MeasureFilter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Membership struct {
|
type Membership struct {
|
||||||
ID gid.GID `json:"id"`
|
ID gid.GID `json:"id"`
|
||||||
UserID gid.GID `json:"userID"`
|
UserID gid.GID `json:"userID"`
|
||||||
OrganizationID gid.GID `json:"organizationID"`
|
OrganizationID gid.GID `json:"organizationID"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
EmailAddress string `json:"emailAddress"`
|
EmailAddress string `json:"emailAddress"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
AuthMethod coredata.UserAuthMethod `json:"authMethod"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Membership) IsNode() {}
|
func (Membership) IsNode() {}
|
||||||
@@ -1396,6 +1454,7 @@ type Organization struct {
|
|||||||
TrustCenterFiles *TrustCenterFileConnection `json:"trustCenterFiles"`
|
TrustCenterFiles *TrustCenterFileConnection `json:"trustCenterFiles"`
|
||||||
TrustCenter *TrustCenter `json:"trustCenter,omitempty"`
|
TrustCenter *TrustCenter `json:"trustCenter,omitempty"`
|
||||||
CustomDomain *CustomDomain `json:"customDomain,omitempty"`
|
CustomDomain *CustomDomain `json:"customDomain,omitempty"`
|
||||||
|
SamlConfigurations []*SAMLConfiguration `json:"samlConfigurations"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
@@ -1580,6 +1639,36 @@ type RiskFilter struct {
|
|||||||
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
|
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SAMLConfiguration struct {
|
||||||
|
ID gid.GID `json:"id"`
|
||||||
|
Organization *Organization `json:"organization"`
|
||||||
|
EmailDomain string `json:"emailDomain"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
EnforcementPolicy coredata.SAMLEnforcementPolicy `json:"enforcementPolicy"`
|
||||||
|
DomainVerified bool `json:"domainVerified"`
|
||||||
|
DomainVerificationToken *string `json:"domainVerificationToken,omitempty"`
|
||||||
|
DomainVerifiedAt *time.Time `json:"domainVerifiedAt,omitempty"`
|
||||||
|
SpEntityID string `json:"spEntityId"`
|
||||||
|
SpAcsURL string `json:"spAcsUrl"`
|
||||||
|
SpMetadataURL string `json:"spMetadataUrl"`
|
||||||
|
IdpEntityID string `json:"idpEntityId"`
|
||||||
|
IdpSsoURL string `json:"idpSsoUrl"`
|
||||||
|
IdpCertificate string `json:"idpCertificate"`
|
||||||
|
IdpMetadataURL *string `json:"idpMetadataUrl,omitempty"`
|
||||||
|
AttributeEmail string `json:"attributeEmail"`
|
||||||
|
AttributeFirstname string `json:"attributeFirstname"`
|
||||||
|
AttributeLastname string `json:"attributeLastname"`
|
||||||
|
AttributeRole string `json:"attributeRole"`
|
||||||
|
DefaultRole string `json:"defaultRole"`
|
||||||
|
AutoSignupEnabled bool `json:"autoSignupEnabled"`
|
||||||
|
TestLoginURL string `json:"testLoginUrl"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (SAMLConfiguration) IsNode() {}
|
||||||
|
func (this SAMLConfiguration) GetID() gid.GID { return this.ID }
|
||||||
|
|
||||||
type SendSigningNotificationsInput struct {
|
type SendSigningNotificationsInput struct {
|
||||||
OrganizationID gid.GID `json:"organizationId"`
|
OrganizationID gid.GID `json:"organizationId"`
|
||||||
}
|
}
|
||||||
@@ -1973,6 +2062,28 @@ type UpdateRiskPayload struct {
|
|||||||
Risk *Risk `json:"risk"`
|
Risk *Risk `json:"risk"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateSAMLConfigurationInput struct {
|
||||||
|
ID gid.GID `json:"id"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
EnforcementPolicy *coredata.SAMLEnforcementPolicy `json:"enforcementPolicy,omitempty"`
|
||||||
|
SpCertificate *string `json:"spCertificate,omitempty"`
|
||||||
|
SpPrivateKey *string `json:"spPrivateKey,omitempty"`
|
||||||
|
IdpEntityID *string `json:"idpEntityId,omitempty"`
|
||||||
|
IdpSsoURL *string `json:"idpSsoUrl,omitempty"`
|
||||||
|
IdpCertificate *string `json:"idpCertificate,omitempty"`
|
||||||
|
IdpMetadataURL *string `json:"idpMetadataUrl,omitempty"`
|
||||||
|
AttributeEmail *string `json:"attributeEmail,omitempty"`
|
||||||
|
AttributeFirstname *string `json:"attributeFirstname,omitempty"`
|
||||||
|
AttributeLastname *string `json:"attributeLastname,omitempty"`
|
||||||
|
AttributeRole *string `json:"attributeRole,omitempty"`
|
||||||
|
DefaultRole *string `json:"defaultRole,omitempty"`
|
||||||
|
AutoSignupEnabled *bool `json:"autoSignupEnabled,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateSAMLConfigurationPayload struct {
|
||||||
|
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
|
||||||
|
}
|
||||||
|
|
||||||
type UpdateTaskInput struct {
|
type UpdateTaskInput struct {
|
||||||
TaskID gid.GID `json:"taskId"`
|
TaskID gid.GID `json:"taskId"`
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
@@ -2359,6 +2470,15 @@ type VendorServiceEdge struct {
|
|||||||
Node *VendorService `json:"node"`
|
Node *VendorService `json:"node"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VerifyDomainInput struct {
|
||||||
|
ID gid.GID `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerifyDomainPayload struct {
|
||||||
|
SamlConfiguration *SAMLConfiguration `json:"samlConfiguration"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
}
|
||||||
|
|
||||||
type Viewer struct {
|
type Viewer struct {
|
||||||
ID gid.GID `json:"id"`
|
ID gid.GID `json:"id"`
|
||||||
User *User `json:"user"`
|
User *User `json:"user"`
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/getprobo/probo/pkg/auth"
|
||||||
"github.com/getprobo/probo/pkg/authz"
|
"github.com/getprobo/probo/pkg/authz"
|
||||||
"github.com/getprobo/probo/pkg/coredata"
|
"github.com/getprobo/probo/pkg/coredata"
|
||||||
"github.com/getprobo/probo/pkg/gid"
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
@@ -1081,6 +1083,20 @@ func (r *measureConnectionResolver) TotalCount(ctx context.Context, obj *types.M
|
|||||||
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthMethod is the resolver for the authMethod field.
|
||||||
|
func (r *membershipResolver) AuthMethod(ctx context.Context, obj *types.Membership) (coredata.UserAuthMethod, error) {
|
||||||
|
session := SessionFromContext(ctx)
|
||||||
|
if session == nil {
|
||||||
|
return coredata.UserAuthMethodPassword, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
authMethod, err := r.authSvc.GetUserAuthMethod(ctx, coredata.NewScope(obj.UserID.TenantID()), obj.UserID, obj.OrganizationID, session)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot get user auth method: %w", err)
|
||||||
|
}
|
||||||
|
return authMethod, nil
|
||||||
|
}
|
||||||
|
|
||||||
// TotalCount is the resolver for the totalCount field.
|
// TotalCount is the resolver for the totalCount field.
|
||||||
func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *types.MembershipConnection) (int, error) {
|
func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *types.MembershipConnection) (int, error) {
|
||||||
switch obj.Resolver.(type) {
|
switch obj.Resolver.(type) {
|
||||||
@@ -1098,6 +1114,8 @@ func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *type
|
|||||||
|
|
||||||
// CreateOrganization is the resolver for the createOrganization field.
|
// CreateOrganization is the resolver for the createOrganization field.
|
||||||
func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.CreateOrganizationInput) (*types.CreateOrganizationPayload, error) {
|
func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.CreateOrganizationInput) (*types.CreateOrganizationPayload, error) {
|
||||||
|
currentUser := UserFromContext(ctx)
|
||||||
|
|
||||||
prb := r.proboSvc.WithTenant(gid.NewTenantID())
|
prb := r.proboSvc.WithTenant(gid.NewTenantID())
|
||||||
|
|
||||||
organization, err := prb.Organizations.Create(
|
organization, err := prb.Organizations.Create(
|
||||||
@@ -1112,7 +1130,7 @@ func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.C
|
|||||||
|
|
||||||
err = r.authzSvc.AddUserToOrganization(
|
err = r.authzSvc.AddUserToOrganization(
|
||||||
ctx,
|
ctx,
|
||||||
UserFromContext(ctx).ID,
|
currentUser.ID,
|
||||||
organization.ID,
|
organization.ID,
|
||||||
string(authz.RoleMember),
|
string(authz.RoleMember),
|
||||||
)
|
)
|
||||||
@@ -1129,8 +1147,8 @@ func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.C
|
|||||||
ctx,
|
ctx,
|
||||||
probo.CreatePeopleRequest{
|
probo.CreatePeopleRequest{
|
||||||
OrganizationID: organization.ID,
|
OrganizationID: organization.ID,
|
||||||
FullName: UserFromContext(ctx).FullName,
|
FullName: currentUser.FullName,
|
||||||
PrimaryEmailAddress: UserFromContext(ctx).EmailAddress,
|
PrimaryEmailAddress: currentUser.EmailAddress,
|
||||||
AdditionalEmailAddresses: []string{},
|
AdditionalEmailAddresses: []string{},
|
||||||
Kind: coredata.PeopleKindEmployee,
|
Kind: coredata.PeopleKindEmployee,
|
||||||
},
|
},
|
||||||
@@ -3537,6 +3555,260 @@ func (r *mutationResolver) DeleteCustomDomain(ctx context.Context, input types.D
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitiateDomainVerification is the resolver for the initiateDomainVerification field.
|
||||||
|
func (r *mutationResolver) InitiateDomainVerification(ctx context.Context, input types.InitiateDomainVerificationInput) (*types.InitiateDomainVerificationPayload, error) {
|
||||||
|
user := UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, fmt.Errorf("user not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
organizationID := input.OrganizationID
|
||||||
|
tenantID := organizationID.TenantID()
|
||||||
|
|
||||||
|
config, err := r.authSvc.InitiateDomainVerification(ctx, tenantID, organizationID, input.EmailDomain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initiate domain verification: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsRecord := auth.GetDomainVerificationRecord(*config.DomainVerificationToken)
|
||||||
|
|
||||||
|
return &types.InitiateDomainVerificationPayload{
|
||||||
|
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
|
||||||
|
config,
|
||||||
|
r.samlSvc.GetEntityID(),
|
||||||
|
r.samlSvc.GetAcsURL(),
|
||||||
|
),
|
||||||
|
DNSRecord: dnsRecord,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyDomain is the resolver for the verifyDomain field.
|
||||||
|
func (r *mutationResolver) VerifyDomain(ctx context.Context, input types.VerifyDomainInput) (*types.VerifyDomainPayload, error) {
|
||||||
|
user := UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, fmt.Errorf("user not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
configID := input.ID
|
||||||
|
tenantID := configID.TenantID()
|
||||||
|
|
||||||
|
config, verified, err := r.authSvc.VerifyDomain(ctx, tenantID, configID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to verify domain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.VerifyDomainPayload{
|
||||||
|
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
|
||||||
|
config,
|
||||||
|
r.samlSvc.GetEntityID(),
|
||||||
|
r.samlSvc.GetAcsURL(),
|
||||||
|
),
|
||||||
|
Verified: verified,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSAMLConfiguration is the resolver for the createSAMLConfiguration field.
|
||||||
|
func (r *mutationResolver) CreateSAMLConfiguration(ctx context.Context, input types.CreateSAMLConfigurationInput) (*types.CreateSAMLConfigurationPayload, error) {
|
||||||
|
user := UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, fmt.Errorf("user not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
organizationID := input.OrganizationID
|
||||||
|
tenantID := organizationID.TenantID()
|
||||||
|
|
||||||
|
var idpEntityID, idpSsoURL, idpCertificate string
|
||||||
|
var idpMetadataURL *string
|
||||||
|
|
||||||
|
if input.IdpMetadataXML != nil && *input.IdpMetadataXML != "" {
|
||||||
|
metadata, err := auth.ParseIdPMetadata(*input.IdpMetadataXML)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse IdP metadata XML: %w", err)
|
||||||
|
}
|
||||||
|
idpEntityID = metadata.EntityID
|
||||||
|
idpSsoURL = metadata.SsoURL
|
||||||
|
idpCertificate = metadata.Certificate
|
||||||
|
idpMetadataURL = metadata.MetadataURL
|
||||||
|
} else {
|
||||||
|
if input.IdpEntityID == nil || *input.IdpEntityID == "" {
|
||||||
|
return nil, fmt.Errorf("either idpMetadataXml or idpEntityId must be provided")
|
||||||
|
}
|
||||||
|
if input.IdpSsoURL == nil || *input.IdpSsoURL == "" {
|
||||||
|
return nil, fmt.Errorf("either idpMetadataXml or idpSsoUrl must be provided")
|
||||||
|
}
|
||||||
|
if input.IdpCertificate == nil || *input.IdpCertificate == "" {
|
||||||
|
return nil, fmt.Errorf("either idpMetadataXml or idpCertificate must be provided")
|
||||||
|
}
|
||||||
|
idpEntityID = *input.IdpEntityID
|
||||||
|
idpSsoURL = *input.IdpSsoURL
|
||||||
|
idpCertificate = *input.IdpCertificate
|
||||||
|
idpMetadataURL = input.IdpMetadataURL
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeEmail := "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
|
||||||
|
if input.AttributeEmail != nil {
|
||||||
|
attributeEmail = *input.AttributeEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeFirstname := "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
|
||||||
|
if input.AttributeFirstname != nil {
|
||||||
|
attributeFirstname = *input.AttributeFirstname
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeLastname := "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
|
||||||
|
if input.AttributeLastname != nil {
|
||||||
|
attributeLastname = *input.AttributeLastname
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeRole := "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role"
|
||||||
|
if input.AttributeRole != nil {
|
||||||
|
attributeRole = *input.AttributeRole
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRole := "MEMBER"
|
||||||
|
if input.DefaultRole != nil {
|
||||||
|
defaultRole = *input.DefaultRole
|
||||||
|
}
|
||||||
|
|
||||||
|
autoSignupEnabled := false
|
||||||
|
if input.AutoSignupEnabled != nil {
|
||||||
|
autoSignupEnabled = *input.AutoSignupEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := r.authSvc.WithTenant(tenantID).CreateSAMLConfiguration(ctx, auth.CreateSAMLConfigurationRequest{
|
||||||
|
OrganizationID: organizationID,
|
||||||
|
EmailDomain: input.EmailDomain,
|
||||||
|
EnforcementPolicy: input.EnforcementPolicy,
|
||||||
|
IdPEntityID: idpEntityID,
|
||||||
|
IdPSsoURL: idpSsoURL,
|
||||||
|
IdPCertificate: idpCertificate,
|
||||||
|
IdPMetadataURL: idpMetadataURL,
|
||||||
|
AttributeEmail: attributeEmail,
|
||||||
|
AttributeFirstname: attributeFirstname,
|
||||||
|
AttributeLastname: attributeLastname,
|
||||||
|
AttributeRole: attributeRole,
|
||||||
|
DefaultRole: defaultRole,
|
||||||
|
AutoSignupEnabled: autoSignupEnabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SAML configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.CreateSAMLConfigurationPayload{
|
||||||
|
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
|
||||||
|
config,
|
||||||
|
r.samlSvc.GetEntityID(),
|
||||||
|
r.samlSvc.GetAcsURL(),
|
||||||
|
),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSAMLConfiguration is the resolver for the updateSAMLConfiguration field.
|
||||||
|
func (r *mutationResolver) UpdateSAMLConfiguration(ctx context.Context, input types.UpdateSAMLConfigurationInput) (*types.UpdateSAMLConfigurationPayload, error) {
|
||||||
|
user := UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, fmt.Errorf("user not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
configID := input.ID
|
||||||
|
tenantID := configID.TenantID()
|
||||||
|
|
||||||
|
updatedConfig, err := r.authSvc.WithTenant(tenantID).UpdateSAMLConfiguration(ctx, auth.UpdateSAMLConfigurationRequest{
|
||||||
|
ID: configID,
|
||||||
|
Enabled: input.Enabled,
|
||||||
|
EnforcementPolicy: input.EnforcementPolicy,
|
||||||
|
IdPEntityID: input.IdpEntityID,
|
||||||
|
IdPSsoURL: input.IdpSsoURL,
|
||||||
|
IdPCertificate: input.IdpCertificate,
|
||||||
|
IdPMetadataURL: input.IdpMetadataURL,
|
||||||
|
AttributeEmail: input.AttributeEmail,
|
||||||
|
AttributeFirstname: input.AttributeFirstname,
|
||||||
|
AttributeLastname: input.AttributeLastname,
|
||||||
|
AttributeRole: input.AttributeRole,
|
||||||
|
DefaultRole: input.DefaultRole,
|
||||||
|
AutoSignupEnabled: input.AutoSignupEnabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to update SAML configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.UpdateSAMLConfigurationPayload{
|
||||||
|
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
|
||||||
|
updatedConfig,
|
||||||
|
r.samlSvc.GetEntityID(),
|
||||||
|
r.samlSvc.GetAcsURL(),
|
||||||
|
),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSAMLConfiguration is the resolver for the deleteSAMLConfiguration field.
|
||||||
|
func (r *mutationResolver) DeleteSAMLConfiguration(ctx context.Context, input types.DeleteSAMLConfigurationInput) (*types.DeleteSAMLConfigurationPayload, error) {
|
||||||
|
user := UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, fmt.Errorf("user not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
configID := input.ID
|
||||||
|
tenantID := configID.TenantID()
|
||||||
|
|
||||||
|
err := r.authSvc.WithTenant(tenantID).DeleteSAMLConfiguration(ctx, configID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to delete SAML configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.DeleteSAMLConfigurationPayload{
|
||||||
|
DeletedSAMLConfigurationID: configID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableSaml is the resolver for the enableSAML field.
|
||||||
|
func (r *mutationResolver) EnableSaml(ctx context.Context, input types.EnableSAMLInput) (*types.EnableSAMLPayload, error) {
|
||||||
|
user := UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, fmt.Errorf("user not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
configID := input.ID
|
||||||
|
tenantID := configID.TenantID()
|
||||||
|
|
||||||
|
enabledConfig, err := r.authSvc.WithTenant(tenantID).EnableSAMLConfiguration(ctx, configID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to enable SAML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.EnableSAMLPayload{
|
||||||
|
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
|
||||||
|
enabledConfig,
|
||||||
|
r.samlSvc.GetEntityID(),
|
||||||
|
r.samlSvc.GetAcsURL(),
|
||||||
|
),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableSaml is the resolver for the disableSAML field.
|
||||||
|
func (r *mutationResolver) DisableSaml(ctx context.Context, input types.DisableSAMLInput) (*types.DisableSAMLPayload, error) {
|
||||||
|
user := UserFromContext(ctx)
|
||||||
|
if user == nil {
|
||||||
|
return nil, fmt.Errorf("user not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
configID := input.ID
|
||||||
|
tenantID := configID.TenantID()
|
||||||
|
|
||||||
|
disabledConfig, err := r.authSvc.WithTenant(tenantID).DisableSAMLConfiguration(ctx, configID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to disable SAML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.DisableSAMLPayload{
|
||||||
|
SamlConfiguration: types.NewSAMLConfigurationWithURLs(
|
||||||
|
disabledConfig,
|
||||||
|
r.samlSvc.GetEntityID(),
|
||||||
|
r.samlSvc.GetAcsURL(),
|
||||||
|
),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Organization is the resolver for the organization field.
|
// Organization is the resolver for the organization field.
|
||||||
func (r *nonconformityResolver) Organization(ctx context.Context, obj *types.Nonconformity) (*types.Organization, error) {
|
func (r *nonconformityResolver) Organization(ctx context.Context, obj *types.Nonconformity) (*types.Organization, error) {
|
||||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||||
@@ -4282,6 +4554,27 @@ func (r *organizationResolver) CustomDomain(ctx context.Context, obj *types.Orga
|
|||||||
return types.NewCustomDomain(domain, r.customDomainCname), nil
|
return types.NewCustomDomain(domain, r.customDomainCname), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SamlConfigurations is the resolver for the samlConfigurations field.
|
||||||
|
func (r *organizationResolver) SamlConfigurations(ctx context.Context, obj *types.Organization) ([]*types.SAMLConfiguration, error) {
|
||||||
|
tenantID := obj.ID.TenantID()
|
||||||
|
|
||||||
|
configs, err := r.authSvc.WithTenant(tenantID).GetSAMLConfigurationsByOrganizationID(ctx, obj.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load SAML configurations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*types.SAMLConfiguration, len(configs))
|
||||||
|
for i, config := range configs {
|
||||||
|
result[i] = types.NewSAMLConfigurationWithURLs(
|
||||||
|
config,
|
||||||
|
r.samlSvc.GetEntityID(),
|
||||||
|
r.samlSvc.GetAcsURL(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// TotalCount is the resolver for the totalCount field.
|
// TotalCount is the resolver for the totalCount field.
|
||||||
func (r *peopleConnectionResolver) TotalCount(ctx context.Context, obj *types.PeopleConnection) (int, error) {
|
func (r *peopleConnectionResolver) TotalCount(ctx context.Context, obj *types.PeopleConnection) (int, error) {
|
||||||
prb := r.ProboService(ctx, obj.ParentID.TenantID())
|
prb := r.ProboService(ctx, obj.ParentID.TenantID())
|
||||||
@@ -4727,6 +5020,41 @@ func (r *riskConnectionResolver) TotalCount(ctx context.Context, obj *types.Risk
|
|||||||
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Organization is the resolver for the organization field.
|
||||||
|
func (r *sAMLConfigurationResolver) Organization(ctx context.Context, obj *types.SAMLConfiguration) (*types.Organization, error) {
|
||||||
|
tenantID := obj.ID.TenantID()
|
||||||
|
prb := r.ProboService(ctx, tenantID)
|
||||||
|
|
||||||
|
config, err := r.authSvc.WithTenant(tenantID).GetSAMLConfigurationByID(ctx, obj.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load SAML configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
org, err := prb.Organizations.Get(ctx, config.OrganizationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load organization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.NewOrganization(org), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpMetadataURL is the resolver for the spMetadataUrl field.
|
||||||
|
// Returns global Entity ID (same as spEntityId since metadata URL no longer needs config parameter)
|
||||||
|
func (r *sAMLConfigurationResolver) SpMetadataURL(ctx context.Context, obj *types.SAMLConfiguration) (string, error) {
|
||||||
|
return r.samlSvc.GetEntityID(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoginURL is the resolver for the testLoginUrl field.
|
||||||
|
func (r *sAMLConfigurationResolver) TestLoginURL(ctx context.Context, obj *types.SAMLConfiguration) (string, error) {
|
||||||
|
entityID := r.samlSvc.GetEntityID()
|
||||||
|
parts := strings.Split(entityID, "/auth/saml/metadata")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", fmt.Errorf("invalid entity ID format")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s/auth/saml/login/%s", parts[0], obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Organization is the resolver for the organization field.
|
// Organization is the resolver for the organization field.
|
||||||
func (r *snapshotResolver) Organization(ctx context.Context, obj *types.Snapshot) (*types.Organization, error) {
|
func (r *snapshotResolver) Organization(ctx context.Context, obj *types.Snapshot) (*types.Organization, error) {
|
||||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||||
@@ -5520,6 +5848,8 @@ func (r *viewerResolver) Organizations(ctx context.Context, obj *types.Viewer, f
|
|||||||
panic(fmt.Errorf("failed to list organizations for user: %w", err))
|
panic(fmt.Errorf("failed to list organizations for user: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show all organizations the user is a member of
|
||||||
|
// Authentication requirements will be enforced when switching to an organization
|
||||||
page := page.NewPage(organizations, cursor)
|
page := page.NewPage(organizations, cursor)
|
||||||
|
|
||||||
return types.NewOrganizationConnection(page), nil
|
return types.NewOrganizationConnection(page), nil
|
||||||
@@ -5649,6 +5979,9 @@ func (r *Resolver) MeasureConnection() schema.MeasureConnectionResolver {
|
|||||||
return &measureConnectionResolver{r}
|
return &measureConnectionResolver{r}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Membership returns schema.MembershipResolver implementation.
|
||||||
|
func (r *Resolver) Membership() schema.MembershipResolver { return &membershipResolver{r} }
|
||||||
|
|
||||||
// MembershipConnection returns schema.MembershipConnectionResolver implementation.
|
// MembershipConnection returns schema.MembershipConnectionResolver implementation.
|
||||||
func (r *Resolver) MembershipConnection() schema.MembershipConnectionResolver {
|
func (r *Resolver) MembershipConnection() schema.MembershipConnectionResolver {
|
||||||
return &membershipConnectionResolver{r}
|
return &membershipConnectionResolver{r}
|
||||||
@@ -5703,6 +6036,11 @@ func (r *Resolver) Risk() schema.RiskResolver { return &riskResolver{r} }
|
|||||||
// RiskConnection returns schema.RiskConnectionResolver implementation.
|
// RiskConnection returns schema.RiskConnectionResolver implementation.
|
||||||
func (r *Resolver) RiskConnection() schema.RiskConnectionResolver { return &riskConnectionResolver{r} }
|
func (r *Resolver) RiskConnection() schema.RiskConnectionResolver { return &riskConnectionResolver{r} }
|
||||||
|
|
||||||
|
// SAMLConfiguration returns schema.SAMLConfigurationResolver implementation.
|
||||||
|
func (r *Resolver) SAMLConfiguration() schema.SAMLConfigurationResolver {
|
||||||
|
return &sAMLConfigurationResolver{r}
|
||||||
|
}
|
||||||
|
|
||||||
// Snapshot returns schema.SnapshotResolver implementation.
|
// Snapshot returns schema.SnapshotResolver implementation.
|
||||||
func (r *Resolver) Snapshot() schema.SnapshotResolver { return &snapshotResolver{r} }
|
func (r *Resolver) Snapshot() schema.SnapshotResolver { return &snapshotResolver{r} }
|
||||||
|
|
||||||
@@ -5818,6 +6156,7 @@ type invitationResolver struct{ *Resolver }
|
|||||||
type invitationConnectionResolver struct{ *Resolver }
|
type invitationConnectionResolver struct{ *Resolver }
|
||||||
type measureResolver struct{ *Resolver }
|
type measureResolver struct{ *Resolver }
|
||||||
type measureConnectionResolver struct{ *Resolver }
|
type measureConnectionResolver struct{ *Resolver }
|
||||||
|
type membershipResolver struct{ *Resolver }
|
||||||
type membershipConnectionResolver struct{ *Resolver }
|
type membershipConnectionResolver struct{ *Resolver }
|
||||||
type mutationResolver struct{ *Resolver }
|
type mutationResolver struct{ *Resolver }
|
||||||
type nonconformityResolver struct{ *Resolver }
|
type nonconformityResolver struct{ *Resolver }
|
||||||
@@ -5832,6 +6171,7 @@ type queryResolver struct{ *Resolver }
|
|||||||
type reportResolver struct{ *Resolver }
|
type reportResolver struct{ *Resolver }
|
||||||
type riskResolver struct{ *Resolver }
|
type riskResolver struct{ *Resolver }
|
||||||
type riskConnectionResolver struct{ *Resolver }
|
type riskConnectionResolver struct{ *Resolver }
|
||||||
|
type sAMLConfigurationResolver struct{ *Resolver }
|
||||||
type snapshotResolver struct{ *Resolver }
|
type snapshotResolver struct{ *Resolver }
|
||||||
type snapshotConnectionResolver struct{ *Resolver }
|
type snapshotConnectionResolver struct{ *Resolver }
|
||||||
type taskResolver struct{ *Resolver }
|
type taskResolver struct{ *Resolver }
|
||||||
|
|||||||
95
pkg/server/auth/accept_invitation_handler.go
Normal file
95
pkg/server/auth/accept_invitation_handler.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
|
"github.com/getprobo/probo/pkg/authz"
|
||||||
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
|
"github.com/getprobo/probo/pkg/server/session"
|
||||||
|
"go.gearno.de/kit/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
AcceptInvitationRequest struct {
|
||||||
|
InvitationID gid.GID `json:"invitationId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
AcceptInvitationResponse struct {
|
||||||
|
InvitationID gid.GID `json:"invitationId"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func AcceptInvitationHandler(authSvc *authsvc.Service, authzSvc *authz.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
sessionAuthCfg := session.AuthConfig{
|
||||||
|
CookieName: authCfg.CookieName,
|
||||||
|
CookieSecret: authCfg.CookieSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
errorHandler := session.ErrorHandler{
|
||||||
|
OnCookieError: func(err error) {
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
|
||||||
|
},
|
||||||
|
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||||
|
session.ClearCookie(w, authCfg)
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
|
||||||
|
},
|
||||||
|
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||||
|
session.ClearCookie(w, authCfg)
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
|
||||||
|
},
|
||||||
|
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||||
|
session.ClearCookie(w, authCfg)
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
|
||||||
|
},
|
||||||
|
OnTenantError: func(err error) {
|
||||||
|
panic(fmt.Errorf("failed to list tenants for user: %w", err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
|
||||||
|
if authResult == nil {
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
var req AcceptInvitationRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("invalid request body"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept the invitation
|
||||||
|
_, err := authzSvc.AcceptInvitationByID(ctx, req.InvitationID, authResult.User.ID)
|
||||||
|
if err != nil {
|
||||||
|
httpserver.RenderError(w, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := AcceptInvitationResponse{
|
||||||
|
InvitationID: req.InvitationID,
|
||||||
|
}
|
||||||
|
|
||||||
|
httpserver.RenderJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
72
pkg/server/auth/auth.go
Normal file
72
pkg/server/auth/auth.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
|
"github.com/getprobo/probo/pkg/authz"
|
||||||
|
"github.com/getprobo/probo/pkg/filemanager"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Auth *authsvc.Service
|
||||||
|
Authz *authz.Service
|
||||||
|
SAML *authsvc.SAMLService
|
||||||
|
CookieName string
|
||||||
|
CookieDomain string
|
||||||
|
SessionDuration time.Duration
|
||||||
|
CookieSecret string
|
||||||
|
FileManager *filemanager.Service
|
||||||
|
PGClient *pg.Client
|
||||||
|
Logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
router *chi.Mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(cfg Config) (*Server, error) {
|
||||||
|
router := chi.NewRouter()
|
||||||
|
|
||||||
|
MountRoutes(
|
||||||
|
router,
|
||||||
|
cfg.Auth,
|
||||||
|
cfg.Authz,
|
||||||
|
cfg.SAML,
|
||||||
|
RoutesConfig{
|
||||||
|
CookieName: cfg.CookieName,
|
||||||
|
CookieDomain: cfg.CookieDomain,
|
||||||
|
SessionDuration: cfg.SessionDuration,
|
||||||
|
CookieSecret: cfg.CookieSecret,
|
||||||
|
FileManager: cfg.FileManager,
|
||||||
|
PGClient: cfg.PGClient,
|
||||||
|
},
|
||||||
|
cfg.Logger,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
router: router,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.router.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
@@ -12,14 +12,14 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package console_v1
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/getprobo/probo/pkg/auth"
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
"go.gearno.de/kit/httpserver"
|
"go.gearno.de/kit/httpserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func ForgetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
func ForgetPasswordHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req ForgetPasswordRequest
|
var req ForgetPasswordRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
203
pkg/server/auth/list_invitations_handler.go
Normal file
203
pkg/server/auth/list_invitations_handler.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
|
"github.com/getprobo/probo/pkg/authz"
|
||||||
|
"github.com/getprobo/probo/pkg/coredata"
|
||||||
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
|
"github.com/getprobo/probo/pkg/page"
|
||||||
|
"github.com/getprobo/probo/pkg/server/session"
|
||||||
|
"go.gearno.de/kit/httpserver"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
ListInvitationsResponse struct {
|
||||||
|
Invitations []InvitationResponse `json:"invitations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
InvitationResponse struct {
|
||||||
|
ID gid.GID `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FullName string `json:"fullName"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
AcceptedAt *string `json:"acceptedAt,omitempty"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
Organization OrganizationSummary `json:"organization"`
|
||||||
|
}
|
||||||
|
|
||||||
|
OrganizationSummary struct {
|
||||||
|
ID gid.GID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadOrganizationByID loads an organization by ID without tenant scope
|
||||||
|
func loadOrganizationByID(
|
||||||
|
ctx context.Context,
|
||||||
|
conn pg.Conn,
|
||||||
|
orgID gid.GID,
|
||||||
|
) (*coredata.Organization, error) {
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
tenant_id,
|
||||||
|
name,
|
||||||
|
logo_file_id,
|
||||||
|
horizontal_logo_file_id,
|
||||||
|
description,
|
||||||
|
website_url,
|
||||||
|
email,
|
||||||
|
headquarter_address,
|
||||||
|
custom_domain_id,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM
|
||||||
|
authz_organizations
|
||||||
|
WHERE
|
||||||
|
id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
row := conn.QueryRow(ctx, query, orgID)
|
||||||
|
|
||||||
|
var org coredata.Organization
|
||||||
|
err := row.Scan(
|
||||||
|
&org.ID,
|
||||||
|
&org.TenantID,
|
||||||
|
&org.Name,
|
||||||
|
&org.LogoFileID,
|
||||||
|
&org.HorizontalLogoFileID,
|
||||||
|
&org.Description,
|
||||||
|
&org.WebsiteURL,
|
||||||
|
&org.Email,
|
||||||
|
&org.HeadquarterAddress,
|
||||||
|
&org.CustomDomainID,
|
||||||
|
&org.CreatedAt,
|
||||||
|
&org.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot load organization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &org, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListInvitationsHandler(authSvc *authsvc.Service, authzSvc *authz.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
sessionAuthCfg := session.AuthConfig{
|
||||||
|
CookieName: authCfg.CookieName,
|
||||||
|
CookieSecret: authCfg.CookieSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
errorHandler := session.ErrorHandler{
|
||||||
|
OnCookieError: func(err error) {
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
|
||||||
|
},
|
||||||
|
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||||
|
session.ClearCookie(w, authCfg)
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
|
||||||
|
},
|
||||||
|
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||||
|
session.ClearCookie(w, authCfg)
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
|
||||||
|
},
|
||||||
|
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||||
|
session.ClearCookie(w, authCfg)
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
|
||||||
|
},
|
||||||
|
OnTenantError: func(err error) {
|
||||||
|
panic(fmt.Errorf("failed to list tenants for user: %w", err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
|
||||||
|
if authResult == nil {
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending invitations for the user
|
||||||
|
cursor := page.NewCursor(
|
||||||
|
1000,
|
||||||
|
nil,
|
||||||
|
page.Head,
|
||||||
|
page.OrderBy[coredata.InvitationOrderField]{
|
||||||
|
Field: coredata.InvitationOrderFieldCreatedAt,
|
||||||
|
Direction: page.OrderDirectionDesc,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
invitationFilter := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
|
||||||
|
|
||||||
|
invitationsPage, err := authzSvc.GetUserInvitations(ctx, authResult.User.EmailAddress, cursor, invitationFilter)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("failed to list invitations for user: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
response := ListInvitationsResponse{
|
||||||
|
Invitations: make([]InvitationResponse, 0, len(invitationsPage.Data)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load organization data for each invitation
|
||||||
|
err = authCfg.PGClient.WithConn(ctx, func(conn pg.Conn) error {
|
||||||
|
for _, invitation := range invitationsPage.Data {
|
||||||
|
invitationResp := InvitationResponse{
|
||||||
|
ID: invitation.ID,
|
||||||
|
Email: invitation.Email,
|
||||||
|
FullName: invitation.FullName,
|
||||||
|
Role: invitation.Role,
|
||||||
|
ExpiresAt: invitation.ExpiresAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
CreatedAt: invitation.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if invitation.AcceptedAt != nil {
|
||||||
|
acceptedAtStr := invitation.AcceptedAt.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
invitationResp.AcceptedAt = &acceptedAtStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load organization details
|
||||||
|
org, err := loadOrganizationByID(ctx, conn, invitation.OrganizationID)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but continue - organization might have been deleted
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
invitationResp.Organization = OrganizationSummary{
|
||||||
|
ID: org.ID,
|
||||||
|
Name: org.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Invitations = append(response.Invitations, invitationResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("failed to load organization details: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
httpserver.RenderJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
201
pkg/server/auth/list_organizations_handler.go
Normal file
201
pkg/server/auth/list_organizations_handler.go
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
|
"github.com/getprobo/probo/pkg/authz"
|
||||||
|
"github.com/getprobo/probo/pkg/coredata"
|
||||||
|
"github.com/getprobo/probo/pkg/filemanager"
|
||||||
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
|
"github.com/getprobo/probo/pkg/server/session"
|
||||||
|
"go.gearno.de/kit/httpserver"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
AuthenticationStatus string
|
||||||
|
|
||||||
|
ListOrganizationsResponse struct {
|
||||||
|
Organizations []OrganizationResponse `json:"organizations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
OrganizationResponse struct {
|
||||||
|
ID gid.GID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
LogoURL *string `json:"logoUrl,omitempty"`
|
||||||
|
AuthenticationMethod string `json:"authenticationMethod"` // "password", "saml", or "any"
|
||||||
|
AuthStatus AuthenticationStatus `json:"authStatus"` // "authenticated", "unauthenticated", "expired"
|
||||||
|
LoginURL string `json:"loginUrl"` // URL to login (SAML or password login page)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuthStatusAuthenticated AuthenticationStatus = "authenticated"
|
||||||
|
AuthStatusUnauthenticated AuthenticationStatus = "unauthenticated"
|
||||||
|
AuthStatusExpired AuthenticationStatus = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateLogoURL generates a presigned URL for an organization's logo
|
||||||
|
func generateLogoURL(
|
||||||
|
ctx context.Context,
|
||||||
|
fileManager *filemanager.Service,
|
||||||
|
conn pg.Conn,
|
||||||
|
logoFileID *gid.GID,
|
||||||
|
) (*string, error) {
|
||||||
|
if logoFileID == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var file coredata.File
|
||||||
|
// Load file without scope since we're in auth context (cross-tenant)
|
||||||
|
q := `SELECT bucket_name, file_key, file_name, mime_type, file_size FROM files WHERE id = $1`
|
||||||
|
err := conn.QueryRow(ctx, q, logoFileID).Scan(
|
||||||
|
&file.BucketName,
|
||||||
|
&file.FileKey,
|
||||||
|
&file.FileName,
|
||||||
|
&file.MimeType,
|
||||||
|
&file.FileSize,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot load file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
presignedURL, err := fileManager.GenerateFileUrl(ctx, &file, 1*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot generate file URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &presignedURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListOrganizationsHandler(authSvc *authsvc.Service, authzSvc *authz.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
sessionAuthCfg := session.AuthConfig{
|
||||||
|
CookieName: authCfg.CookieName,
|
||||||
|
CookieSecret: authCfg.CookieSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
errorHandler := session.ErrorHandler{
|
||||||
|
OnCookieError: func(err error) {
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
|
||||||
|
},
|
||||||
|
OnParseError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||||
|
session.ClearCookie(w, authCfg)
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("invalid session"))
|
||||||
|
},
|
||||||
|
OnSessionError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||||
|
session.ClearCookie(w, authCfg)
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("session expired"))
|
||||||
|
},
|
||||||
|
OnUserError: func(w http.ResponseWriter, authCfg session.AuthConfig) {
|
||||||
|
session.ClearCookie(w, authCfg)
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("user not found"))
|
||||||
|
},
|
||||||
|
OnTenantError: func(err error) {
|
||||||
|
panic(fmt.Errorf("failed to list tenants for user: %w", err))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
|
||||||
|
if authResult == nil {
|
||||||
|
httpserver.RenderError(w, http.StatusUnauthorized, fmt.Errorf("authentication required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all organizations for the user (without filtering by authentication state)
|
||||||
|
organizations, err := authzSvc.GetAllUserOrganizations(ctx, authResult.User.ID)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("failed to list organizations for user: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response with authentication requirements for each organization
|
||||||
|
response := ListOrganizationsResponse{
|
||||||
|
Organizations: make([]OrganizationResponse, 0, len(organizations)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, org := range organizations {
|
||||||
|
orgResponse := OrganizationResponse{
|
||||||
|
ID: org.ID,
|
||||||
|
Name: org.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate logo URL if available
|
||||||
|
if authCfg.FileManager != nil && authCfg.PGClient != nil {
|
||||||
|
err := authCfg.PGClient.WithConn(ctx, func(conn pg.Conn) error {
|
||||||
|
logoURL, err := generateLogoURL(ctx, authCfg.FileManager, conn, org.LogoFileID)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but don't fail the request
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
orgResponse.LogoURL = logoURL
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// Log error but continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authentication requirements for this organization
|
||||||
|
err := authSvc.CheckOrganizationAccess(ctx, authResult.User, org.ID, authResult.Session)
|
||||||
|
if err != nil {
|
||||||
|
// User needs additional authentication
|
||||||
|
var errSAMLRequired authsvc.ErrSAMLAuthRequired
|
||||||
|
if errors.As(err, &errSAMLRequired) {
|
||||||
|
orgResponse.AuthenticationMethod = "saml"
|
||||||
|
orgResponse.AuthStatus = AuthStatusUnauthenticated
|
||||||
|
orgResponse.LoginURL = fmt.Sprintf("/auth/saml/login/%s", errSAMLRequired.ConfigID)
|
||||||
|
} else {
|
||||||
|
orgResponse.AuthenticationMethod = "password"
|
||||||
|
orgResponse.AuthStatus = AuthStatusUnauthenticated
|
||||||
|
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// User has proper authentication
|
||||||
|
orgResponse.AuthStatus = AuthStatusAuthenticated
|
||||||
|
|
||||||
|
// Determine which auth method they used
|
||||||
|
if authResult.Session.Data.PasswordAuthenticated {
|
||||||
|
orgResponse.AuthenticationMethod = "password"
|
||||||
|
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||||
|
} else if len(authResult.Session.Data.SAMLAuthenticatedOrgs) > 0 {
|
||||||
|
// Find SAML config for this org
|
||||||
|
orgResponse.AuthenticationMethod = "saml"
|
||||||
|
// Try to find the SAML config ID for login URL
|
||||||
|
if samlInfo, ok := authResult.Session.Data.SAMLAuthenticatedOrgs[org.ID.String()]; ok {
|
||||||
|
orgResponse.LoginURL = fmt.Sprintf("/auth/saml/login/%s", samlInfo.SAMLConfigID)
|
||||||
|
} else {
|
||||||
|
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
orgResponse.AuthenticationMethod = "any"
|
||||||
|
orgResponse.LoginURL = "/authentication/login?method=password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Organizations = append(response.Organizations, orgResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpserver.RenderJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package console_v1
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/getprobo/probo/pkg/auth"
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
"go.gearno.de/kit/httpserver"
|
"go.gearno.de/kit/httpserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func ResetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
func ResetPasswordHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req ResetPasswordRequest
|
var req ResetPasswordRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -46,8 +46,8 @@ func ResetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.Handle
|
|||||||
|
|
||||||
err := authSvc.ResetPassword(r.Context(), req.Token, req.Password)
|
err := authSvc.ResetPassword(r.Context(), req.Token, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var invalidPasswordErr *auth.ErrInvalidPassword
|
var invalidPasswordErr *authsvc.ErrInvalidPassword
|
||||||
var invalidTokenErr *auth.ErrInvalidTokenType
|
var invalidTokenErr *authsvc.ErrInvalidTokenType
|
||||||
|
|
||||||
if errors.As(err, &invalidPasswordErr) {
|
if errors.As(err, &invalidPasswordErr) {
|
||||||
httpserver.RenderError(w, http.StatusBadRequest, err)
|
httpserver.RenderError(w, http.StatusBadRequest, err)
|
||||||
60
pkg/server/auth/router.go
Normal file
60
pkg/server/auth/router.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
|
"github.com/getprobo/probo/pkg/authz"
|
||||||
|
"github.com/getprobo/probo/pkg/filemanager"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoutesConfig struct {
|
||||||
|
CookieName string
|
||||||
|
CookieDomain string
|
||||||
|
SessionDuration time.Duration
|
||||||
|
CookieSecret string
|
||||||
|
FileManager *filemanager.Service
|
||||||
|
PGClient *pg.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func MountRoutes(
|
||||||
|
r chi.Router,
|
||||||
|
authSvc *authsvc.Service,
|
||||||
|
authzSvc *authz.Service,
|
||||||
|
samlSvc *authsvc.SAMLService,
|
||||||
|
authCfg RoutesConfig,
|
||||||
|
logger *log.Logger,
|
||||||
|
) {
|
||||||
|
r.Post("/register", SignUpHandler(authSvc, authCfg))
|
||||||
|
r.Post("/login", SignInHandler(authSvc, authCfg))
|
||||||
|
r.Delete("/logout", SignOutHandler(authSvc, authCfg))
|
||||||
|
r.Post("/signup-from-invitation", SignupFromInvitationHandler(authSvc, authCfg))
|
||||||
|
r.Post("/forget-password", ForgetPasswordHandler(authSvc, authCfg))
|
||||||
|
r.Post("/reset-password", ResetPasswordHandler(authSvc, authCfg))
|
||||||
|
r.Post("/check-sso", SAMLCheckSSOHandler(authSvc, logger))
|
||||||
|
r.Get("/organizations", ListOrganizationsHandler(authSvc, authzSvc, authCfg))
|
||||||
|
r.Get("/invitations", ListInvitationsHandler(authSvc, authzSvc, authCfg))
|
||||||
|
r.Post("/invitations/accept", AcceptInvitationHandler(authSvc, authzSvc, authCfg))
|
||||||
|
|
||||||
|
// SAML routes
|
||||||
|
r.Get("/saml/login/{samlConfigID}", SAMLLoginHandler(samlSvc, authSvc, logger))
|
||||||
|
r.Post("/saml/consume", SAMLACSHandler(samlSvc, authSvc, authzSvc, authCfg, logger))
|
||||||
|
r.Get("/saml/metadata", SAMLMetadataHandler(samlSvc))
|
||||||
|
}
|
||||||
131
pkg/server/auth/saml_acs_handler.go
Normal file
131
pkg/server/auth/saml_acs_handler.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
|
"github.com/getprobo/probo/pkg/authz"
|
||||||
|
"github.com/getprobo/probo/pkg/coredata"
|
||||||
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
|
"github.com/getprobo/probo/pkg/securecookie"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getSessionIDFromCookie(r *http.Request, authCfg RoutesConfig) (gid.GID, error) {
|
||||||
|
cookieValue, err := securecookie.Get(r, securecookie.DefaultConfig(
|
||||||
|
authCfg.CookieName,
|
||||||
|
authCfg.CookieSecret,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
return gid.GID{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return gid.ParseGID(cookieValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SAMLACSHandler(samlSvc *authsvc.SAMLService, authSvc *authsvc.Service, authzSvc *authz.Service, authCfg RoutesConfig, logger *log.Logger) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "failed to parse form", log.Error(err))
|
||||||
|
http.Error(w, "failed to parse form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.FormValue("SAMLResponse") == "" {
|
||||||
|
logger.WarnCtx(ctx, "missing SAMLResponse")
|
||||||
|
http.Error(w, "missing SAMLResponse", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.FormValue("RelayState") == "" {
|
||||||
|
logger.WarnCtx(ctx, "missing RelayState")
|
||||||
|
http.Error(w, "missing RelayState", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo, err := samlSvc.HandleSAMLAssertion(ctx, r)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "SAML authentication failed", log.Error(err))
|
||||||
|
http.Error(w, "SAML authentication failed", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := authSvc.CreateOrGetSAMLUser(ctx, userInfo.Email, userInfo.FullName, userInfo.SAMLSubject)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "cannot create or get SAML user", log.Error(err), log.String("email", userInfo.Email))
|
||||||
|
http.Error(w, "failed to create user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = authzSvc.EnsureSAMLMembership(ctx, userInfo.TenantID, user.ID, userInfo.OrganizationID, userInfo.Role)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "cannot ensure membership", log.Error(err), log.String("user_id", user.ID.String()), log.String("org_id", userInfo.OrganizationID.String()))
|
||||||
|
http.Error(w, "failed to create membership", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var session *coredata.Session
|
||||||
|
if existingSessionID, err := getSessionIDFromCookie(r, authCfg); err == nil {
|
||||||
|
if existingSession, err := authSvc.GetSession(ctx, existingSessionID); err == nil && existingSession.UserID == user.ID {
|
||||||
|
session = existingSession
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if session == nil {
|
||||||
|
session, err = authSvc.CreateSessionForUser(ctx, user.ID, authCfg.SessionDuration)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "cannot create session", log.Error(err), log.String("user_id", user.ID.String()))
|
||||||
|
http.Error(w, "failed to create session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if session.Data.SAMLAuthenticatedOrgs == nil {
|
||||||
|
session.Data.SAMLAuthenticatedOrgs = make(map[string]coredata.SAMLAuthInfo)
|
||||||
|
}
|
||||||
|
session.Data.SAMLAuthenticatedOrgs[userInfo.OrganizationID.String()] = coredata.SAMLAuthInfo{
|
||||||
|
AuthenticatedAt: time.Now(),
|
||||||
|
SAMLConfigID: userInfo.SAMLConfigID,
|
||||||
|
SAMLSubject: userInfo.SAMLSubject,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = authSvc.UpdateSessionData(ctx, session.ID, session.Data)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "cannot update session data", log.Error(err), log.String("session_id", session.ID.String()))
|
||||||
|
http.Error(w, "failed to update session", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
securecookie.Set(
|
||||||
|
w,
|
||||||
|
securecookie.DefaultConfig(
|
||||||
|
authCfg.CookieName,
|
||||||
|
authCfg.CookieSecret,
|
||||||
|
),
|
||||||
|
session.ID.String(),
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.InfoCtx(ctx, "SAML login successful", log.String("user_id", user.ID.String()), log.String("org_id", userInfo.OrganizationID.String()))
|
||||||
|
|
||||||
|
redirectURL := fmt.Sprintf("/organizations/%s", userInfo.OrganizationID)
|
||||||
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
pkg/server/auth/saml_check_sso_handler.go
Normal file
90
pkg/server/auth/saml_check_sso_handler.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
|
"go.gearno.de/kit/httpserver"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
CheckSSORequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckSSOResponse struct {
|
||||||
|
SSOAvailable bool `json:"ssoAvailable"`
|
||||||
|
SAMLConfigID *string `json:"samlConfigId,omitempty"`
|
||||||
|
OrganizationID *string `json:"organizationId,omitempty"`
|
||||||
|
EnforcementPolicy *string `json:"enforcementPolicy,omitempty"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func SAMLCheckSSOHandler(authSvc *authsvc.Service, logger *log.Logger) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
var req CheckSSORequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Email == "" {
|
||||||
|
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("email is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, err := authSvc.CheckSSOAvailabilityByEmail(ctx, req.Email)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "cannot check SSO availability", log.Error(err), log.String("email", req.Email))
|
||||||
|
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("cannot check SSO availability"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No SAML configs found for this domain
|
||||||
|
if len(configs) == 0 {
|
||||||
|
httpserver.RenderJSON(w, http.StatusOK, CheckSSOResponse{
|
||||||
|
SSOAvailable: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple SAML configs found - ambiguous, user must use organization-specific SSO URL
|
||||||
|
if len(configs) > 1 {
|
||||||
|
logger.WarnCtx(ctx, "multiple SAML configurations found for domain", log.String("email", req.Email), log.Int("count", len(configs)))
|
||||||
|
httpserver.RenderError(w, http.StatusConflict, fmt.Errorf("multiple SSO configurations found for this domain. Please use your organization-specific SSO login URL"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single SAML config found - return it
|
||||||
|
config := configs[0]
|
||||||
|
configIDStr := config.ID.String()
|
||||||
|
orgIDStr := config.OrganizationID.String()
|
||||||
|
enforcementPolicy := string(config.EnforcementPolicy)
|
||||||
|
|
||||||
|
httpserver.RenderJSON(w, http.StatusOK, CheckSSOResponse{
|
||||||
|
SSOAvailable: true,
|
||||||
|
SAMLConfigID: &configIDStr,
|
||||||
|
OrganizationID: &orgIDStr,
|
||||||
|
EnforcementPolicy: &enforcementPolicy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
65
pkg/server/auth/saml_login_handler.go
Normal file
65
pkg/server/auth/saml_login_handler.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SAMLLoginHandler(samlSvc *authsvc.SAMLService, authSvc *authsvc.Service, logger *log.Logger) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
samlConfigIDStr := chi.URLParam(r, "samlConfigID")
|
||||||
|
if samlConfigIDStr == "" {
|
||||||
|
logger.WarnCtx(ctx, "missing SAML config ID in URL")
|
||||||
|
http.Error(w, "missing SAML config ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
samlConfigID, err := gid.ParseGID(samlConfigIDStr)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "invalid SAML config ID", log.Error(err), log.String("saml_config_id", samlConfigIDStr))
|
||||||
|
http.Error(w, "invalid SAML config ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID := samlConfigID.TenantID()
|
||||||
|
|
||||||
|
config, err := authSvc.WithTenant(tenantID).GetSAMLConfigurationByID(ctx, samlConfigID)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "cannot load SAML configuration", log.Error(err), log.String("saml_config_id", samlConfigID.String()))
|
||||||
|
http.Error(w, "SAML configuration not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURL, err := samlSvc.InitiateSAMLLogin(ctx, config.OrganizationID, tenantID, config.EmailDomain)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCtx(ctx, "cannot initiate SAML login", log.Error(err), log.String("saml_config_id", samlConfigID.String()), log.String("org_id", config.OrganizationID.String()), log.String("email_domain", config.EmailDomain))
|
||||||
|
http.Error(w, fmt.Sprintf("SAML login failed: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.InfoCtx(ctx, "SAML login initiated", log.String("saml_config_id", samlConfigID.String()), log.String("org_id", config.OrganizationID.String()), log.String("email_domain", config.EmailDomain))
|
||||||
|
|
||||||
|
http.Redirect(w, r, redirectURL, http.StatusFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
38
pkg/server/auth/saml_metadata_handler.go
Normal file
38
pkg/server/auth/saml_metadata_handler.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SAMLMetadataHandler returns an HTTP handler that serves the SAML Service Provider metadata XML
|
||||||
|
// Uses global SP certificate configured at service startup
|
||||||
|
func SAMLMetadataHandler(samlSvc *authsvc.SAMLService) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
metadataXML, err := samlSvc.GenerateMetadata()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to generate metadata: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/samlmetadata+xml")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write(metadataXML)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package console_v1
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -21,9 +21,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
|
"github.com/getprobo/probo/pkg/coredata"
|
||||||
"github.com/getprobo/probo/pkg/gid"
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
"github.com/getprobo/probo/pkg/securecookie"
|
"github.com/getprobo/probo/pkg/securecookie"
|
||||||
"github.com/getprobo/probo/pkg/auth"
|
|
||||||
"go.gearno.de/kit/httpserver"
|
"go.gearno.de/kit/httpserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func SignInHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
func SignInHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
var req SignInRequest
|
var req SignInRequest
|
||||||
@@ -55,9 +56,16 @@ func SignInHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session, user, err := authSvc.SignIn(r.Context(), req.Email, req.Password)
|
var existingSession *coredata.Session
|
||||||
|
if existingSessionID, err := getSessionIDFromCookie(r, authCfg); err == nil {
|
||||||
|
if session, err := authSvc.GetSession(r.Context(), existingSessionID); err == nil {
|
||||||
|
existingSession = session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session, user, err := authSvc.SignInWithExistingSession(r.Context(), req.Email, req.Password, existingSession)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var ErrInvalidCredentials *auth.ErrInvalidCredentials
|
var ErrInvalidCredentials *authsvc.ErrInvalidCredentials
|
||||||
if errors.As(err, &ErrInvalidCredentials) {
|
if errors.As(err, &ErrInvalidCredentials) {
|
||||||
httpserver.RenderError(w, http.StatusUnauthorized, err)
|
httpserver.RenderError(w, http.StatusUnauthorized, err)
|
||||||
return
|
return
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package console_v1
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -20,11 +20,11 @@ import (
|
|||||||
|
|
||||||
"github.com/getprobo/probo/pkg/gid"
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
"github.com/getprobo/probo/pkg/securecookie"
|
"github.com/getprobo/probo/pkg/securecookie"
|
||||||
"github.com/getprobo/probo/pkg/auth"
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
"go.gearno.de/kit/httpserver"
|
"go.gearno.de/kit/httpserver"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SignOutHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
func SignOutHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
sessionID, err := securecookie.Get(r, securecookie.DefaultConfig(
|
sessionID, err := securecookie.Get(r, securecookie.DefaultConfig(
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package console_v1
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/getprobo/probo/pkg/auth"
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
"github.com/getprobo/probo/pkg/securecookie"
|
"github.com/getprobo/probo/pkg/securecookie"
|
||||||
"go.gearno.de/kit/httpserver"
|
"go.gearno.de/kit/httpserver"
|
||||||
)
|
)
|
||||||
@@ -37,7 +37,7 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func SignUpHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
func SignUpHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req SignUpRequest
|
var req SignUpRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -52,13 +52,13 @@ func SignUpHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
|||||||
req.FullName,
|
req.FullName,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var errUserAlreadyExists *auth.ErrUserAlreadyExists
|
var errUserAlreadyExists *authsvc.ErrUserAlreadyExists
|
||||||
if errors.As(err, &errUserAlreadyExists) {
|
if errors.As(err, &errUserAlreadyExists) {
|
||||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
|
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var errSignupDisabled *auth.ErrSignupDisabled
|
var errSignupDisabled *authsvc.ErrSignupDisabled
|
||||||
if errors.As(err, &errSignupDisabled) {
|
if errors.As(err, &errSignupDisabled) {
|
||||||
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
|
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
|
||||||
return
|
return
|
||||||
@@ -12,14 +12,14 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package console_v1
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/getprobo/probo/pkg/auth"
|
authsvc "github.com/getprobo/probo/pkg/auth"
|
||||||
"github.com/getprobo/probo/pkg/securecookie"
|
"github.com/getprobo/probo/pkg/securecookie"
|
||||||
"go.gearno.de/kit/httpserver"
|
"go.gearno.de/kit/httpserver"
|
||||||
)
|
)
|
||||||
@@ -35,7 +35,7 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func SignupFromInvitationHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
|
func SignupFromInvitationHandler(authSvc *authsvc.Service, authCfg RoutesConfig) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req SignupFromInvitationRequest
|
var req SignupFromInvitationRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -19,13 +19,53 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
|
||||||
|
"github.com/getprobo/probo/pkg/auth"
|
||||||
|
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||||
"go.gearno.de/kit/httpserver"
|
"go.gearno.de/kit/httpserver"
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RecoverFunc(ctx context.Context, err any) error {
|
func RecoverFunc(ctx context.Context, err any) error {
|
||||||
|
if gqlErr, ok := err.(*gqlerror.Error); ok {
|
||||||
|
return gqlErr
|
||||||
|
}
|
||||||
|
|
||||||
|
var errSAMLRequired auth.ErrSAMLAuthRequired
|
||||||
|
if errors.As(asError(err), &errSAMLRequired) {
|
||||||
|
return &gqlerror.Error{
|
||||||
|
Message: "Additional authentication required to access this organization",
|
||||||
|
Extensions: map[string]any{
|
||||||
|
"code": "AUTHENTICATION_REQUIRED",
|
||||||
|
"requiresSaml": true,
|
||||||
|
"redirectUrl": errSAMLRequired.RedirectURL,
|
||||||
|
"samlConfigId": errSAMLRequired.ConfigID.String(),
|
||||||
|
"organizationId": errSAMLRequired.OrganizationID.String(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var errPasswordRequired auth.ErrPasswordAuthRequired
|
||||||
|
if errors.As(asError(err), &errPasswordRequired) {
|
||||||
|
return &gqlerror.Error{
|
||||||
|
Message: "Additional authentication required to access this organization",
|
||||||
|
Extensions: map[string]any{
|
||||||
|
"code": "AUTHENTICATION_REQUIRED",
|
||||||
|
"requiresSaml": false,
|
||||||
|
"redirectUrl": errPasswordRequired.RedirectURL,
|
||||||
|
"organizationId": errPasswordRequired.OrganizationID.String(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger := httpserver.LoggerFromContext(ctx)
|
logger := httpserver.LoggerFromContext(ctx)
|
||||||
logger.Error("resolver panic", log.Any("error", err), log.Any("stack", string(debug.Stack())))
|
logger.Error("resolver panic", log.Any("error", err), log.Any("stack", string(debug.Stack())))
|
||||||
|
|
||||||
return errors.New("internal server error")
|
return errors.New("internal server error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func asError(err any) error {
|
||||||
|
if e, ok := err.(error); ok {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return errors.New("unknown panic")
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ import (
|
|||||||
"github.com/getprobo/probo/pkg/auth"
|
"github.com/getprobo/probo/pkg/auth"
|
||||||
"github.com/getprobo/probo/pkg/authz"
|
"github.com/getprobo/probo/pkg/authz"
|
||||||
"github.com/getprobo/probo/pkg/connector"
|
"github.com/getprobo/probo/pkg/connector"
|
||||||
|
"github.com/getprobo/probo/pkg/filemanager"
|
||||||
"github.com/getprobo/probo/pkg/gid"
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
"github.com/getprobo/probo/pkg/probo"
|
"github.com/getprobo/probo/pkg/probo"
|
||||||
"github.com/getprobo/probo/pkg/saferedirect"
|
"github.com/getprobo/probo/pkg/saferedirect"
|
||||||
"github.com/getprobo/probo/pkg/server/api"
|
"github.com/getprobo/probo/pkg/server/api"
|
||||||
|
auth_server "github.com/getprobo/probo/pkg/server/auth"
|
||||||
trust_v1 "github.com/getprobo/probo/pkg/server/api/trust/v1"
|
trust_v1 "github.com/getprobo/probo/pkg/server/api/trust/v1"
|
||||||
"github.com/getprobo/probo/pkg/server/trust"
|
"github.com/getprobo/probo/pkg/server/trust"
|
||||||
"github.com/getprobo/probo/pkg/server/web"
|
"github.com/getprobo/probo/pkg/server/web"
|
||||||
@@ -35,6 +37,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"go.gearno.de/kit/httpserver"
|
"go.gearno.de/kit/httpserver"
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -44,12 +47,15 @@ type Config struct {
|
|||||||
Auth *auth.Service
|
Auth *auth.Service
|
||||||
Authz *authz.Service
|
Authz *authz.Service
|
||||||
Trust *trust_pkg.Service
|
Trust *trust_pkg.Service
|
||||||
|
SAML *auth.SAMLService
|
||||||
ConsoleAuth api.ConsoleAuthConfig
|
ConsoleAuth api.ConsoleAuthConfig
|
||||||
TrustAuth api.TrustAuthConfig
|
TrustAuth api.TrustAuthConfig
|
||||||
ConnectorRegistry *connector.ConnectorRegistry
|
ConnectorRegistry *connector.ConnectorRegistry
|
||||||
Agent *agents.Agent
|
Agent *agents.Agent
|
||||||
SafeRedirect *saferedirect.SafeRedirect
|
SafeRedirect *saferedirect.SafeRedirect
|
||||||
CustomDomainCname string
|
CustomDomainCname string
|
||||||
|
FileManager *filemanager.Service
|
||||||
|
PGClient *pg.Client
|
||||||
Logger *log.Logger
|
Logger *log.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +63,7 @@ type Server struct {
|
|||||||
apiServer *api.Server
|
apiServer *api.Server
|
||||||
webServer *web.Server
|
webServer *web.Server
|
||||||
trustServer *trust.Server
|
trustServer *trust.Server
|
||||||
|
authServer *auth_server.Server
|
||||||
router *chi.Mux
|
router *chi.Mux
|
||||||
extraHeaderFields map[string]string
|
extraHeaderFields map[string]string
|
||||||
proboService *probo.Service
|
proboService *probo.Service
|
||||||
@@ -70,6 +77,7 @@ func NewServer(cfg Config) (*Server, error) {
|
|||||||
Auth: cfg.Auth,
|
Auth: cfg.Auth,
|
||||||
Authz: cfg.Authz,
|
Authz: cfg.Authz,
|
||||||
Trust: cfg.Trust,
|
Trust: cfg.Trust,
|
||||||
|
SAML: cfg.SAML,
|
||||||
ConsoleAuth: cfg.ConsoleAuth,
|
ConsoleAuth: cfg.ConsoleAuth,
|
||||||
TrustAuth: cfg.TrustAuth,
|
TrustAuth: cfg.TrustAuth,
|
||||||
ConnectorRegistry: cfg.ConnectorRegistry,
|
ConnectorRegistry: cfg.ConnectorRegistry,
|
||||||
@@ -92,12 +100,29 @@ func NewServer(cfg Config) (*Server, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authServer, err := auth_server.NewServer(auth_server.Config{
|
||||||
|
Auth: cfg.Auth,
|
||||||
|
Authz: cfg.Authz,
|
||||||
|
SAML: cfg.SAML,
|
||||||
|
CookieName: cfg.ConsoleAuth.CookieName,
|
||||||
|
CookieDomain: cfg.ConsoleAuth.CookieDomain,
|
||||||
|
SessionDuration: cfg.ConsoleAuth.SessionDuration,
|
||||||
|
CookieSecret: cfg.ConsoleAuth.CookieSecret,
|
||||||
|
FileManager: cfg.FileManager,
|
||||||
|
PGClient: cfg.PGClient,
|
||||||
|
Logger: cfg.Logger.Named("auth"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
|
|
||||||
server := &Server{
|
server := &Server{
|
||||||
apiServer: apiServer,
|
apiServer: apiServer,
|
||||||
webServer: webServer,
|
webServer: webServer,
|
||||||
trustServer: trustServer,
|
trustServer: trustServer,
|
||||||
|
authServer: authServer,
|
||||||
router: router,
|
router: router,
|
||||||
extraHeaderFields: cfg.ExtraHeaderFields,
|
extraHeaderFields: cfg.ExtraHeaderFields,
|
||||||
proboService: cfg.Probo,
|
proboService: cfg.Probo,
|
||||||
@@ -111,6 +136,7 @@ func NewServer(cfg Config) (*Server, error) {
|
|||||||
|
|
||||||
func (s *Server) setupRoutes() {
|
func (s *Server) setupRoutes() {
|
||||||
s.router.Mount("/api", s.apiServer)
|
s.router.Mount("/api", s.apiServer)
|
||||||
|
s.router.Mount("/auth", s.authServer)
|
||||||
|
|
||||||
s.router.Route("/trust/{slugOrId}", func(r chi.Router) {
|
s.router.Route("/trust/{slugOrId}", func(r chi.Router) {
|
||||||
r.Use(s.loadTrustCenterBySlugOrID)
|
r.Use(s.loadTrustCenterBySlugOrID)
|
||||||
|
|||||||
@@ -32,9 +32,10 @@ type AuthConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthResult struct {
|
type AuthResult struct {
|
||||||
Session *coredata.Session
|
Session *coredata.Session
|
||||||
User *coredata.User
|
User *coredata.User
|
||||||
TenantIDs []gid.TenantID
|
TenantIDs []gid.TenantID
|
||||||
|
AuthErrors map[gid.TenantID]error // Maps tenant ID to authentication error
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrorHandler struct {
|
type ErrorHandler struct {
|
||||||
@@ -97,15 +98,28 @@ func TryAuth(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tenantIDs := make([]gid.TenantID, len(organizations))
|
// Validate organization access based on authentication requirements
|
||||||
for i, org := range organizations {
|
// Only include organizations the user has proper authentication for
|
||||||
tenantIDs[i] = org.ID.TenantID()
|
allowedTenantIDs := make([]gid.TenantID, 0, len(organizations))
|
||||||
|
authErrors := make(map[gid.TenantID]error)
|
||||||
|
|
||||||
|
for _, org := range organizations {
|
||||||
|
// Check if user has the required authentication for this organization
|
||||||
|
err := authSvc.CheckOrganizationAccess(ctx, user, org.ID, session)
|
||||||
|
if err == nil {
|
||||||
|
// User has proper authentication for this org
|
||||||
|
allowedTenantIDs = append(allowedTenantIDs, org.ID.TenantID())
|
||||||
|
} else {
|
||||||
|
// Store the authentication error for later use
|
||||||
|
authErrors[org.ID.TenantID()] = err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AuthResult{
|
return &AuthResult{
|
||||||
Session: session,
|
Session: session,
|
||||||
User: user,
|
User: user,
|
||||||
TenantIDs: tenantIDs,
|
TenantIDs: allowedTenantIDs,
|
||||||
|
AuthErrors: authErrors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user