Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add custom domain setting #19

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions components/Form.tsx
Original file line number Diff line number Diff line change
@@ -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 && <Required />}
</Text>
)}

{element.description && (
<Text small type="secondary">
{typeof element.description == "function"
? element.description()
: element.description}
</Text>
)}
</Input>
);
} 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}
209 changes: 209 additions & 0 deletions lib/domains.ts
Original file line number Diff line number Diff line change
@@ -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<DomainResponse> => {
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<DomainResponse & { error: { code: string; message: 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}`
: ""
}`,
{
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<DomainConfigResponse> => {
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<DomainVerificationResponse> => {
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,}$/
);
85 changes: 80 additions & 5 deletions pages/[slug]/settings.tsx
Original file line number Diff line number Diff line change
@@ -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}(?<!-)\.)+[A-Za-z]{2,6}$/;
return value == "" || regex.test(value);
},
description: () => {
const { visible, setVisible, bindings } = useModal();
return (
<>
{hackathon.domainResponse?.verified ? (
<Tag type="success">
<Dot type="success">Verified</Dot>
</Tag>
) : (
<>
<Tag
type="warning"
style={{ cursor: "pointer" }}
onClick={() => setVisible(true)}
>
<Dot type="warning">Unverified</Dot>
</Tag>
<Modal {...bindings}>
<Modal.Title>Verify Domain</Modal.Title>
<Modal.Content>
<Code block>{hackathon.domainResponse}</Code>
</Modal.Content>
<Modal.Action
passive
onClick={() => setVisible(false)}
>
Cancel
</Modal.Action>
<Modal.Action>Check</Modal.Action>
</Modal>
</>
)}
</>
);
}
}
],
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;
}>;
Loading