diff --git a/components/Form.tsx b/components/Form.tsx
index 6f3d21d..a609ce5 100644
--- a/components/Form.tsx
+++ b/components/Form.tsx
@@ -1,5 +1,5 @@
import { AutoComplete, Button, Input, Select, Text } from "@geist-ui/core";
-import React, { CSSProperties, useState } from "react";
+import React, { CSSProperties, ReactNode, useState } from "react";
function Required() {
return (
@@ -101,7 +101,7 @@ interface BaseFormElement {
interface FormInput extends BaseFormElement {
validate?: (value: string) => boolean;
- description?: string;
+ description?: JSX.Element | (() => JSX.Element) | ReactNode | string;
required?: boolean;
name: string;
}
@@ -347,6 +347,14 @@ export const Form = React.forwardRef(
{element.required && }
)}
+
+ {element.description && (
+
+ {typeof element.description == "function"
+ ? element.description()
+ : element.description}
+
+ )}
);
} else if (formElement.type == "tuple") {
@@ -425,6 +433,7 @@ export const Form = React.forwardRef(
options={options}
crossOrigin
mb={1}
+ aria-label={element.label}
width="100%"
onChange={(v) => updateValue(element.name, v)}
onSearch={searchHandler}
diff --git a/lib/domains.ts b/lib/domains.ts
new file mode 100644
index 0000000..c0b95a8
--- /dev/null
+++ b/lib/domains.ts
@@ -0,0 +1,209 @@
+// From https://github.com/vercel/platforms/blob/main/lib/domains.ts
+export type DomainVerificationStatusProps =
+ | "Valid Configuration"
+ | "Invalid Configuration"
+ | "Pending Verification"
+ | "Domain Not Found"
+ | "Unknown Error";
+
+// From https://vercel.com/docs/rest-api/endpoints#get-a-project-domain
+export interface DomainResponse {
+ name: string;
+ apexName: string;
+ projectId: string;
+ redirect?: string | null;
+ redirectStatusCode?: (307 | 301 | 302 | 308) | null;
+ gitBranch?: string | null;
+ updatedAt?: number;
+ createdAt?: number;
+ /** `true` if the domain is verified for use with the project. If `false` it will not be used as an alias on this project until the challenge in `verification` is completed. */
+ verified: boolean;
+ /** A list of verification challenges, one of which must be completed to verify the domain for use on the project. After the challenge is complete `POST /projects/:idOrName/domains/:domain/verify` to verify the domain. Possible challenges: - If `verification.type = TXT` the `verification.domain` will be checked for a TXT record matching `verification.value`. */
+ verification: {
+ type: string;
+ domain: string;
+ value: string;
+ reason: string;
+ }[];
+}
+
+// From https://vercel.com/docs/rest-api/endpoints#get-a-domain-s-configuration
+export interface DomainConfigResponse {
+ /** How we see the domain's configuration. - `CNAME`: Domain has a CNAME pointing to Vercel. - `A`: Domain's A record is resolving to Vercel. - `http`: Domain is resolving to Vercel but may be behind a Proxy. - `null`: Domain is not resolving to Vercel. */
+ configuredBy?: ("CNAME" | "A" | "http") | null;
+ /** Which challenge types the domain can use for issuing certs. */
+ acceptedChallenges?: ("dns-01" | "http-01")[];
+ /** Whether or not the domain is configured AND we can automatically generate a TLS certificate. */
+ misconfigured: boolean;
+}
+
+// From https://vercel.com/docs/rest-api/endpoints#verify-project-domain
+export interface DomainVerificationResponse {
+ name: string;
+ apexName: string;
+ projectId: string;
+ redirect?: string | null;
+ redirectStatusCode?: (307 | 301 | 302 | 308) | null;
+ gitBranch?: string | null;
+ updatedAt?: number;
+ createdAt?: number;
+ /** `true` if the domain is verified for use with the project. If `false` it will not be used as an alias on this project until the challenge in `verification` is completed. */
+ verified: boolean;
+ /** A list of verification challenges, one of which must be completed to verify the domain for use on the project. After the challenge is complete `POST /projects/:idOrName/domains/:domain/verify` to verify the domain. Possible challenges: - If `verification.type = TXT` the `verification.domain` will be checked for a TXT record matching `verification.value`. */
+ verification?: {
+ type: string;
+ domain: string;
+ value: string;
+ reason: string;
+ }[];
+}
+
+export const addDomainToVercel = async (
+ domain: string
+): Promise => {
+ return await fetch(
+ `https://api.vercel.com/v10/projects/${
+ process.env.PROJECT_ID_VERCEL
+ }/domains${
+ process.env.TEAM_ID_VERCEL
+ ? `?teamId=${process.env.TEAM_ID_VERCEL}`
+ : ""
+ }`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ name: domain
+ // Optional: Redirect www. to root domain
+ // ...(domain.startsWith("www.") && {
+ // redirect: domain.replace("www.", ""),
+ // }),
+ })
+ }
+ ).then((res) => res.json());
+};
+
+export const removeDomainFromVercelProject = async (domain: string) => {
+ return await fetch(
+ `https://api.vercel.com/v9/projects/${
+ process.env.PROJECT_ID_VERCEL
+ }/domains/${domain}${
+ process.env.TEAM_ID_VERCEL
+ ? `?teamId=${process.env.TEAM_ID_VERCEL}`
+ : ""
+ }`,
+ {
+ headers: {
+ Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`
+ },
+ method: "DELETE"
+ }
+ ).then((res) => res.json());
+};
+
+export const removeDomainFromVercelTeam = async (domain: string) => {
+ return await fetch(
+ `https://api.vercel.com/v6/domains/${domain}${
+ process.env.TEAM_ID_VERCEL
+ ? `?teamId=${process.env.TEAM_ID_VERCEL}`
+ : ""
+ }`,
+ {
+ headers: {
+ Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`
+ },
+ method: "DELETE"
+ }
+ ).then((res) => res.json());
+};
+
+export const getDomainResponse = async (
+ domain: string
+): Promise => {
+ return await fetch(
+ `https://api.vercel.com/v9/projects/${
+ process.env.PROJECT_ID_VERCEL
+ }/domains/${domain}${
+ process.env.TEAM_ID_VERCEL
+ ? `?teamId=${process.env.TEAM_ID_VERCEL}`
+ : ""
+ }`,
+ {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,
+ "Content-Type": "application/json"
+ }
+ }
+ ).then((res) => {
+ return res.json();
+ });
+};
+
+export const getConfigResponse = async (
+ domain: string
+): Promise => {
+ return await fetch(
+ `https://api.vercel.com/v6/domains/${domain}/config${
+ process.env.TEAM_ID_VERCEL
+ ? `?teamId=${process.env.TEAM_ID_VERCEL}`
+ : ""
+ }`,
+ {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,
+ "Content-Type": "application/json"
+ }
+ }
+ ).then((res) => res.json());
+};
+
+export const verifyDomain = async (
+ domain: string
+): Promise => {
+ return await fetch(
+ `https://api.vercel.com/v9/projects/${
+ process.env.PROJECT_ID_VERCEL
+ }/domains/${domain}/verify${
+ process.env.TEAM_ID_VERCEL
+ ? `?teamId=${process.env.TEAM_ID_VERCEL}`
+ : ""
+ }`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`,
+ "Content-Type": "application/json"
+ }
+ }
+ ).then((res) => res.json());
+};
+
+export const getSubdomain = (name: string, apexName: string) => {
+ if (name === apexName) return null;
+ return name.slice(0, name.length - apexName.length - 1);
+};
+
+export const getApexDomain = (url: string) => {
+ let domain;
+ try {
+ domain = new URL(url).hostname;
+ } catch (e) {
+ return "";
+ }
+ const parts = domain.split(".");
+ if (parts.length > 2) {
+ // if it's a subdomain (e.g. dub.vercel.app), return the last 2 parts
+ return parts.slice(-2).join(".");
+ }
+ // if it's a normal domain (e.g. dub.sh), we return the domain
+ return domain;
+};
+
+export const validDomainRegex = new RegExp(
+ /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
+);
diff --git a/pages/[slug]/settings.tsx b/pages/[slug]/settings.tsx
index 4930420..deba86f 100644
--- a/pages/[slug]/settings.tsx
+++ b/pages/[slug]/settings.tsx
@@ -1,19 +1,33 @@
import prisma from "@/lib/prisma";
import { getAuth } from "@clerk/nextjs/server";
-import { Card, Page, Text } from "@geist-ui/core";
+import {
+ Card,
+ Code,
+ Dot,
+ Modal,
+ Page,
+ Tag,
+ Text,
+ useModal
+} from "@geist-ui/core";
import type { GetServerSideProps } from "next";
import { Form } from "@/components/Form";
import HackathonLayout from "@/components/layouts/organizer/OrganizerLayout";
+import { DomainResponse, getDomainResponse } from "@/lib/domains";
import { delay } from "@/lib/utils";
import type { Hackathon } from "@prisma/client";
import { useRouter } from "next/router";
import type { ReactElement } from "react";
+type HackathonWithDomainResponse = Hackathon & {
+ domainResponse?: DomainResponse;
+};
+
export default function Hackathon({
hackathon
}: {
- hackathon: Hackathon | null;
+ hackathon: HackathonWithDomainResponse | null;
}): any {
const router = useRouter();
@@ -105,6 +119,55 @@ export default function Hackathon({
label: "Venue & Location",
name: "location",
defaultValue: hackathon.location
+ },
+ {
+ type: "text",
+ label: "Custom Domain",
+ name: "customDomain",
+ defaultValue:
+ hackathon.customDomain ?? `${hackathon.slug}.hackathon.zip`,
+ inlineLabel: "https://",
+ validate(value) {
+ // allow only apex domains or subdomains, no paths or protocols
+ const regex =
+ /^((?!-)[A-Za-z0-9-]{1,63}(? {
+ const { visible, setVisible, bindings } = useModal();
+ return (
+ <>
+ {hackathon.domainResponse?.verified ? (
+
+ Verified
+
+ ) : (
+ <>
+ setVisible(true)}
+ >
+ Unverified
+
+
+ Verify Domain
+
+ {hackathon.domainResponse}
+
+ setVisible(false)}
+ >
+ Cancel
+
+ Check
+
+ >
+ )}
+ >
+ );
+ }
}
],
submitText: "Save"
@@ -148,7 +211,7 @@ export const getServerSideProps = (async (context) => {
console.log({ userId });
if (context.params?.slug) {
- const hackathon = await prisma.hackathon.findUnique({
+ const h = await prisma.hackathon.findUnique({
where: {
slug: context.params?.slug.toString(),
OR: [
@@ -163,9 +226,21 @@ export const getServerSideProps = (async (context) => {
]
}
});
+
+ if (!h) return { props: { hackathon: null } };
+
+ if (h.customDomain) {
+ const domainResponse = await getDomainResponse(h.customDomain);
+
+ const hackathon: HackathonWithDomainResponse = {
+ ...h,
+ domainResponse
+ };
+ }
+
return {
props: {
- hackathon
+ hackathon: h
}
};
} else {
@@ -176,5 +251,5 @@ export const getServerSideProps = (async (context) => {
};
}
}) satisfies GetServerSideProps<{
- hackathon: Hackathon | null;
+ hackathon: HackathonWithDomainResponse | null;
}>;
diff --git a/pages/api/organizer/hackathons/[slug]/update.ts b/pages/api/organizer/hackathons/[slug]/update.ts
index 432cc67..e0187c7 100644
--- a/pages/api/organizer/hackathons/[slug]/update.ts
+++ b/pages/api/organizer/hackathons/[slug]/update.ts
@@ -1,3 +1,7 @@
+import {
+ addDomainToVercel,
+ removeDomainFromVercelProject
+} from "@/lib/domains";
import prisma from "@/lib/prisma";
import { permitParams } from "@/lib/utils";
import { getAuth } from "@clerk/nextjs/server";
@@ -68,7 +72,18 @@ export default async function handler(
}
});
- console.log({ hackathon });
+ if (!newData.customDomain && hackathon.customDomain) {
+ await removeDomainFromVercelProject(hackathon.customDomain);
+ } else if (newData.customDomain && !hackathon.customDomain) {
+ await addDomainToVercel(newData.customDomain);
+ } else if (
+ newData.customDomain &&
+ hackathon.customDomain &&
+ newData.customDomain !== hackathon.customDomain
+ ) {
+ await removeDomainFromVercelProject(hackathon.customDomain);
+ await addDomainToVercel(newData.customDomain);
+ }
res.redirect(`/${hackathon.slug}`);
} catch (error) {