From ea45cce1288bab6094470de68c45ed2926532c62 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Thu, 29 May 2025 11:31:29 -0400 Subject: [PATCH] Feature/pub custom domains (#137) * add unstyled domains section to update pub form * mutate local data on custom domain updates * added custom domain styling * slight updates to style to try and make thiings look more clickable * add vercel verification step to domains --------- Co-authored-by: celine --- actions/domains/addDomain.ts | 48 ++- app/api/rpc/[command]/domain_routes.ts | 19 +- app/api/rpc/[command]/get_publication_data.ts | 1 + .../[did]/[publication]/dashboard/Actions.tsx | 2 - app/lish/createPub/UpdatePubForm.tsx | 358 ++++++++++++++++-- app/lish/createPub/updatePublication.ts | 49 +++ components/Icons/LoadingTiny.tsx | 19 + components/Icons/PinTiny.tsx | 19 + components/Input.tsx | 20 +- 9 files changed, 465 insertions(+), 70 deletions(-) create mode 100644 components/Icons/LoadingTiny.tsx create mode 100644 components/Icons/PinTiny.tsx diff --git a/actions/domains/addDomain.ts b/actions/domains/addDomain.ts index 64e90e74..12c7b08b 100644 --- a/actions/domains/addDomain.ts +++ b/actions/domains/addDomain.ts @@ -4,6 +4,7 @@ import { cookies } from "next/headers"; import { Database } from "supabase/database.types"; import { createServerClient } from "@supabase/ssr"; +import { getIdentityData } from "actions/getIdentityData"; const VERCEL_TOKEN = process.env.VERCEL_TOKEN; const vercel = new Vercel({ @@ -17,21 +18,8 @@ let supabase = createServerClient( ); export async function addDomain(domain: string) { - let auth_token = (await cookies()).get("auth_token")?.value; - if (!auth_token) return {}; - let { data: auth_data } = await supabase - .from("email_auth_tokens") - .select( - `*, - identities( - *, - custom_domains(*) - )`, - ) - .eq("id", auth_token) - .eq("confirmed", true) - .single(); - if (!auth_data || !auth_data.email) return {}; + let identity = await getIdentityData(); + if (!identity || !identity.email) return {}; if ( domain.includes("leaflet.pub") && ![ @@ -39,10 +27,36 @@ export async function addDomain(domain: string) { "brendan@hyperlink.academy", "jared@hyperlink.academy", "brendan.schlagel@gmail.com", - ].includes(auth_data.email) + ].includes(identity.email) ) return {}; + return await createDomain(domain, identity.email); +} + +export async function addPublicationDomain( + domain: string, + publication_uri: string, +) { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return {}; + let { data: publication } = await supabase + .from("publications") + .select("*") + .eq("uri", publication_uri) + .single(); + + if (publication?.identity_did !== identity.atp_did) return {}; + let { error } = await createDomain(domain, null); + if (error) return { error }; + await supabase.from("publication_domains").insert({ + publication: publication_uri, + identity: identity.atp_did, + domain, + }); + return {}; +} +async function createDomain(domain: string, email: string | null) { try { await vercel.projects.addProjectDomain({ idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", @@ -71,7 +85,7 @@ export async function addDomain(domain: string) { await supabase.from("custom_domains").insert({ domain, - identity: auth_data.email, + identity: email, confirmed: false, }); return {}; diff --git a/app/api/rpc/[command]/domain_routes.ts b/app/api/rpc/[command]/domain_routes.ts index 3e795642..ca063a54 100644 --- a/app/api/rpc/[command]/domain_routes.ts +++ b/app/api/rpc/[command]/domain_routes.ts @@ -20,10 +20,25 @@ export const get_domain_status = makeRoute({ teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", }), ]); - return { status, config }; + return { config }; } catch (e) { - console.log(e); let errorResponse = e as NextApiResponse; + if (errorResponse.statusCode === 403) { + try { + let verification = await vercel.projects.getProjectDomain({ + idOrName: "prj_9jX4tmYCISnm176frFxk07fF74kG", + teamId: "team_42xaJiZMTw9Sr7i0DcLTae9d", + domain, + }); + if (!verification.verification) return {}; + return { + error: "Verification_needed", + verification: verification.verification, + } as const; + } catch (e) { + return { error: true }; + } + } if (errorResponse.statusCode === 404) return { error: "Not Found" } as const; return { error: true }; diff --git a/app/api/rpc/[command]/get_publication_data.ts b/app/api/rpc/[command]/get_publication_data.ts index 74d77da3..008e9b52 100644 --- a/app/api/rpc/[command]/get_publication_data.ts +++ b/app/api/rpc/[command]/get_publication_data.ts @@ -20,6 +20,7 @@ export const get_publication_data = makeRoute({ .select( `*, documents_in_publications(documents(*)), + publication_domains(*), leaflets_in_publications(*, permission_tokens(*, permission_token_rights(*), diff --git a/app/lish/[did]/[publication]/dashboard/Actions.tsx b/app/lish/[did]/[publication]/dashboard/Actions.tsx index a105727b..478223c8 100644 --- a/app/lish/[did]/[publication]/dashboard/Actions.tsx +++ b/app/lish/[did]/[publication]/dashboard/Actions.tsx @@ -101,8 +101,6 @@ function PublicationShareButton() { } function PublicationSettingsButton(props: { publication: string }) { - let router = useRouter(); - return ( { let pubData = usePublicationData(); @@ -43,24 +45,15 @@ export const EditPubForm = () => { if (!pubData) return; e.preventDefault(); setFormState("loading"); - let data = await updatePublication({ - uri: pubData.uri, - name: nameValue, - description: descriptionValue, - iconFile: iconFile, - }); toast({ type: "success", content: "Updated!" }); setFormState("normal"); mutate("publication-data"); }} >
-
-

- Logo{" "} - (optional) -

-
+

+ Logo (optional) +

fileInputRef.current?.click()} @@ -93,30 +86,315 @@ export const EditPubForm = () => { }} />
- { - setNameValue(e.currentTarget.value); - }} - /> - - { - setDescriptionValue(e.currentTarget.value); - }} - /> + + + - {formState === "loading" ? : "Update Publication"} + {formState === "loading" ? : "Update!"} ); }; + +export function CustomDomainForm() { + let pubData = usePublicationData(); + if (!pubData) return null; + let record = pubData?.record as PubLeafletPublication.Record; + let [state, setState] = useState< + | { type: "default" } + | { type: "addDomain" } + | { type: "domainSettings"; domain: string; verification?: Verification[] } + >({ type: "default" }); + let domains = pubData?.publication_domains || []; + + return ( +
+

+ Publication Domain{domains.length > 1 && "s"} +

+ +
+ {state.type === "addDomain" ? ( + setState({ type: "default" })} + /> + ) : state.type === "domainSettings" ? ( + setState({ type: "default" })} + /> + ) : ( +
+ {domains.map((d) => ( + + { + setState({ + type: "domainSettings", + domain: d.domain, + verification: v, + }); + }} + /> +
+
+ ))} + +
+ )} +
+
+ ); +} + +function AddDomain(props: { publication_uri: string; goBack: () => void }) { + let [domain, setDomain] = useState(""); + let smoker = useSmoker(); + + return ( +
+ +
+ + +
+
+ ); +} + +// OKay so... You hit this button, it gives you a form. You type in the form, and then hit add. We create a record, and a the record link it to your publiction. Then we show you the stuff to set. ) +// We don't want to switch it, until it works. +// There's a checkbox to say that this is hosted somewhere else + +function Domain(props: { + domain: string; + base_path: string; + publication_uri: string; + setDomain: (v?: Verification[]) => void; +}) { + let { data } = useSWR(props.domain, async (domain) => { + return await callRPC("get_domain_status", { domain }); + }); + + let pending = data?.config?.misconfigured || data?.error; + console.log(props.domain, data); + + return ( +
+ {props.domain} +
+ {pending ? ( + + ) : props.base_path === props.domain ? ( +
+

+ current default domain +

+ +
+ ) : ( + + )} +
+
+ ); +} + +const DomainSettings = (props: { + domain: string; + goBack: () => void; + verification?: Verification[]; +}) => { + let isSubdomain = props.domain.split(".").length > 2; + if (props.verification) + return ( +
+
{props.domain} is in use on a Vercel account.
+
+
+
Type
+
{props.verification[0].type}
+
+
+
Name
+
+ {props.verification[0].domain} +
+
+
+
Value
+
+ {props.verification?.[0].value} +
+
+
+
+ +
+ +
+ ); + + return ( +
+
+ To verify this domain, add the following record to your DNS provider for{" "} + {props.domain}. +
+ + {isSubdomain ? ( +
+
+
Type
+
CNAME
+
+
+
Name
+
+ {props.domain.split(".").slice(0, -2).join(".")} +
+
+
+
Value
+
cname.vercel-dns.com
+
+
+ ) : ( +
+
+
Type
+
A
+
+
+
Name
+
@
+
+
+
Value
+
76.76.21.21
+
+
+ )} + +
+ ); +}; diff --git a/app/lish/createPub/updatePublication.ts b/app/lish/createPub/updatePublication.ts index 5f04582c..b7be9ac6 100644 --- a/app/lish/createPub/updatePublication.ts +++ b/app/lish/createPub/updatePublication.ts @@ -81,3 +81,52 @@ export async function updatePublication({ return { success: true, publication }; } + +export async function updatePublicationBasePath({ + uri, + base_path, +}: { + uri: string; + base_path: string; +}) { + const oauthClient = await createOauthClient(); + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return; + + let credentialSession = await oauthClient.restore(identity.atp_did); + let agent = new AtpBaseClient( + credentialSession.fetchHandler.bind(credentialSession), + ); + let { data: existingPub } = await supabaseServerClient + .from("publications") + .select("*") + .eq("uri", uri) + .single(); + if (!existingPub || existingPub.identity_did !== identity.atp_did) return; + let aturi = new AtUri(existingPub.uri); + + let record: PubLeafletPublication.Record = { + ...(existingPub.record as PubLeafletPublication.Record), + base_path, + }; + + let result = await agent.com.atproto.repo.putRecord({ + repo: credentialSession.did!, + rkey: aturi.rkey, + record, + collection: record.$type, + validate: false, + }); + + //optimistically write to our db! + let { data: publication, error } = await supabaseServerClient + .from("publications") + .update({ + name: record.name, + record: record as Json, + }) + .eq("uri", uri) + .select() + .single(); + return { success: true, publication }; +} diff --git a/components/Icons/LoadingTiny.tsx b/components/Icons/LoadingTiny.tsx new file mode 100644 index 00000000..2a52e2e4 --- /dev/null +++ b/components/Icons/LoadingTiny.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const LoadingTiny = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Icons/PinTiny.tsx b/components/Icons/PinTiny.tsx new file mode 100644 index 00000000..199ce352 --- /dev/null +++ b/components/Icons/PinTiny.tsx @@ -0,0 +1,19 @@ +import { Props } from "./Props"; + +export const PinTiny = (props: Props) => { + return ( + + + + ); +}; diff --git a/components/Input.tsx b/components/Input.tsx index bebb32b1..3300144c 100644 --- a/components/Input.tsx +++ b/components/Input.tsx @@ -3,12 +3,13 @@ import { useCallback, useEffect, useRef, type JSX } from "react"; import { onMouseDown } from "src/utils/iosInputMouseDown"; import { isIOS } from "src/utils/isDevice"; -export function Input( - props: React.DetailedHTMLProps< - React.InputHTMLAttributes, - HTMLInputElement - >, -) { +export const Input = ( + props: { + textarea?: boolean; + } & JSX.IntrinsicElements["input"] & + JSX.IntrinsicElements["textarea"], +) => { + let { textarea, ...inputProps } = props; let ref = useRef(null); useEffect(() => { if (!isIOS()) return; @@ -17,15 +18,16 @@ export function Input( } }, [props.autoFocus]); + if (textarea) return