import type { OpenGeniBrowserClient } from "@opengeni/sdk/browser"; import type { SiteAbuseReport, SiteHostnameAvailability, SitePublication, SitePublishWarning, SitePublishWarningFix, WorkspaceArtifact, } from "@opengeni/sdk"; import { CheckIcon, CopyIcon, ExternalLinkIcon, FlagIcon, GlobeIcon, LoaderCircleIcon, PlusIcon, RotateCcwIcon, UsersIcon, XIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { toast } from "../../lib/toast"; import { ModelPicker } from "../pickers"; import { payerShortLabel } from "../models/models-ui"; import { Button, buttonVariants } from "../../../ui/button"; import { ChoiceCard, ChoiceCards } from "../../../ui/choice-cards"; import { useCopyToClipboard } from "../../../ui/copy-field"; import { showUndoToast } from "../ui/destructive-confirm"; import { DropdownMenuItem } from "../../../ui/dropdown-menu"; import { EmailChipsInput } from "../../../ui/email-chips-input"; import { Disclosure } from "../../../ui/disclosure"; import { CheckboxField, Field, FieldStack, TextArea, TextInput } from "../../../ui/field"; import { FormDialog, FormInline, FormPage, type FormFrameProps } from "../../../ui/form-dialog"; import { ListRow, RowList } from "../../../ui/list-row"; import { LogoTile, type LogoTileSize } from "../../../ui/logo-tile"; import { Notice } from "../../../ui/notice"; import { RelativeTime } from "../../../ui/relative-time"; import { Section } from "../../../ui/section"; import { SelectMenu } from "../../../ui/select-menu"; import { SettingRow } from "../../../ui/setting-row"; import { StatusBadge } from "../../../ui/status-badge"; import { Switch } from "../../../ui/switch"; import { userErrorText } from "../../../ui/api-error"; import { ACCESS_CHOICES, SPEND_PERIOD_OPTIONS, abuseCategoryLabel, availabilityMessage, domainFromHostname, draftFromPublication, hasOutsideViewers, invitedEmailProblem, labelProblemMessage, normalizeLabelInput, outsideLimitsFromDraft, outsideUsageLine, platformRefusal, publicationRequest, readinessTitle, sentence, siteErrorCode, sitePublicationLoadFromError, suggestedLabel, suspendedByLabel, viewAsVisitorUrl, type SitePublicationLoad, type SitePublishDraft, type SitesClientConfig, type SpendPeriod, } from "../../lib/site-publishing"; import { useWorkspaceModelCatalog } from "../../lib/use-workspace-model-catalog"; import { cn } from "../../lib/utils"; export type SitePublishingClient = Pick< OpenGeniBrowserClient, | "getSitePublication" | "updateSitePublication" | "unpublishSite" | "checkSiteHostname" | "takeDownSite" | "restoreSite" | "listSiteAbuseReports" | "updateSiteAbuseReport" | "applySitePublicationFix" >; /** * The Site's publication as its page needs it. Only people who manage the * Site ask; everyone else sees no publishing UI. A deployment without a * Sites domain answers 503, which hides the entry point. */ export function useSitePublication({ client, workspaceId, siteId, enabled, }: { client: Pick; workspaceId: string; siteId: string; enabled: boolean; }) { const [load, setLoad] = useState({ state: "loading" }); const abortRef = useRef(null); const reload = useCallback(async () => { abortRef.current?.abort(); if (!enabled) return; const abort = new AbortController(); abortRef.current = abort; try { const publication = await client.getSitePublication(workspaceId, siteId, { signal: abort.signal, }); if (!abort.signal.aborted) setLoad({ state: "ready", publication }); } catch (error) { if (!abort.signal.aborted) setLoad(sitePublicationLoadFromError(error)); } }, [client, enabled, siteId, workspaceId]); useEffect(() => { setLoad({ state: "loading" }); void reload(); return () => abortRef.current?.abort(); }, [reload]); const setPublication = useCallback( (publication: SitePublication) => setLoad({ state: "ready", publication }), [], ); return { load, reload, setPublication }; } function livePublication(load: SitePublicationLoad): SitePublication | null { return load.state === "ready" && load.publication.status === "active" ? load.publication : null; } /** * The header's Publish button, while the Site isn't on the web yet. Hidden * where the deployment can't publish, and once it is published or taken * down: its address and settings are then in the Site's Settings tab. */ export function SitePublishButton({ load, onOpen, }: { load: SitePublicationLoad; onOpen: () => void; }) { if (load.state === "unavailable" || load.state === "forbidden") return null; if (load.state === "ready" && load.publication.status !== "unpublished") return null; return ( ); } /** The live address in the Site header's meta line, or the takedown badge. */ export function sitePublicationHeader(load: SitePublicationLoad): { meta: ReactNode; chip: ReactNode; } { const live = livePublication(load); const suspended = load.state === "ready" && load.publication.status === "suspended"; return { meta: live ? ( {live.hostname} ) : null, chip: suspended ? ( Taken down ) : null, }; } type Availability = | { state: "idle" } | { state: "checking"; label: string } | { state: "done"; label: string; result: SiteHostnameAvailability } | { state: "failed"; label: string }; /** Ask the API whether a label is free, 350ms after typing stops. */ function useHostnameAvailability( client: Pick, workspaceId: string, label: string, skip: boolean, ): Availability { const [availability, setAvailability] = useState({ state: "idle" }); useEffect(() => { if (skip || labelProblemMessage(label)) { setAvailability({ state: "idle" }); return; } setAvailability({ state: "checking", label }); const abort = new AbortController(); const timer = setTimeout(() => { client .checkSiteHostname(workspaceId, label, { signal: abort.signal }) .then((result) => { if (!abort.signal.aborted) setAvailability({ state: "done", label, result }); }) .catch(() => { if (!abort.signal.aborted) setAvailability({ state: "failed", label }); }); }, 350); return () => { clearTimeout(timer); abort.abort(); }; }, [client, label, skip, workspaceId]); return availability; } export type SitePublishPageProps = { client: SitePublishingClient; workspaceId: string; workspaceName: string; site: Pick; load: SitePublicationLoad; onPublication: (publication: SitePublication) => void; onReload: () => void; onBack: () => void; /** The viewer owns the Site or administers the workspace, and may publish. */ canManage: boolean; /** The viewer administers the workspace. Only admins change limits and restore an admin's takedown. */ isAdmin: boolean; /** Offered while the Site is Only me and the viewer owns it. */ onShareWithWorkspace?: (() => void) | undefined; /** * The Site is in a Personal workspace, where Only me and the workspace are * the same one person: publishing an Only me Site shares it as it publishes. */ personalWorkspace?: boolean | undefined; sites: SitesClientConfig; /** Fixed suffix for the suggested label, for previews and tests. */ labelSuffix?: string | undefined; /** * `page` is Publish to web on a page of its own; `panel` is the same form * inline, in the Site's Settings, with no back link. */ layout?: "page" | "panel" | undefined; }; /** The publish form's frame: its own page, or a panel in the Site's Settings. */ function PublishFrame({ layout = "page", back, ...props }: Omit & { layout?: "page" | "panel" | undefined }) { return layout === "panel" ? : ; } /** * Publish a Site to the web: its address, who can open it, its embedded * agent and limits, search engines, and reports and takedown. A page of its * own (`?view=publish`) with the Site page as its back link. */ export function SitePublishPage(props: SitePublishPageProps) { const { load, site, onBack, layout } = props; const back = { label: site.title, onClick: onBack }; const blocked = (title: string, body: ReactNode, action?: ReactNode) => ( {body} ); if (!props.canManage || load.state === "forbidden") return blocked( "You can't publish this Site", "You need permission to publish artifacts in this workspace.", ); if (load.state === "unavailable") return blocked( "Publishing isn't set up on this server", "An administrator sets a Sites domain to turn it on.", ); if (site.visibility === "private" && load.state !== "ready" && !props.personalWorkspace) return blocked( "Share this Site with the workspace first", "A Site that only you can see can't be published.", props.onShareWithWorkspace ? ( ) : undefined, ); if (load.state === "error") return blocked( "Couldn't load publishing", userErrorText(load.error), , ); if (load.state === "loading") return ( ); return ( ); } function SitePublishForm({ client, workspaceId, workspaceName, site, saved, onPublication, onBack, sites, labelSuffix, isAdmin, layout = "page", }: SitePublishPageProps & { saved: SitePublication | null }) { const forceNoindex = sites.forceNoindex === true; const [draft, setDraft] = useState(() => draftFromPublication(saved, suggestedLabel(site.title, labelSuffix), forceNoindex), ); const [labelError, setLabelError] = useState(null); const [emailError, setEmailError] = useState(null); const [limitError, setLimitError] = useState(null); const [agentError, setAgentError] = useState(null); const [refusal, setRefusal] = useState<{ title: string; body: string } | null>(null); const [busy, setBusy] = useState<"unpublish" | "restore" | null>(null); const [takedownOpen, setTakedownOpen] = useState(false); const update = (patch: Partial) => setDraft((current) => ({ ...current, ...patch })); const discard = () => { setLabelError(null); setEmailError(null); setLimitError(null); setAgentError(null); setDraft(draftFromPublication(saved, suggestedLabel(site.title, labelSuffix), forceNoindex)); }; const labelUnchanged = saved?.label === draft.label; // A taken-down Site keeps its address until it is restored. const suspended = saved?.status === "suspended"; const availability = useHostnameAvailability(client, workspaceId, draft.label, labelUnchanged); const reportedDomain = sites.domain ?? domainFromHostname(saved?.hostname, saved?.label ?? "") ?? (availability.state === "done" ? domainFromHostname(availability.result.hostname, availability.label) : null); // Keep the suffix once known, so it doesn't vanish while a name is invalid. const [lastDomain, setLastDomain] = useState(reportedDomain); if (reportedDomain && reportedDomain !== lastDomain) setLastDomain(reportedDomain); const domain = reportedDomain ?? lastDomain; const localProblem = labelProblemMessage(draft.label); const remoteProblem = availability.state === "done" && availability.label === draft.label ? availabilityMessage(availability.result) : null; const shownLabelError = labelError ?? localProblem ?? remoteProblem; const publishing = !saved || saved.status === "unpublished"; const request = publicationRequest(draft, saved, { publish: publishing, canChangeLimits: isAdmin, }); const dirty = Object.keys(request).length > 0; const outsideViewers = hasOutsideViewers(draft.access); const publicDisabled = sites.publicEnabled === false; // Readiness warnings describe the saved access; hide them while it changes. const warnings = saved?.status === "active" && saved.access === draft.access ? saved.warnings : []; const submit = async () => { setLabelError(null); setAgentError(null); if (localProblem || remoteProblem) return false; if (availability.state === "checking" && !labelUnchanged) return false; const badEmail = draft.access === "invited" ? draft.invitedEmails.find(invitedEmailProblem) : undefined; if (badEmail) { setEmailError(`${badEmail} isn't an email address.`); return false; } setEmailError(null); if (outsideViewers && isAdmin && "invalid" in outsideLimitsFromDraft(draft)) { setLimitError("Use dollar amounts like 10 or 0.50."); return false; } setLimitError(null); setRefusal(null); try { const next = await client.updateSitePublication(workspaceId, site.id, request); onPublication(next); toast.success(publishing ? "Site published" : "Changes saved", { ...(publishing ? { description: next.hostname } : {}), }); return true; } catch (error) { const code = siteErrorCode(error); if (code === "site_hostname_taken") { setLabelError("Another Site uses this name."); return false; } if (code === "site_admin_required") { if (request.limitProfile) setLimitError("Only a workspace admin can change limits."); else setAgentError("Only a workspace admin can make this change."); return false; } const platform = platformRefusal(error); if (platform) { setRefusal(platform); return false; } throw error; } }; const applyFix = async (fix: SitePublishWarningFix) => { try { onPublication(await client.applySitePublicationFix(workspaceId, site.id, fix)); toast.success("Fixed"); } catch (error) { toast.error("Couldn't apply the fix", { description: userErrorText(error) }); } }; const unpublish = async () => { if (!saved) return; setBusy("unpublish"); try { const next = await client.unpublishSite(workspaceId, site.id); onPublication(next); showUndoToast({ title: "Site unpublished", description: `${saved.hostname} stays reserved for this workspace.`, onUndo: () => void client .updateSitePublication(workspaceId, site.id, { status: "active" }) .then(onPublication) .catch((error) => toast.error("Couldn't publish again", { description: userErrorText(error) }), ), }); } catch (error) { toast.error("Couldn't unpublish", { description: userErrorText(error) }); } finally { setBusy(null); } }; const restore = async () => { setBusy("restore"); try { onPublication(await client.restoreSite(workspaceId, site.id)); toast.success("Site restored"); } catch (error) { toast.error("Couldn't restore the Site", { description: userErrorText(error) }); } finally { setBusy(null); } }; return ( <> {refusal ? ( {refusal.body} ) : null} {saved ? ( void unpublish()} onRestore={() => void restore()} /> ) : null} ) } >
{ setLabelError(null); update({ label: normalizeLabelInput(event.target.value) }); }} suppressAutofill spellCheck={false} autoCapitalize="off" maxLength={63} className="w-auto min-w-0 flex-1 basis-56 font-mono text-[13px]" /> {domain ? ( .{domain} ) : null}
update({ access: value as SitePublishDraft["access"] })} variant="radio" > {ACCESS_CHOICES.map((choice) => ( ))} {draft.access === "invited" ? ( ({ value, ...(invitedEmailProblem(value) ? { problem: invitedEmailProblem(value) } : {}), }))} onChange={(invitedEmails) => { setEmailError(null); update({ invitedEmails }); }} placeholder="name@example.com" /> ) : null} {warnings.length > 0 ? ( ) : null}
update({ agentEnabled })} /> {draft.agentEnabled ? (
update({ agentModel })} /> {outsideViewers ? ( { setLimitError(null); update(patch); }} /> ) : agentError ? (

{agentError}

) : null}
) : null}
update({ noindex })} /> {saved ? ( setTakedownOpen(true)} /> ) : null}
{saved ? ( { const next = await client.takeDownSite(workspaceId, site.id, { reason }); onPublication(next); toast.success("Site taken down", { description: "Visitors see a takedown notice.", }); }} /> ) : null} ); } function AvailabilityHint({ availability, label, domain, unchanged, }: { availability: Availability; label: string; domain: string | null; unchanged: boolean; }) { if (unchanged) return "Changing it moves the Site. The old address stops working."; if ( availability.state === "checking" || (availability.state !== "idle" && availability.label !== label) ) return ( ); if (availability.state === "failed") return "Couldn't check this name. It's checked again when you publish."; if (availability.state === "done" && availability.result.available) return ( ); return "Lowercase letters, digits and hyphens."; } function PublicationStatus({ publication, isAdmin, busy, onUnpublish, onRestore, }: { publication: SitePublication; isAdmin: boolean; busy: "unpublish" | "restore" | null; onUnpublish: () => void; onRestore: () => void; }) { const { state: copied, copy } = useCopyToClipboard(); const viewAs = viewAsVisitorUrl(publication); if (publication.status === "suspended") { const { suspendedAt, suspendedBy, suspendedReason } = publication.moderation; const byPlatform = suspendedBy === "platform"; // Only Opengeni restores its own takedowns, and only admins restore an admin's. const adminOnly = suspendedBy === "admin" && !isAdmin; return ( ); } if (publication.status === "unpublished") return ( {publication.hostname} stays reserved for this workspace. Publish to put it back online. ); return ( } title="Published" actionLayout="responsive" action={
} > {publication.hostname} {viewAs ? ( View as visitor ) : null}
); } /** The embedded agent's model, with its payment source, from the workspace catalog. */ function SiteAgentModelField({ workspaceId, model, allowed, onChange, }: { workspaceId: string; model: string | null; allowed: string[] | null; onChange: (model: string | null) => void; }) { const catalog = useWorkspaceModelCatalog(workspaceId); const rows = useMemo( () => (allowed ? catalog.rows.filter((row) => allowed.includes(row.id)) : catalog.rows), [allowed, catalog.rows], ); const fallback = catalog.defaultSelection?.model ?? rows[0]?.id ?? ""; const shown = model ?? fallback; const selected = rows.find((row) => row.id === shown); return ( onChange(next)} onEffortChange={() => {}} onLatencyModeChange={() => {}} /> ); } const reportRowLeading = (size: LogoTileSize) => ( } name="Report" /> ); /** * Invited and public Sites: the agent for people outside the workspace, the * one Site limit, the optional limit per person and this period's usage. * Members are never limited here. Only admins change it. */ function OutsideAgentSection({ workspaceId, draft, saved, isAdmin, error, onChange, }: { workspaceId: string; draft: SitePublishDraft; saved: SitePublication | null; isAdmin: boolean; error: string | null; onChange: (patch: Partial) => void; }) { const locked = !isAdmin; // The usage line and the model line describe what is saved. const savedHere = saved?.status === "active" && saved.access === draft.access ? saved : null; return ( onChange({ outsideAgent })} /> } className="border-0 px-0" > {draft.outsideAgent ? (
{savedHere?.outsideAgentModel && savedHere.agent.model === draft.agentModel ? ( ) : null} onChange({ siteLimit })} onPeriodChange={(sitePeriod) => onChange({ sitePeriod })} /> {draft.personLimitOn ? ( onChange({ personLimit })} onPeriodChange={(personPeriod) => onChange({ personPeriod })} onRemove={() => onChange({ personLimitOn: false, personLimit: "" })} /> ) : locked ? null : ( )} {savedHere ? (

{outsideUsageLine(savedHere)}

) : null}
) : null}
); } /** "Limit: [$10] per [month ▾]". */ function SpendLimitLine({ label, amount, period, disabled, onAmountChange, onPeriodChange, onRemove, }: { label: string; amount: string; period: SpendPeriod; disabled: boolean; onAmountChange: (amount: string) => void; onPeriodChange: (period: SpendPeriod) => void; onRemove?: (() => void) | undefined; }) { return (
{label}
$ onAmountChange(event.target.value)} className="pl-6" />
per {onRemove && !disabled ? ( ) : null}
); } /** A subscription model is never used by people outside the workspace: name the one they get. */ function OutsideModelLine({ workspaceId, model }: { workspaceId: string; model: string }) { const catalog = useWorkspaceModelCatalog(workspaceId); const label = catalog.rows.find((row) => row.id === model)?.label ?? model; return (

People outside the workspace use {label} (paid with credits).

); } /** "2 things won't work for public visitors", opening to the list with one-click fixes. */ function SiteReadiness({ warnings, access, onFix, }: { warnings: readonly SitePublishWarning[]; access: SitePublication["access"]; onFix: (fix: SitePublishWarningFix) => Promise; }) { const [fixing, setFixing] = useState(null); return (
    {warnings.map((warning, index) => (
  • {warning.message}

    {warning.suggestion}

    {warning.fix ? ( ) : null}
  • ))}
); } function SiteReports({ client, workspaceId, siteId, publication, onTakeDown, }: { client: Pick; workspaceId: string; siteId: string; publication: SitePublication; onTakeDown: () => void; }) { const [reports, setReports] = useState(null); const [failed, setFailed] = useState(false); const openReports = publication.moderation.openReports; useEffect(() => { const abort = new AbortController(); setFailed(false); client .listSiteAbuseReports(workspaceId, siteId, { signal: abort.signal }) .then((response) => { if (!abort.signal.aborted) setReports(response.reports.filter((r) => r.status === "open")); }) .catch(() => { if (!abort.signal.aborted) setFailed(true); }); return () => abort.abort(); }, [client, openReports, siteId, workspaceId]); const close = async (report: SiteAbuseReport, status: "dismissed" | "actioned") => { try { await client.updateSiteAbuseReport(workspaceId, siteId, report.id, { status }); setReports((current) => current?.filter((item) => item.id !== report.id) ?? null); toast.success(status === "dismissed" ? "Report dismissed" : "Report marked as handled"); } catch (error) { toast.error("Couldn't update the report", { description: userErrorText(error) }); } }; return (
Take down… ) } className="border-t border-border pt-6" >
{failed ? (

Couldn't load reports.

) : reports === null ? (

Loading reports…

) : reports.length === 0 ? (

No open reports.

) : ( {reports.map((report) => ( , ]} menuLabel={`Actions for the ${abuseCategoryLabel(report.category)} report`} menu={ <> void close(report, "actioned")}> Mark as handled void close(report, "dismissed")}> Dismiss } /> ))} )}
); } function TakedownDialog({ open, onOpenChange, hostname, onTakeDown, }: { open: boolean; onOpenChange: (open: boolean) => void; hostname: string; onTakeDown: (reason: string) => Promise; }) { const [reason, setReason] = useState(""); const [error, setError] = useState(null); useEffect(() => { if (open) { setReason(""); setError(null); } }, [open]); return ( { if (!reason.trim()) { setError("Say why, for the record."); return false; } await onTakeDown(reason.trim()); }} >