Skip to content

Commit 81ba6bb

Browse files
adaam2claude
andauthored
feat: implement custom domain removal (#1489)
## Summary - Implement the `DeleteDomain` API endpoint (previously a no-op stub) with K8s ingress cleanup via Temporal workflow - Redesign the custom domain settings UI from a table to a single card, making it clear only one domain is supported per org - Add `mise db:reset` task for fully resetting the local database - Build elements before starting the dashboard dev server Depends on #1488 (migration PR) ## Test plan - [ ] Delete a custom domain via the settings page — domain card disappears - [ ] Re-register the same domain after deletion — works without conflict - [ ] Verify the confirmation dialog appears before deletion - [ ] Verify DNS record placeholders only populate after entering a valid domain 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/speakeasy-api/gram/pull/1489"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 1f74200 commit 81ba6bb

File tree

7 files changed

+272
-127
lines changed

7 files changed

+272
-127
lines changed

.mise-tasks/db/reset.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env bash
2+
#MISE dir="{{ config_root }}/server"
3+
#MISE description="Drop and recreate the database schema, then re-run all migrations"
4+
5+
set -e
6+
7+
echo "Dropping and recreating public schema..."
8+
9+
psql "${GRAM_DATABASE_URL//&search_path=public/}" \
10+
-c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
11+
12+
echo "Schema reset. Running migrations..."
13+
14+
mise run db:migrate

.mise-tasks/start/dashboard.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,7 @@
33

44
set -e
55

6+
# Elements is a dependency of the dashboard and must be built first
7+
pnpm --filter ./elements build
8+
69
exec pnpm --filter ./client/dashboard dev

client/dashboard/src/pages/settings/Settings.tsx

Lines changed: 148 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
2830
import { useRegisterDomainMutation } from "@gram/client/react-query/registerDomain";
2931
import { useRevokeAPIKeyMutation } from "@gram/client/react-query/revokeAPIKey";
3032
import { 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";
4144
import { 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-z0-9]([a-z0-9-]{0,61}[a-z0-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-z0-9]([a-z0-9-]{0,61}[a-z0-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

Comments
 (0)