diff --git a/apps/console/src/components/customDomains/CustomDomainManager.tsx b/apps/console/src/components/customDomains/CustomDomainManager.tsx new file mode 100644 index 000000000..396c971a3 --- /dev/null +++ b/apps/console/src/components/customDomains/CustomDomainManager.tsx @@ -0,0 +1,501 @@ +import { useState } from "react"; +import { useTranslate } from "@probo/i18n"; +import { graphql, useLazyLoadQuery, useMutation } from "react-relay"; +import { useOrganizationId } from "/hooks/useOrganizationId"; +import { + Button, + Card, + Badge, + Field, + Dialog, + DialogContent, + DialogFooter, + useDialogRef, + useToast, +} from "@probo/ui"; +import type { CustomDomainManagerQuery } from "./__generated__/CustomDomainManagerQuery.graphql"; +import type { CustomDomainManagerCreateMutation } from "./__generated__/CustomDomainManagerCreateMutation.graphql"; +import type { CustomDomainManagerDeleteMutation } from "./__generated__/CustomDomainManagerDeleteMutation.graphql"; + +const customDomainsQuery = graphql` + query CustomDomainManagerQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + ... on Organization { + id + customDomains(first: 100) { + edges { + node { + id + domain + sslStatus + isActive + dnsRecords { + type + name + value + ttl + purpose + } + createdAt + updatedAt + verifiedAt + sslExpiresAt + } + } + } + } + } + } +`; + +const createCustomDomainMutation = graphql` + mutation CustomDomainManagerCreateMutation($input: CreateCustomDomainInput!) { + createCustomDomain(input: $input) { + customDomainEdge { + node { + id + domain + sslStatus + isActive + dnsRecords { + type + name + value + ttl + purpose + } + createdAt + updatedAt + verifiedAt + sslExpiresAt + } + } + } + } +`; + +const deleteCustomDomainMutation = graphql` + mutation CustomDomainManagerDeleteMutation($input: DeleteCustomDomainInput!) { + deleteCustomDomain(input: $input) { + deletedCustomDomainId + } + } +`; + +function DomainDetailsDialog({ + domain, + children, + onDelete, + isDeletingDomain, +}: { + domain: any; + children: React.ReactNode; + onDelete: (domainId: string, domainName: string) => void; + isDeletingDomain: boolean; +}) { + const { __ } = useTranslate(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + + const getStatusBadge = (domain: any) => { + if (domain.sslStatus === "ACTIVE") { + return {__("Active")}; + } + if (domain.sslStatus === "PROVISIONING" || domain.sslStatus === "RENEWING") { + return {__("Provisioning")}; + } + if (domain.sslStatus === "PENDING") { + return {__("Pending")}; + } + if (domain.sslStatus === "FAILED") { + return {__("Failed")}; + } + if (domain.sslStatus === "EXPIRED") { + return {__("Expired")}; + } + return {__("Unknown")}; + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast({ + title: __("Copied"), + description: __("Value copied to clipboard"), + variant: "success", + }); + }; + + return ( + + {domain.domain} + {getStatusBadge(domain)} + + } + > + + {domain.verifiedAt && ( +

+ {__("Verified")} {new Date(domain.verifiedAt).toLocaleDateString()} +

+ )} + + {domain.sslStatus === "ACTIVE" ? ( +
+
+ + + +
+

{__("Domain is active")}

+

+ {__("Your custom domain is verified and SSL certificate is active")} +

+ {domain.sslExpiresAt && ( +

+ {__("SSL expires")} {new Date(domain.sslExpiresAt).toLocaleDateString()} +

+ )} +
+
+
+ ) : ( +
+

{__("DNS Configuration")}

+

+ {__("Add these DNS records to your domain to complete verification")} +

+ +
+ {domain.dnsRecords?.map((record: any, index: number) => ( +
+
+ {record.type} + {record.purpose} +
+
+
+ +
+ + {record.name} + + +
+
+
+ +
+ + {record.value} + + +
+
+ {record.ttl && ( +
+ TTL: {record.ttl} +
+ )} +
+
+ ))} +
+ + {domain.sslStatus === "PENDING" && ( +
+

+ {__("After adding the DNS records, verification will happen automatically. This may take a few minutes to propagate.")} +

+
+ )} +
+ )} + +
+ +
+
+
+ ); +} + +export function CustomDomainManager() { + const { __ } = useTranslate(); + const { toast } = useToast(); + const organizationId = useOrganizationId(); + const dialogRef = useDialogRef(); + + const data = useLazyLoadQuery( + customDomainsQuery, + { organizationId }, + { fetchPolicy: "network-only" } + ); + + const [createCustomDomain, isCreatingDomain] = + useMutation(createCustomDomainMutation); + const [deleteCustomDomain, isDeletingDomain] = + useMutation(deleteCustomDomainMutation); + + const [newDomain, setNewDomain] = useState(""); + + const domains = + data.organization?.customDomains?.edges?.map((edge) => edge.node) || []; + + const validateDomain = (domainInput: string): boolean => { + const domainRegex = + /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/i; + return domainRegex.test(domainInput); + }; + + const handleAddDomain = () => { + const trimmedDomain = newDomain + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); + + if (!validateDomain(trimmedDomain)) { + toast({ + title: __("Invalid Domain"), + description: __( + "Please enter a valid domain (e.g., compliance.example.com)" + ), + variant: "error", + }); + return; + } + + createCustomDomain({ + variables: { + input: { + organizationId, + domain: trimmedDomain, + }, + }, + onCompleted: () => { + setNewDomain(""); + dialogRef.current?.close(); + + toast({ + title: __("Domain Added Successfully"), + description: __( + "Configure the DNS records below to verify and activate your domain" + ), + variant: "success", + }); + }, + onError: (error) => { + toast({ + title: __("Failed to Add Domain"), + description: error.message, + variant: "error", + }); + }, + }); + }; + + const handleDeleteDomain = (domainId: string, domainName: string) => { + if (!confirm(`${__("Are you sure you want to delete")} ${domainName}?`)) { + return; + } + + deleteCustomDomain({ + variables: { + input: { domainId }, + }, + onCompleted: () => { + toast({ + title: __("Domain Deleted"), + description: __("The domain has been removed"), + variant: "success", + }); + }, + onError: (error) => { + toast({ + title: __("Failed to Delete Domain"), + description: error.message, + variant: "error", + }); + }, + }); + }; + + const getStatusBadge = (domain: any) => { + if (domain.sslStatus === "ACTIVE") { + return {__("Active")}; + } + if (domain.sslStatus === "PROVISIONING" || domain.sslStatus === "RENEWING") { + return {__("Provisioning")}; + } + if (domain.sslStatus === "PENDING") { + return {__("Pending")}; + } + if (domain.sslStatus === "FAILED") { + return {__("Failed")}; + } + if (domain.sslStatus === "EXPIRED") { + return {__("Expired")}; + } + return {__("Unknown")}; + }; + + return ( +
+
+
+

{__("Custom Domains")}

+

+ {__("Use your own domain for your trust center")} +

+
+ +
+ + {domains.length === 0 ? ( + +
+
+ + + +
+

+ {__("No custom domains yet")} +

+

+ {__( + "Add your own domain to make your trust center more professional" + )} +

+ +
+
+ ) : ( + +
+ {domains.map((domain: any) => ( + +
+
+
+
{domain.domain}
+ {getStatusBadge(domain)} +
+ + + +
+
+
+ ))} +
+
+ )} + + { + setNewDomain(""); + }} + > + +
+
+

+ {__("Add Custom Domain")} +

+

+ {__( + "Enter your domain and we'll generate the DNS records you need to add" + )} +

+
+ + ) => + setNewDomain(e.target.value) + } + placeholder="compliance.example.com" + help={__("Enter without http:// or https://")} + autoFocus + /> + +
+

+ {__("Examples:")} compliance.example.com, + trust.example.com +

+
+
+
+ + + +
+
+ ); +} diff --git a/apps/console/src/components/customDomains/__generated__/CustomDomainManagerCreateMutation.graphql.ts b/apps/console/src/components/customDomains/__generated__/CustomDomainManagerCreateMutation.graphql.ts new file mode 100644 index 000000000..d17d69a5d --- /dev/null +++ b/apps/console/src/components/customDomains/__generated__/CustomDomainManagerCreateMutation.graphql.ts @@ -0,0 +1,229 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type SSLStatus = "ACTIVE" | "EXPIRED" | "EXPIRED" | "FAILED" | "PENDING" | "PROVISIONING" | "RENEWING"; +export type CreateCustomDomainInput = { + domain: string; + organizationId: string; +}; +export type CustomDomainManagerCreateMutation$variables = { + input: CreateCustomDomainInput; +}; +export type CustomDomainManagerCreateMutation$data = { + readonly createCustomDomain: { + readonly customDomainEdge: { + readonly node: { + 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 isActive: boolean; + readonly sslExpiresAt: any | null | undefined; + readonly sslStatus: SSLStatus; + readonly updatedAt: any; + readonly verifiedAt: any | null | undefined; + }; + }; + }; +}; +export type CustomDomainManagerCreateMutation = { + response: CustomDomainManagerCreateMutation$data; + variables: CustomDomainManagerCreateMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "CreateCustomDomainPayload", + "kind": "LinkedField", + "name": "createCustomDomain", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "CustomDomainEdge", + "kind": "LinkedField", + "name": "customDomainEdge", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "CustomDomain", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "domain", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "sslStatus", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "isActive", + "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": "verifiedAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "sslExpiresAt", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "CustomDomainManagerCreateMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "CustomDomainManagerCreateMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "f10f04ab871fe2b42cc29c5789dc103a", + "id": null, + "metadata": {}, + "name": "CustomDomainManagerCreateMutation", + "operationKind": "mutation", + "text": "mutation CustomDomainManagerCreateMutation(\n $input: CreateCustomDomainInput!\n) {\n createCustomDomain(input: $input) {\n customDomainEdge {\n node {\n id\n domain\n sslStatus\n isActive\n dnsRecords {\n type\n name\n value\n ttl\n purpose\n }\n createdAt\n updatedAt\n verifiedAt\n sslExpiresAt\n }\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "f2c706c80df1d0a9fa2e7ea4faa75fb3"; + +export default node; diff --git a/apps/console/src/components/customDomains/__generated__/CustomDomainManagerDeleteMutation.graphql.ts b/apps/console/src/components/customDomains/__generated__/CustomDomainManagerDeleteMutation.graphql.ts new file mode 100644 index 000000000..44ff64ccf --- /dev/null +++ b/apps/console/src/components/customDomains/__generated__/CustomDomainManagerDeleteMutation.graphql.ts @@ -0,0 +1,92 @@ +/** + * @generated SignedSource<<869106a8eb759c253f8578a596b748a0>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type DeleteCustomDomainInput = { + domainId: string; +}; +export type CustomDomainManagerDeleteMutation$variables = { + input: DeleteCustomDomainInput; +}; +export type CustomDomainManagerDeleteMutation$data = { + readonly deleteCustomDomain: { + readonly deletedCustomDomainId: string; + }; +}; +export type CustomDomainManagerDeleteMutation = { + response: CustomDomainManagerDeleteMutation$data; + variables: CustomDomainManagerDeleteMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "DeleteCustomDomainPayload", + "kind": "LinkedField", + "name": "deleteCustomDomain", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "deletedCustomDomainId", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "CustomDomainManagerDeleteMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "CustomDomainManagerDeleteMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "9f4a3ed02de61bc60a8370e3771ca7bd", + "id": null, + "metadata": {}, + "name": "CustomDomainManagerDeleteMutation", + "operationKind": "mutation", + "text": "mutation CustomDomainManagerDeleteMutation(\n $input: DeleteCustomDomainInput!\n) {\n deleteCustomDomain(input: $input) {\n deletedCustomDomainId\n }\n}\n" + } +}; +})(); + +(node as any).hash = "e3878d11e361c3da2471363664150f3d"; + +export default node; diff --git a/apps/console/src/components/customDomains/__generated__/CustomDomainManagerQuery.graphql.ts b/apps/console/src/components/customDomains/__generated__/CustomDomainManagerQuery.graphql.ts new file mode 100644 index 000000000..a161057fb --- /dev/null +++ b/apps/console/src/components/customDomains/__generated__/CustomDomainManagerQuery.graphql.ts @@ -0,0 +1,283 @@ +/** + * @generated SignedSource<<04bd8d8635434cd05ea80edbb804b56f>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type SSLStatus = "ACTIVE" | "EXPIRED" | "EXPIRED" | "FAILED" | "PENDING" | "PROVISIONING" | "RENEWING"; +export type CustomDomainManagerQuery$variables = { + organizationId: string; +}; +export type CustomDomainManagerQuery$data = { + readonly organization: { + readonly customDomains?: { + readonly edges: ReadonlyArray<{ + readonly node: { + 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 isActive: boolean; + readonly sslExpiresAt: any | null | undefined; + readonly sslStatus: SSLStatus; + readonly updatedAt: any; + readonly verifiedAt: any | null | undefined; + }; + }>; + }; + readonly id?: string; + }; +}; +export type CustomDomainManagerQuery = { + response: CustomDomainManagerQuery$data; + variables: CustomDomainManagerQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "organizationId" + } +], +v1 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "organizationId" + } +], +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v3 = { + "alias": null, + "args": [ + { + "kind": "Literal", + "name": "first", + "value": 100 + } + ], + "concreteType": "CustomDomainConnection", + "kind": "LinkedField", + "name": "customDomains", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "CustomDomainEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "CustomDomain", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v2/*: 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, + "kind": "ScalarField", + "name": "isActive", + "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": "verifiedAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "sslExpiresAt", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": "customDomains(first:100)" +}; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "CustomDomainManagerQuery", + "selections": [ + { + "alias": "organization", + "args": (v1/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "kind": "InlineFragment", + "selections": [ + (v2/*: any*/), + (v3/*: any*/) + ], + "type": "Organization", + "abstractKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "CustomDomainManagerQuery", + "selections": [ + { + "alias": "organization", + "args": (v1/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + }, + (v2/*: any*/), + { + "kind": "InlineFragment", + "selections": [ + (v3/*: any*/) + ], + "type": "Organization", + "abstractKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "c561676e49338e212721ed12e1069dd0", + "id": null, + "metadata": {}, + "name": "CustomDomainManagerQuery", + "operationKind": "query", + "text": "query CustomDomainManagerQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n customDomains(first: 100) {\n edges {\n node {\n id\n domain\n sslStatus\n isActive\n dnsRecords {\n type\n name\n value\n ttl\n purpose\n }\n createdAt\n updatedAt\n verifiedAt\n sslExpiresAt\n }\n }\n }\n }\n id\n }\n}\n" + } +}; +})(); + +(node as any).hash = "0aea6f600572fac571dbc0e1e6a3117c"; + +export default node; diff --git a/apps/console/src/pages/organizations/SettingsPage.tsx b/apps/console/src/pages/organizations/SettingsPage.tsx index 1bc6e59a1..1aa2c7a4a 100644 --- a/apps/console/src/pages/organizations/SettingsPage.tsx +++ b/apps/console/src/pages/organizations/SettingsPage.tsx @@ -37,6 +37,7 @@ import { InviteUserDialog } from "/components/organizations/InviteUserDialog"; import { useDeleteOrganizationMutation } from "/hooks/graph/OrganizationGraph"; import { useNavigate } from "react-router"; import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog"; +import { CustomDomainManager } from "/components/customDomains/CustomDomainManager"; const organizationSchema = z.object({ name: z.string().min(1, "Organization name is required"), @@ -321,6 +322,12 @@ export default function SettingsPage({ queryRef }: Props) { + {/* Custom Domains */} +
+

{__("Custom Domains")}

+ +
+

{__("Danger Zone")}