386 lines · 14.3 KB · TypeScript
RawDownload
import { useState } from "react";import { createFileRoute, Link } from "@tanstack/react-router";import { Exit } from "effect";import { useAtomValue, useAtomSet } from "@effect/atom-react";import { trackEvent } from "@executor-js/react/api/analytics";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import { useCustomer } from "autumn-js/react";import { toast } from "sonner";import { orgDomainWriteKeys, authWriteKeys } from "@executor-js/react/api/reactivity-keys";import { Button } from "@executor-js/react/components/button";import { Badge } from "@executor-js/react/components/badge";import { Input } from "@executor-js/react/components/input";import { Label } from "@executor-js/react/components/label";import { CopyButton } from "@executor-js/react/components/copy-button";import {  Dialog,  DialogContent,  DialogHeader,  DialogTitle,  DialogDescription,  DialogFooter,  DialogClose,} from "@executor-js/react/components/dialog";import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuItem,  DropdownMenuTrigger,} from "@executor-js/react/components/dropdown-menu";import { orgMembersAtom } from "@executor-js/react/api/account-atoms";import { OrgPage as SharedOrgPage } from "@executor-js/react/pages/org";import { orgDomainsAtom, getDomainVerificationLink, deleteDomain } from "../../web/org-atoms";import { deleteOrganization, useAuth } from "../../web/auth"; // ---------------------------------------------------------------------------// Cloud organization page. The members / roles / invite / org-name surface is// the SHARED `@executor-js/react` OrgPage over the provider-neutral// `/account/*` atoms — identical to self-host. Cloud composes its WorkOS-only// extras AROUND that page://   - a seat/billing banner (Autumn member-limit upsell)//   - the WorkOS domain-verification section (over the surviving cloud-local//     `/org/domains` endpoints)// These are cloud additions, not a fork of the shared page.// --------------------------------------------------------------------------- export const Route = createFileRoute("/{-$orgSlug}/org")({  component: OrgPage,}); type DomainData = {  id: string;  domain: string;  state: string;  verificationToken?: string;  verificationPrefix?: string;}; function OrgPage() {  return (    <div className="min-h-0 flex-1 overflow-y-auto">      {/* Shared members / roles / invite / org-name surface. */}      <SharedOrgPage        domainsSection={<DomainsSection />}        upgradeAction={          <Link to="/{-$orgSlug}/billing/plans">            <Button size="sm">Upgrade plan</Button>          </Link>        }        dangerZoneSection={<DangerZoneSection />}      />    </div>  );} // Destructive org teardown, admin-only. Hidden entirely for non-admins (the// backend enforces admin + name-confirmation regardless). Deleting the org// removes the workspace and all of its data for every member, cancels billing,// and logs the caller out.function DangerZoneSection() {  const auth = useAuth();  const membersResult = useAtomValue(orgMembersAtom);  const doDelete = useAtomSet(deleteOrganization, { mode: "promiseExit" });  const [open, setOpen] = useState(false);  const [confirmText, setConfirmText] = useState("");  const [deleting, setDeleting] = useState(false);   const organizationName = auth.status === "authenticated" ? auth.organization?.name : undefined;   // Only admins may delete. Derive the caller's role from the members list  // (already loaded for this page); render nothing while it loads or for  // members, so a delete control never flashes for someone who can't use it.  const isAdmin = AsyncResult.match(membersResult, {    onInitial: () => false,    onFailure: () => false,    onSuccess: ({ value }) =>      value.members.some((m) => m.isCurrentUser && m.status === "active" && m.role === "admin"),  });   if (!isAdmin || !organizationName) return null;   const confirmed = confirmText.trim() === organizationName.trim();   const handleDelete = async () => {    if (!confirmed || deleting) return;    setDeleting(true);    const exit = await doDelete({      payload: { confirmName: confirmText.trim() },      reactivityKeys: authWriteKeys,    });    trackEvent("org_deleted", { success: Exit.isSuccess(exit) });    if (Exit.isSuccess(exit)) {      // The org (and the caller's session) are gone. A full navigation resets      // all app state and lets the auth gate rehydrate to another membership or      // the create-org screen.      toast.success(`Deleted ${organizationName}`);      window.location.href = "/";      return;    }    setDeleting(false);    toast.error("Failed to delete organization");  };   return (    <section className="mb-2 border-t border-border pt-8">      <div className="flex items-center justify-between rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">        <div className="min-w-0">          <h2 className="text-sm font-medium text-destructive">Delete organization</h2>          <p className="mt-0.5 text-sm text-muted-foreground">            Permanently delete this organization and all of its data for every member. This cannot            be undone.          </p>        </div>        <Button          size="sm"          variant="destructive"          className="ml-4 shrink-0"          onClick={() => {            setConfirmText("");            setOpen(true);          }}        >          Delete        </Button>      </div>       <Dialog        open={open}        onOpenChange={(v) => {          if (deleting) return;          if (!v) setConfirmText("");          setOpen(v);        }}      >        <DialogContent className="sm:max-w-[440px]">          <DialogHeader>            <DialogTitle className="font-display text-xl">Delete organization</DialogTitle>            <DialogDescription className="text-sm leading-relaxed">              This permanently deletes{" "}              <span className="font-medium text-foreground">{organizationName}</span>, including              every integration, connection, credential, and policy, for all members. Billing is              canceled and everyone loses access immediately. This cannot be undone.            </DialogDescription>          </DialogHeader>           <div className="grid gap-1.5 py-1">            <Label              htmlFor="delete-org-confirm"              className="text-sm font-medium uppercase tracking-wider text-muted-foreground"            >              Type <span className="font-mono normal-case text-foreground">{organizationName}</span>{" "}              to confirm            </Label>            <Input              id="delete-org-confirm"              autoComplete="off"              value={confirmText}              onChange={(e) => setConfirmText((e.target as HTMLInputElement).value)}              onKeyDown={(e) => {                if (e.key === "Enter") handleDelete();              }}              className="h-9 text-sm"            />          </div>           <DialogFooter>            <DialogClose asChild>              <Button variant="ghost" size="sm" disabled={deleting}>                Cancel              </Button>            </DialogClose>            <Button              size="sm"              variant="destructive"              onClick={handleDelete}              disabled={!confirmed || deleting}            >              {deleting ? "Deleting…" : "Delete organization"}            </Button>          </DialogFooter>        </DialogContent>      </Dialog>    </section>  );} function DomainsSection() {  const domainsResult = useAtomValue(orgDomainsAtom);  const doDeleteDomain = useAtomSet(deleteDomain, { mode: "promiseExit" });  const doGetVerificationLink = useAtomSet(getDomainVerificationLink, {    mode: "promiseExit",  });  const { check, isLoading: customerLoading } = useCustomer();  const canUseDomains = customerLoading    ? false    : check({ featureId: "domain-verification" }).allowed;   const handleDeleteDomain = async (domainId: string, domain: string) => {    const exit = await doDeleteDomain({      params: { domainId },      reactivityKeys: orgDomainWriteKeys,    });    trackEvent("org_domain_removed", { success: Exit.isSuccess(exit) });    toast[Exit.isSuccess(exit) ? "success" : "error"](      Exit.isSuccess(exit) ? `Removed ${domain}` : "Failed to remove domain",    );  };   const handleAddDomain = async () => {    const exit = await doGetVerificationLink({      reactivityKeys: orgDomainWriteKeys,    });    trackEvent("org_domain_added", { success: Exit.isSuccess(exit) });    if (Exit.isSuccess(exit)) {      window.open(exit.value.link, "_blank");    } else {      toast.error("Failed to generate verification link");    }  };   return (    <section className="mb-2">      <div className="flex items-center justify-between mb-4">        <div>          <h2 className="text-sm font-medium text-foreground">Domains</h2>          <p className="mt-0.5 text-sm text-muted-foreground">            Verify a domain to let anyone with a matching email join automatically.          </p>        </div>        <Button size="sm" className="min-w-32" disabled={!canUseDomains} onClick={handleAddDomain}>          Add domain        </Button>      </div>       {!canUseDomains && (        <div className="mb-3 flex items-center justify-between rounded-lg border border-border px-4 py-3">          <p className="text-sm text-muted-foreground">            Join by domain is available on the Team plan.          </p>          <Link to="/{-$orgSlug}/billing/plans">            <Button size="sm" variant="outline">              Upgrade            </Button>          </Link>        </div>      )}       {AsyncResult.match(domainsResult, {        onInitial: () => (          <div className="space-y-3">            {[1, 2].map((i) => (              <div key={i} className="h-16 animate-pulse rounded-lg bg-muted/50" />            ))}          </div>        ),        onFailure: () => (          <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">            <p className="text-sm text-destructive">Failed to load domains</p>          </div>        ),        onSuccess: ({ value }) => {          if (value.domains.length === 0) {            if (!canUseDomains) return null;            return (              <p className="py-6 text-center text-sm text-muted-foreground">                No domains yet. Add your company domain so members can join without an invite.              </p>            );          }           return (            <div className="space-y-2">              {value.domains.map((d: DomainData) => (                <DomainCard                  key={d.id}                  domain={d}                  onDelete={() => handleDeleteDomain(d.id, d.domain)}                />              ))}            </div>          );        },      })}    </section>  );} function DomainCard({ domain: d, onDelete }: { domain: DomainData; onDelete: () => void }) {  const isVerified = d.state === "verified";  const isPending = d.state === "pending";   const recordValue = d.verificationPrefix    ? `${d.verificationPrefix}=${d.verificationToken}`    : (d.verificationToken ?? "");   const copyPromptValue = `Add a DNS TXT record for domain verification:\n\nDomain: ${d.domain}\nRecord name: @\nRecord value: ${recordValue}\n\nPlease add this TXT record to my DNS configuration.`;   return (    <div className="rounded-lg border border-border">      <div className="flex items-center gap-3 px-4 py-3">        <div className="min-w-0 flex-1">          <div className="flex items-center gap-2">            <p className="truncate text-sm font-medium text-foreground">{d.domain}</p>            <Badge              className={                isVerified                  ? "bg-muted text-foreground"                  : isPending                    ? "bg-muted text-muted-foreground"                    : "bg-destructive/10 text-destructive"              }            >              {isVerified ? "Verified" : isPending ? "Pending" : "Failed"}            </Badge>          </div>        </div>        <div className="flex shrink-0 items-center gap-1.5">          <DropdownMenu>            <DropdownMenuTrigger asChild>              <Button variant="ghost" size="icon" className="size-7">                <svg viewBox="0 0 16 16" className="size-3">                  <circle cx="8" cy="3" r="1.2" fill="currentColor" />                  <circle cx="8" cy="8" r="1.2" fill="currentColor" />                  <circle cx="8" cy="13" r="1.2" fill="currentColor" />                </svg>              </Button>            </DropdownMenuTrigger>            <DropdownMenuContent align="end" className="w-44">              <DropdownMenuItem                className="text-destructive focus:text-destructive text-sm"                onClick={onDelete}              >                Remove domain              </DropdownMenuItem>            </DropdownMenuContent>          </DropdownMenu>        </div>      </div>       {!isVerified && d.verificationToken && (        <div className="border-t border-border px-4 py-3">          <div className="flex items-center justify-between">            <p className="text-sm text-muted-foreground">              Add this TXT record to your DNS provider to verify ownership.            </p>            <CopyButton value={copyPromptValue} label="Copy prompt" />          </div>          <div className="mt-3 grid grid-cols-[4rem_3.5rem_1fr] items-center gap-y-2">            <p className="text-xs font-medium text-muted-foreground">Type</p>            <p className="text-xs font-medium text-muted-foreground">Name</p>            <p className="text-xs font-medium text-muted-foreground">Value</p>            <p className="text-sm font-mono text-foreground">TXT</p>            <p className="text-sm font-mono text-foreground">@</p>            <span className="inline-flex min-w-0 items-center gap-1">              <code className="truncate text-sm font-mono text-foreground">{recordValue}</code>              <CopyButton value={recordValue} />            </span>          </div>          <p className="mt-3 text-xs text-muted-foreground">            DNS changes can take up to 72 hours to propagate, but usually complete within a few            minutes.          </p>        </div>      )}    </div>  );}