@@ -25,6 +25,8 @@ import {
2525 invalidateListAPIKeys ,
2626 useListAPIKeysSuspense ,
2727} from "@gram/client/react-query/listAPIKeys" ;
28+ import { useDeleteDomainMutation } from "@gram/client/react-query/deleteDomain" ;
29+ import { invalidateAllGetDomain } from "@gram/client/react-query/getDomain" ;
2830import { useRegisterDomainMutation } from "@gram/client/react-query/registerDomain" ;
2931import { useRevokeAPIKeyMutation } from "@gram/client/react-query/revokeAPIKey" ;
3032import { Button , Column , Icon , Stack , Table } from "@speakeasy-api/moonshine" ;
@@ -36,6 +38,7 @@ import {
3638 Globe ,
3739 Loader2 ,
3840 ShieldAlert ,
41+ Trash2 ,
3942 X ,
4043} from "lucide-react" ;
4144import { useEffect , useState } from "react" ;
@@ -57,12 +60,19 @@ export default function Settings() {
5760 const [ isCnameCopied , setIsCnameCopied ] = useState ( false ) ;
5861 const [ isTxtCopied , setIsTxtCopied ] = useState ( false ) ;
5962 const [ isCustomDomainModalOpen , setIsCustomDomainModalOpen ] = useState ( false ) ;
63+ const [ isDeleteDomainDialogOpen , setIsDeleteDomainDialogOpen ] =
64+ useState ( false ) ;
6065 const [ domainInput , setDomainInput ] = useState ( "" ) ;
6166 const [ domainError , setDomainError ] = useState ( "" ) ;
6267 const CNAME_VALUE = getCustomDomainCNAME ( ) ;
6368
64- // Dynamic values based on domain input
65- const subdomain = domainInput . trim ( ) || "sub.yourdomain.com" ;
69+ // Domain validation regex (same as used in the backend)
70+ const domainRegex = / ^ [ a - z 0 - 9 ] ( [ a - z 0 - 9 - ] { 0 , 61 } [ a - z 0 - 9 ] ) ? (?: \. [ a - z ] { 2 , } ) + $ / i;
71+
72+ // Only show real values once a valid domain is entered
73+ const validDomain =
74+ domainInput . trim ( ) && domainRegex . test ( domainInput . trim ( ) ) ;
75+ const subdomain = validDomain ? domainInput . trim ( ) : "sub.yourdomain.com" ;
6676 const txtName = `_gram.${ subdomain } ` ;
6777 const txtValue = `gram-domain-verify=${ subdomain } ,${ organization . id } ` ;
6878
@@ -80,9 +90,6 @@ export default function Settings() {
8090 }
8191 } , [ domain ?. domain , domainInput ] ) ;
8292
83- // Domain validation regex (same as used in the backend)
84- const domainRegex = / ^ [ a - z 0 - 9 ] ( [ a - z 0 - 9 - ] { 0 , 61 } [ a - z 0 - 9 ] ) ? (?: \. [ a - z ] { 2 , } ) + $ / i;
85-
8693 const validateDomain = ( domain : string ) : string => {
8794 if ( ! domain . trim ( ) ) {
8895 return "Domain is required" ;
@@ -139,6 +146,14 @@ export default function Settings() {
139146 } ,
140147 } ) ;
141148
149+ const deleteDomainMutation = useDeleteDomainMutation ( {
150+ onSuccess : async ( ) => {
151+ setIsDeleteDomainDialogOpen ( false ) ;
152+ setDomainInput ( "" ) ;
153+ await invalidateAllGetDomain ( queryClient ) ;
154+ } ,
155+ } ) ;
156+
142157 const handleCreateKey : React . FormEventHandler < HTMLFormElement > = ( e ) => {
143158 e . preventDefault ( ) ;
144159 const formEl = e . currentTarget ;
@@ -454,116 +469,141 @@ export default function Settings() {
454469 </ Dialog . Content >
455470 </ Dialog >
456471
457- < Stack
458- direction = "horizontal"
459- justify = "space-between"
460- align = "center"
461- className = "mt-8"
462- >
463- < Heading variant = "h4" > Custom Domains</ Heading >
464- { session . gramAccountType === "free" && (
465- < Type className = "text-muted-foreground" >
466- Contact gram support to get access to custom domains for your
467- account.
468- </ Type >
469- ) }
470- { ! domainIsLoading && ! domain ?. verified && (
471- < Button
472- onClick = { ( ) => {
473- if ( session . gramAccountType === "free" ) {
474- setIsCustomDomainModalOpen ( true ) ;
475- } else {
476- setIsAddDomainDialogOpen ( true ) ;
477- }
478- } }
479- disabled = { domain ?. isUpdating }
480- >
481- { domain ?. domain ? "Verify Domain" : "Add Domain" }
482- </ Button >
483- ) }
484- </ Stack >
485- < Table
486- data = { domain ?. domain ? [ domain ] : [ ] }
487- rowKey = { ( row ) => row . id }
488- className = "min-h-fit"
489- noResultsMessage = {
490- < Stack
491- gap = { 2 }
492- className = "h-full p-4 bg-background"
493- align = "center"
494- justify = "center"
495- >
496- < Type variant = "body" > No custom domains yet</ Type >
497- < Button
498- size = "sm"
499- variant = "secondary"
500- onClick = { ( ) => {
501- if ( session . gramAccountType === "free" ) {
502- setIsCustomDomainModalOpen ( true ) ;
503- } else {
504- setIsAddDomainDialogOpen ( true ) ;
505- }
506- } }
507- disabled = { domain ?. isUpdating }
508- >
509- < Button . LeftIcon >
510- < Icon name = "globe" className = "h-4 w-4" />
511- </ Button . LeftIcon >
512- < Button . Text > Add Domain</ Button . Text >
513- </ Button >
514- </ Stack >
515- }
516- columns = { [
517- {
518- key : "domain" ,
519- header : "Domain" ,
520- width : "1fr" ,
521- render : ( row ) => < Type variant = "body" > { row . domain } </ Type > ,
522- } ,
523- {
524- key : "createdAt" ,
525- header : "Date Linked" ,
526- width : "1fr" ,
527- render : ( row ) => (
528- < Type variant = "body" >
529- < HumanizeDateTime date = { row . createdAt } />
530- </ Type >
531- ) ,
532- } ,
533- {
534- key : "verified" ,
535- header : "Verified" ,
536- width : "120px" ,
537- render : ( row ) => (
538- < span className = "flex justify-center items-center" >
539- { row . isUpdating ? (
540- < SimpleTooltip tooltip = "Your domain is being verified. Please refresh the page in a minute or two." >
541- < Loader2 className = "w-5 h-5 animate-spin text-blue-500" />
472+ < Heading variant = "h4" className = "mt-8" >
473+ Custom Domain
474+ </ Heading >
475+ { domain ?. domain ? (
476+ < div className = "rounded-lg border border-border bg-card p-4" >
477+ < Stack direction = "horizontal" justify = "space-between" align = "start" >
478+ < Stack gap = { 1 } >
479+ < Stack direction = "horizontal" align = "center" gap = { 2 } >
480+ < Globe className = "h-4 w-4 text-muted-foreground" />
481+ < Type variant = "body" className = "font-mono font-medium" >
482+ { domain . domain }
483+ </ Type >
484+ { domain . isUpdating ? (
485+ < SimpleTooltip tooltip = "Your domain is being verified. This may take a few minutes." >
486+ < Loader2 className = "w-4 h-4 animate-spin text-blue-500" />
487+ </ SimpleTooltip >
488+ ) : domain . verified ? (
489+ < SimpleTooltip tooltip = "Domain verified and active" >
490+ < Check className = "w-4 h-4 stroke-3 text-green-500" />
542491 </ SimpleTooltip >
543- ) : row . verified ? (
544- < Check
545- className = { cn ( "w-5 h-5 stroke-3" , "text-green-500" ) }
546- />
547492 ) : (
548- < SimpleTooltip tooltip = "Domain verification failed, please ensure your DNS records have been setup correctly" >
549- < X className = "w-5 h-5 stroke-3 text-red-500" />
493+ < SimpleTooltip tooltip = "Domain verification failed. Ensure your DNS records are set up correctly. " >
494+ < X className = "w-4 h-4 stroke-3 text-red-500" />
550495 </ SimpleTooltip >
551496 ) }
552- </ span >
553- ) ,
554- } ,
555- ] }
556- />
497+ </ Stack >
498+ < Type
499+ variant = "body"
500+ className = "text-muted-foreground text-sm ml-6"
501+ >
502+ Linked < HumanizeDateTime date = { domain . createdAt } />
503+ </ Type >
504+ </ Stack >
505+ < Stack direction = "horizontal" gap = { 2 } >
506+ { ! domain . verified && (
507+ < Button
508+ variant = "secondary"
509+ size = "sm"
510+ onClick = { ( ) => setIsAddDomainDialogOpen ( true ) }
511+ disabled = { domain . isUpdating }
512+ >
513+ Reverify
514+ </ Button >
515+ ) }
516+ < Button
517+ variant = "tertiary"
518+ size = "sm"
519+ onClick = { ( ) => setIsDeleteDomainDialogOpen ( true ) }
520+ className = "hover:text-destructive"
521+ disabled = { deleteDomainMutation . isPending }
522+ >
523+ < Trash2 className = "h-4 w-4" />
524+ </ Button >
525+ </ Stack >
526+ </ Stack >
527+ </ div >
528+ ) : (
529+ ! domainIsLoading && (
530+ < div className = "rounded-lg border border-dashed border-border p-6" >
531+ < Stack gap = { 2 } align = "center" justify = "center" >
532+ < Type variant = "body" className = "text-muted-foreground" >
533+ No custom domain configured
534+ </ Type >
535+ < Type variant = "body" className = "text-muted-foreground text-sm" >
536+ You can connect one custom domain per organization for your
537+ MCP servers.
538+ </ Type >
539+ < Button
540+ size = "sm"
541+ variant = "secondary"
542+ className = "mt-2"
543+ onClick = { ( ) => {
544+ if ( session . gramAccountType === "free" ) {
545+ setIsCustomDomainModalOpen ( true ) ;
546+ } else {
547+ setIsAddDomainDialogOpen ( true ) ;
548+ }
549+ } }
550+ >
551+ < Button . LeftIcon >
552+ < Globe className = "h-4 w-4" />
553+ </ Button . LeftIcon >
554+ < Button . Text > Add Domain</ Button . Text >
555+ </ Button >
556+ </ Stack >
557+ </ div >
558+ )
559+ ) }
560+
561+ < Dialog
562+ open = { isDeleteDomainDialogOpen }
563+ onOpenChange = { setIsDeleteDomainDialogOpen }
564+ >
565+ < Dialog . Content >
566+ < Dialog . Header >
567+ < Dialog . Title > Remove Custom Domain</ Dialog . Title >
568+ </ Dialog . Header >
569+ < div className = "space-y-4 py-4" >
570+ < Type variant = "body" >
571+ Are you sure you want to remove{ " " }
572+ < span className = "italic font-bold" > { domain ?. domain } </ span > ? This
573+ will delete the associated ingress and TLS certificate.
574+ </ Type >
575+ < div className = "flex justify-end space-x-2" >
576+ < Button
577+ variant = "secondary"
578+ onClick = { ( ) => setIsDeleteDomainDialogOpen ( false ) }
579+ >
580+ Cancel
581+ </ Button >
582+ < Button
583+ variant = "destructive-primary"
584+ onClick = { ( ) =>
585+ deleteDomainMutation . mutate ( {
586+ security : { sessionHeaderGramSession : "" } ,
587+ } )
588+ }
589+ disabled = { deleteDomainMutation . isPending }
590+ >
591+ { deleteDomainMutation . isPending ? "Removing..." : "Remove" }
592+ </ Button >
593+ </ div >
594+ </ div >
595+ </ Dialog . Content >
596+ </ Dialog >
557597
558598 < Dialog
559599 open = { isAddDomainDialogOpen }
560600 onOpenChange = { setIsAddDomainDialogOpen }
561601 >
562- < Dialog . Content >
602+ < Dialog . Content className = "max-w-lg" >
563603 < Dialog . Header >
564604 < Dialog . Title > Connect a Custom Domain</ Dialog . Title >
565605 </ Dialog . Header >
566- < div className = "space-y-6 py-4" >
606+ < div className = "space-y-6 py-4 min-h-[420px] " >
567607 < div >
568608 < Type
569609 variant = "body"
@@ -602,8 +642,8 @@ export default function Settings() {
602642 </ Type >
603643 < Type variant = "body" className = "text-muted-foreground mb-2" >
604644 Create a CNAME record for{ " " }
605- < span className = "font-mono" > { subdomain } </ span > pointing to the
606- following:
645+ < span className = "font-mono break-all " > { subdomain } </ span > { " " }
646+ pointing to the following:
607647 </ Type >
608648 < div className = "flex items-center space-x-2 bg-muted p-3 rounded-md mt-2" >
609649 < code className = "flex-1 break-all" > { CNAME_VALUE } </ code >
@@ -630,8 +670,8 @@ export default function Settings() {
630670 </ Type >
631671 < Type variant = "body" className = "text-muted-foreground mb-2" >
632672 Create a TXT record at{ " " }
633- < span className = "font-mono" > { txtName } </ span > with the
634- following value:
673+ < span className = "font-mono break-all " > { txtName } </ span > with
674+ the following value:
635675 </ Type >
636676 < div className = "flex items-center space-x-2 bg-muted p-3 rounded-md mt-2" >
637677 < code className = "flex-1 break-all" > { txtValue } </ code >
@@ -661,7 +701,7 @@ export default function Settings() {
661701 { registerDomainMutation . isPending
662702 ? "Registering..."
663703 : domain ?. domain
664- ? "Verify "
704+ ? "Reverify "
665705 : "Register" }
666706 </ Button >
667707 </ div >
0 commit comments