Skip to content
Draft
Show file tree
Hide file tree
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 (
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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}
Expand Down
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();

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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: [
Expand All @@ -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 {
Expand All @@ -176,5 +251,5 @@ export const getServerSideProps = (async (context) => {
};
}
}) satisfies GetServerSideProps<{
hackathon: Hackathon | null;
hackathon: HackathonWithDomainResponse | null;
}>;
Loading