Skip to content

Commit

Permalink
feat: custom domains created on Vercel if you include vercel API keys
Browse files Browse the repository at this point in the history
  • Loading branch information
carhartlewis committed Feb 7, 2025
1 parent bcea03b commit c8ca4fe
Show file tree
Hide file tree
Showing 12 changed files with 492 additions and 74 deletions.
40 changes: 40 additions & 0 deletions apps/app/src/actions/organization/check-subdomain-availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// check-subdomain-availability.ts

"use server";

import { db } from "@bubba/db";
import { authActionClient } from "../safe-action";
import { subdomainAvailabilitySchema } from "../schema";
import type { ActionResponse } from "../types";

export const checkSubdomainAvailability = authActionClient
.schema(subdomainAvailabilitySchema)
.metadata({
name: "check-subdomain-availability",
})
.action(async ({ parsedInput }): Promise<ActionResponse> => {
const { subdomain } = parsedInput;

try {
const subdomainExists = await db.organization.findFirst({
where: {
subdomain: {
equals: subdomain,
mode: 'insensitive'
},
},
select: { id: true }
});

return {
success: true,
data: !subdomainExists
};
} catch (error) {
console.error('Prisma error:', error);
return {
success: false,
error: "Failed to check subdomain availability"
};
}
});
33 changes: 30 additions & 3 deletions apps/app/src/actions/organization/create-organization-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

import { createOrganizationAndConnectUser } from "@/auth/org";
import type { createDefaultPoliciesTask } from "@/jobs/tasks/organization/create-default-policies";
import { addDomainToVercel, removeDomainFromVercelProject } from "@/lib/domains";
import { db } from "@bubba/db";
import { tasks, wait } from "@trigger.dev/sdk/v3";
import { tasks } from "@trigger.dev/sdk/v3";
import { revalidateTag } from "next/cache";
import { authActionClient } from "../safe-action";
import { organizationSchema } from "../schema";
Expand All @@ -21,19 +22,35 @@ export const createOrganizationAction = authActionClient
},
})
.action(async ({ parsedInput, ctx }) => {
const { name, website } = parsedInput;
const { name, website, subdomain } = parsedInput;
const { id: userId, organizationId } = ctx.user;

if (!name || !website) {
console.log("Invalid input detected:", { name, website });

throw new Error("Invalid user input");
}

const hasVercelConfig = Boolean(
process.env.NEXT_PUBLIC_VERCEL_URL &&
process.env.VERCEL_AUTH_TOKEN &&
process.env.VERCEL_TEAM_ID &&
process.env.VERCEL_PROJECT_ID
);

if (hasVercelConfig && subdomain) {
try {
await addDomainToVercel(`${subdomain}.${process.env.NEXT_PUBLIC_VERCEL_URL}`);
} catch (error) {
console.error("Failed to add domain to Vercel:", error);
throw new Error("Failed to set up subdomain");
}
}

if (!organizationId) {
await createOrganizationAndConnectUser({
userId,
normalizedEmail: ctx.user.email!,
subdomain: hasVercelConfig ? (subdomain || "") : "",
});
}

Expand All @@ -60,10 +77,12 @@ export const createOrganizationAction = authActionClient
update: {
name,
website,
subdomain: hasVercelConfig ? (subdomain || "") : "",
},
create: {
name,
website,
subdomain: hasVercelConfig ? (subdomain || "") : "",
},
});

Expand Down Expand Up @@ -97,6 +116,14 @@ export const createOrganizationAction = authActionClient
success: true,
};
} catch (error) {
if (hasVercelConfig && subdomain) {
try {
await removeDomainFromVercelProject(`${subdomain}.${process.env.NEXT_PUBLIC_VERCEL_URL}`);
} catch (cleanupError) {
console.error("Failed to clean up subdomain after error:", cleanupError);
}
}

console.error("Error during organization update:", error);
throw new Error("Failed to update organization");
}
Expand Down
75 changes: 40 additions & 35 deletions apps/app/src/actions/safe-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,45 +84,50 @@ export const authActionClient = actionClientWithMeta
throw new Error("Unauthorized");
}

if (!session.user.organizationId) {
throw new Error("Organization not found");
}
if (
metadata.name !== "check-subdomain-availability" &&
metadata.name !== "create-organization"
) {
if (!session.user.organizationId) {
throw new Error("Organization not found");
}

const auditData = {
userId: session.user.id,
email: session.user.email,
name: session.user.name,
organizationId: session.user.organizationId,
action: metadata.name,
ipAddress: headersList.get("x-forwarded-for") || null,
userAgent: headersList.get("user-agent") || null,
};

try {
await db.auditLog.create({
data: {
data: auditData,
userId: session.user.id,
organizationId: session.user.organizationId,
},
});

if (metadata.track) {
await ServerAnalytics.track(
session.user.id,
metadata.track.event,
{
channel: metadata.track.channel,
email: session.user.email,
name: session.user.name,
const auditData = {
userId: session.user.id,
email: session.user.email,
name: session.user.name,
organizationId: session.user.organizationId,
action: metadata.name,
ipAddress: headersList.get("x-forwarded-for") || null,
userAgent: headersList.get("user-agent") || null,
};

try {
await db.auditLog.create({
data: {
data: auditData,
userId: session.user.id,
organizationId: session.user.organizationId,
}
);
},
});

if (metadata.track) {
await ServerAnalytics.track(
session.user.id,
metadata.track.event,
{
channel: metadata.track.channel,
email: session.user.email,
name: session.user.name,
organizationId: session.user.organizationId,
}
);
}
} catch (error) {
logger("Audit log error:", error);
}
} catch (error) {
logger("Audit log error:", error);
}

}
return next({
ctx: {
user: session.user,
Expand Down
17 changes: 15 additions & 2 deletions apps/app/src/actions/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,25 @@ import {
import { z } from "zod";

export const organizationSchema = z.object({
name: z.string().min(1, "Organization name is required"),
name: z.string().min(1, "Name is required"),
website: z.string().url("Must be a valid URL"),
subdomain: z.string().min(1, "Subdomain is required").optional(),
});

export const organizationNameSchema = z.object({
name: z.string().min(1).max(255),
name: z.string()
.min(1, "Organization name is required")
.max(255, "Organization name cannot exceed 255 characters")
});

export const subdomainAvailabilitySchema = z.object({
subdomain: z.string()
.min(1, "Subdomain is required")
.max(255, "Subdomain cannot exceed 255 characters")
.regex(/^[a-z0-9-]+$/, {
message:
"Subdomain can only contain lowercase letters, numbers, and hyphens",
})
});

export const uploadSchema = z.object({
Expand Down
59 changes: 59 additions & 0 deletions apps/app/src/actions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,62 @@ export interface ActionResponse<T = any> {
data?: T | null;
error?: string;
}

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;
}[];
}
2 changes: 2 additions & 0 deletions apps/app/src/auth/org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ async function createStripeCustomer(input: {
export async function createOrganizationAndConnectUser(input: {
userId: string;
normalizedEmail: string;
subdomain: string;
}): Promise<string> {
const initialName = "New Organization";

Expand All @@ -35,6 +36,7 @@ export async function createOrganizationAndConnectUser(input: {
name: initialName,
tier: "free",
website: "",
subdomain: input.subdomain,
members: {
create: {
userId: input.userId,
Expand Down
Loading

0 comments on commit c8ca4fe

Please sign in to comment.