all resources at the base domain closes #137

This commit is contained in:
Milo Schwartz 2025-02-03 21:18:16 -05:00
parent 0840c166ab
commit e475c1ea50
No known key found for this signature in database
15 changed files with 496 additions and 141 deletions

View file

@ -53,7 +53,8 @@ export const resources = sqliteTable("resources", {
proxyPort: integer("proxyPort"), proxyPort: integer("proxyPort"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull() .notNull()
.default(false) .default(false),
isBaseDomain: integer("isBaseDomain", { mode: "boolean" })
}); });
export const targets = sqliteTable("targets", { export const targets = sqliteTable("targets", {

View file

@ -10,7 +10,6 @@ import {
configFilePath1, configFilePath1,
configFilePath2 configFilePath2
} from "@server/lib/consts"; } from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
import stoi from "./stoi"; import stoi from "./stoi";
@ -152,7 +151,8 @@ const configSchema = z.object({
require_email_verification: z.boolean().optional(), require_email_verification: z.boolean().optional(),
disable_signup_without_invite: z.boolean().optional(), disable_signup_without_invite: z.boolean().optional(),
disable_user_create_org: z.boolean().optional(), disable_user_create_org: z.boolean().optional(),
allow_raw_resources: z.boolean().optional() allow_raw_resources: z.boolean().optional(),
allow_base_domain_resources: z.boolean().optional()
}) })
.optional() .optional()
}); });
@ -252,9 +252,9 @@ export class Config {
? "true" ? "true"
: "false"; : "false";
process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags
?.allow_raw_resources ?.allow_raw_resources
? "true" ? "true"
: "false"; : "false";
process.env.SESSION_COOKIE_NAME = process.env.SESSION_COOKIE_NAME =
parsedConfig.data.server.session_cookie_name; parsedConfig.data.server.session_cookie_name;
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false"; process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
@ -270,6 +270,10 @@ export class Config {
parsedConfig.data.server.resource_access_token_param; parsedConfig.data.server.resource_access_token_param;
process.env.RESOURCE_SESSION_REQUEST_PARAM = process.env.RESOURCE_SESSION_REQUEST_PARAM =
parsedConfig.data.server.resource_session_request_param; parsedConfig.data.server.resource_session_request_param;
process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES = parsedConfig.data.flags
?.allow_base_domain_resources
? "true"
: "false";
this.rawConfig = parsedConfig.data; this.rawConfig = parsedConfig.data;
} }

View file

@ -34,7 +34,8 @@ const createResourceSchema = z
siteId: z.number(), siteId: z.number(),
http: z.boolean(), http: z.boolean(),
protocol: z.string(), protocol: z.string(),
proxyPort: z.number().optional() proxyPort: z.number().optional(),
isBaseDomain: z.boolean().optional()
}) })
.refine( .refine(
(data) => { (data) => {
@ -55,7 +56,7 @@ const createResourceSchema = z
) )
.refine( .refine(
(data) => { (data) => {
if (data.http) { if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success; return subdomainSchema.safeParse(data.subdomain).success;
} }
return true; return true;
@ -75,7 +76,7 @@ const createResourceSchema = z
return true; return true;
}, },
{ {
message: "Cannot update proxyPort" message: "Proxy port cannot be set"
} }
) )
.refine( .refine(
@ -88,6 +89,19 @@ const createResourceSchema = z
{ {
message: "Port 80 and 443 are reserved for http and https resources" message: "Port 80 and 443 are reserved for http and https resources"
} }
)
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
if (data.isBaseDomain) {
return false;
}
}
return true;
},
{
message: "Base domain resources are not allowed"
}
); );
export type CreateResourceResponse = Resource; export type CreateResourceResponse = Resource;
@ -108,7 +122,7 @@ export async function createResource(
); );
} }
let { name, subdomain, protocol, proxyPort, http } = parsedBody.data; let { name, subdomain, protocol, proxyPort, http, isBaseDomain } = parsedBody.data;
// Validate request params // Validate request params
const parsedParams = createResourceParamsSchema.safeParse(req.params); const parsedParams = createResourceParamsSchema.safeParse(req.params);
@ -145,7 +159,13 @@ export async function createResource(
); );
} }
const fullDomain = `${subdomain}.${org[0].domain}`; let fullDomain = "";
if (isBaseDomain) {
fullDomain = org[0].domain;
} else {
fullDomain = `${subdomain}.${org[0].domain}`;
}
// if http is false check to see if there is already a resource with the same port and protocol // if http is false check to see if there is already a resource with the same port and protocol
if (!http) { if (!http) {
const existingResource = await db const existingResource = await db
@ -195,7 +215,8 @@ export async function createResource(
http, http,
protocol, protocol,
proxyPort, proxyPort,
ssl: true ssl: true,
isBaseDomain
}) })
.returning(); .returning();

View file

@ -28,7 +28,8 @@ const updateResourceBodySchema = z
sso: z.boolean().optional(), sso: z.boolean().optional(),
blockAccess: z.boolean().optional(), blockAccess: z.boolean().optional(),
proxyPort: z.number().int().min(1).max(65535).optional(), proxyPort: z.number().int().min(1).max(65535).optional(),
emailWhitelistEnabled: z.boolean().optional() emailWhitelistEnabled: z.boolean().optional(),
isBaseDomain: z.boolean().optional()
}) })
.strict() .strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
@ -55,6 +56,19 @@ const updateResourceBodySchema = z
{ {
message: "Port 80 and 443 are reserved for http and https resources" message: "Port 80 and 443 are reserved for http and https resources"
} }
)
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
if (data.isBaseDomain) {
return false;
}
}
return true;
},
{
message: "Base domain resources are not allowed"
}
); );
export async function updateResource( export async function updateResource(
@ -104,6 +118,29 @@ export async function updateResource(
); );
} }
if (updateData.subdomain) {
if (!resource.http) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Cannot update subdomain for non-http resource"
)
);
}
const valid = subdomainSchema.safeParse(
updateData.subdomain
).success;
if (!valid) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid subdomain provided"
)
);
}
}
if (updateData.proxyPort) { if (updateData.proxyPort) {
const proxyPort = updateData.proxyPort; const proxyPort = updateData.proxyPort;
const existingResource = await db const existingResource = await db
@ -138,15 +175,32 @@ export async function updateResource(
); );
} }
const fullDomain = updateData.subdomain let fullDomain = "";
? `${updateData.subdomain}.${org.domain}` if (updateData.isBaseDomain) {
: undefined; fullDomain = org.domain;
} else {
fullDomain = `${updateData.subdomain}.${org.domain}`;
}
const updatePayload = { const updatePayload = {
...updateData, ...updateData,
...(fullDomain && { fullDomain }) ...(fullDomain && { fullDomain })
}; };
const [existingDomain] = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, fullDomain));
if (existingDomain && existingDomain.resourceId !== resourceId) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
const updatedResource = await db const updatedResource = await db
.update(resources) .update(resources)
.set(updatePayload) .set(updatePayload)

View file

@ -25,6 +25,7 @@ export async function traefikConfigProvider(
http: resources.http, http: resources.http,
proxyPort: resources.proxyPort, proxyPort: resources.proxyPort,
protocol: resources.protocol, protocol: resources.protocol,
isBaseDomain: resources.isBaseDomain,
// Site fields // Site fields
site: { site: {
siteId: sites.siteId, siteId: sites.siteId,
@ -110,11 +111,11 @@ export async function traefikConfigProvider(
const routerName = `${resource.resourceId}-router`; const routerName = `${resource.resourceId}-router`;
const serviceName = `${resource.resourceId}-service`; const serviceName = `${resource.resourceId}-service`;
const fullDomain = `${resource.subdomain}.${org.domain}`; const fullDomain = `${resource.fullDomain}`;
if (resource.http) { if (resource.http) {
// HTTP configuration remains the same // HTTP configuration remains the same
if (!resource.subdomain) { if (!resource.subdomain && !resource.isBaseDomain) {
continue; continue;
} }
@ -148,6 +149,8 @@ export async function traefikConfigProvider(
: {}) : {})
}; };
logger.debug(config.getRawConfig().traefik.prefer_wildcard_cert)
const additionalMiddlewares = const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || []; config.getRawConfig().traefik.additional_middlewares || [];

View file

@ -23,7 +23,12 @@ export async function copyInConfig() {
const allResources = await trx.select().from(resources); const allResources = await trx.select().from(resources);
for (const resource of allResources) { for (const resource of allResources) {
const fullDomain = `${resource.subdomain}.${domain}`; let fullDomain = "";
if (resource.isBaseDomain) {
fullDomain = domain;
} else {
fullDomain = `${resource.subdomain}.${domain}`;
}
await trx await trx
.update(resources) .update(resources)
.set({ fullDomain }) .set({ fullDomain })

View file

@ -3,8 +3,9 @@ import db, { exists } from "@server/db";
import path from "path"; import path from "path";
import semver from "semver"; import semver from "semver";
import { versionMigrations } from "@server/db/schema"; import { versionMigrations } from "@server/db/schema";
import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
import { SqliteError } from "better-sqlite3"; import { SqliteError } from "better-sqlite3";
import fs from "fs";
import m1 from "./scripts/1.0.0-beta1"; import m1 from "./scripts/1.0.0-beta1";
import m2 from "./scripts/1.0.0-beta2"; import m2 from "./scripts/1.0.0-beta2";
import m3 from "./scripts/1.0.0-beta3"; import m3 from "./scripts/1.0.0-beta3";
@ -12,6 +13,7 @@ import m4 from "./scripts/1.0.0-beta5";
import m5 from "./scripts/1.0.0-beta6"; import m5 from "./scripts/1.0.0-beta6";
import m6 from "./scripts/1.0.0-beta9"; import m6 from "./scripts/1.0.0-beta9";
import m7 from "./scripts/1.0.0-beta10"; import m7 from "./scripts/1.0.0-beta10";
import m8 from "./scripts/1.0.0-beta12";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -24,12 +26,41 @@ const migrations = [
{ version: "1.0.0-beta.5", run: m4 }, { version: "1.0.0-beta.5", run: m4 },
{ version: "1.0.0-beta.6", run: m5 }, { version: "1.0.0-beta.6", run: m5 },
{ version: "1.0.0-beta.9", run: m6 }, { version: "1.0.0-beta.9", run: m6 },
{ version: "1.0.0-beta.10", run: m7 } { version: "1.0.0-beta.10", run: m7 },
{ version: "1.0.0-beta.12", run: m8 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;
// Run the migrations await run();
await runMigrations();
async function run() {
// backup the database
backupDb();
// run the migrations
await runMigrations();
}
function backupDb() {
// make dir config/db/backups
const appPath = APP_PATH;
const dbDir = path.join(appPath, "db");
const backupsDir = path.join(dbDir, "backups");
// check if the backups directory exists and create it if it doesn't
if (!fs.existsSync(backupsDir)) {
fs.mkdirSync(backupsDir, { recursive: true });
}
// copy the db.sqlite file to backups
// add the date to the filename
const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath);
}
export async function runMigrations() { export async function runMigrations() {
try { try {
@ -105,7 +136,10 @@ async function executeScripts() {
`Successfully completed migration ${migration.version}` `Successfully completed migration ${migration.version}`
); );
} catch (e) { } catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { if (
e instanceof SqliteError &&
e.code === "SQLITE_CONSTRAINT_UNIQUE"
) {
console.error("Migration has already run! Skipping..."); console.error("Migration has already run! Skipping...");
continue; continue;
} }

View file

@ -0,0 +1,62 @@
import db from "@server/db";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.12...");
try {
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
if (!rawConfig.flags) {
rawConfig.flags = {};
}
rawConfig.flags.allow_base_domain_resources = true;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
console.log(`Added new config option: allow_base_domain_resources`);
} catch (e) {
console.log(
`Unable to add new config option: allow_base_domain_resources. This is not critical.`
);
console.error(e);
}
try {
db.transaction((trx) => {
trx.run(sql`ALTER TABLE 'resources' ADD 'isBaseDomain' integer;`);
});
console.log(`Added new column: isBaseDomain`);
} catch (e) {
console.log("Unable to add new column: isBaseDomain");
throw e;
}
console.log("Done.");
}

View file

@ -63,6 +63,8 @@ import { subdomainSchema } from "@server/schemas/subdomainSchema";
import Link from "next/link"; import Link from "next/link";
import { SquareArrowOutUpRight } from "lucide-react"; import { SquareArrowOutUpRight } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
const createResourceFormSchema = z const createResourceFormSchema = z
.object({ .object({
@ -71,7 +73,8 @@ const createResourceFormSchema = z
siteId: z.number(), siteId: z.number(),
http: z.boolean(), http: z.boolean(),
protocol: z.string(), protocol: z.string(),
proxyPort: z.number().optional() proxyPort: z.number().optional(),
isBaseDomain: z.boolean().optional()
}) })
.refine( .refine(
(data) => { (data) => {
@ -92,7 +95,7 @@ const createResourceFormSchema = z
) )
.refine( .refine(
(data) => { (data) => {
if (data.http) { if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success; return subdomainSchema.safeParse(data.subdomain).success;
} }
return true; return true;
@ -131,12 +134,15 @@ export default function CreateResourceForm({
const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain); const [domainSuffix, setDomainSuffix] = useState<string>(org.org.domain);
const [showSnippets, setShowSnippets] = useState(false); const [showSnippets, setShowSnippets] = useState(false);
const [resourceId, setResourceId] = useState<number | null>(null); const [resourceId, setResourceId] = useState<number | null>(null);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
"subdomain"
);
const form = useForm<CreateResourceFormValues>({ const form = useForm<CreateResourceFormValues>({
resolver: zodResolver(createResourceFormSchema), resolver: zodResolver(createResourceFormSchema),
defaultValues: { defaultValues: {
subdomain: "", subdomain: "",
name: "My Resource", name: "",
http: true, http: true,
protocol: "tcp" protocol: "tcp"
} }
@ -180,7 +186,8 @@ export default function CreateResourceForm({
http: data.http, http: data.http,
protocol: data.protocol, protocol: data.protocol,
proxyPort: data.http ? undefined : data.proxyPort, proxyPort: data.http ? undefined : data.proxyPort,
siteId: data.siteId siteId: data.siteId,
isBaseDomain: data.isBaseDomain
} }
) )
.catch((e) => { .catch((e) => {
@ -246,7 +253,7 @@ export default function CreateResourceForm({
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="Your name" placeholder="Resource name"
{...field} {...field}
/> />
</FormControl> </FormControl>
@ -291,33 +298,89 @@ export default function CreateResourceForm({
/> />
)} )}
{form.watch("http") &&
env.flags.allowBaseDomainResources && (
<div>
<RadioGroup
className="flex space-x-4"
defaultValue={domainType}
onValueChange={(val) => {
setDomainType(
val as any
);
form.setValue(
"isBaseDomain",
val === "basedomain"
);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="subdomain"
id="r1"
/>
<Label htmlFor="r1">
Subdomain
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="basedomain"
id="r2"
/>
<Label htmlFor="r2">
Base Domain
</Label>
</div>
</RadioGroup>
</div>
)}
{form.watch("http") && ( {form.watch("http") && (
<FormField <FormField
control={form.control} control={form.control}
name="subdomain" name="subdomain"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> {!env.flags
Subdomain .allowBaseDomainResources && (
</FormLabel> <FormLabel>
<FormControl> Subdomain
<CustomDomainInput </FormLabel>
value={ )}
field.value ?? {domainType ===
"" "subdomain" ? (
} <FormControl>
domainSuffix={ <CustomDomainInput
domainSuffix value={
} field.value ??
placeholder="Enter subdomain" ""
onChange={(value) => }
form.setValue( domainSuffix={
"subdomain", domainSuffix
}
placeholder="Subdomain"
onChange={(
value value
) ) =>
} form.setValue(
/> "subdomain",
</FormControl> value
)
}
/>
</FormControl>
) : (
<FormControl>
<Input
value={
domainSuffix
}
readOnly
disabled
/>
</FormControl>
)}
<FormDescription> <FormDescription>
This is the fully This is the fully
qualified domain name qualified domain name
@ -471,9 +534,7 @@ export default function CreateResourceForm({
site site
) => ( ) => (
<CommandItem <CommandItem
value={ value={`${site.siteId}:${site.name}:${site.niceId}`}
`${site.siteId}:${site.name}:${site.niceId}`
}
key={ key={
site.siteId site.siteId
} }
@ -567,21 +628,25 @@ export default function CreateResourceForm({
)} )}
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
{!showSnippets && <Button {!showSnippets && (
type="submit" <Button
form="create-resource-form" type="submit"
loading={loading} form="create-resource-form"
disabled={loading} loading={loading}
> disabled={loading}
Create Resource >
</Button>} Create Resource
</Button>
)}
{showSnippets && <Button {showSnippets && (
loading={loading} <Button
onClick={() => goToResource()} loading={loading}
> onClick={() => goToResource()}
Go to Resource >
</Button>} Go to Resource
</Button>
)}
<CredenzaClose asChild> <CredenzaClose asChild>
<Button variant="outline">Close</Button> <Button variant="outline">Close</Button>

View file

@ -2,11 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
InfoIcon,
ShieldCheck,
ShieldOff
} from "lucide-react";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { useResourceContext } from "@app/hooks/useResourceContext"; import { useResourceContext } from "@app/hooks/useResourceContext";
import { Separator } from "@app/components/ui/separator"; import { Separator } from "@app/components/ui/separator";
@ -26,9 +22,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
const { org } = useOrgContext(); const { org } = useOrgContext();
const { resource, authInfo } = useResourceContext(); const { resource, authInfo } = useResourceContext();
const fullUrl = `${resource.ssl ? "https" : "http"}://${ let fullUrl = `${resource.ssl ? "https" : "http"}://`;
resource.subdomain if (resource.isBaseDomain) {
}.${org.org.domain}`; fullUrl = fullUrl + org.org.domain;
} else {
fullUrl = fullUrl + `${resource.subdomain}.${org.org.domain}`;
}
return ( return (
<Alert> <Alert>
@ -82,7 +81,9 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
<InfoSection> <InfoSection>
<InfoSectionTitle>Protocol</InfoSectionTitle> <InfoSectionTitle>Protocol</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
<span>{resource.protocol.toUpperCase()}</span> <span>
{resource.protocol.toUpperCase()}
</span>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
<Separator orientation="vertical" /> <Separator orientation="vertical" />

View file

@ -132,9 +132,8 @@ export default function ReverseProxyTargets(props: {
defaultValues: { defaultValues: {
ip: "", ip: "",
method: resource.http ? "http" : null, method: resource.http ? "http" : null,
port: ""
// protocol: "TCP", // protocol: "TCP",
} } as z.infer<typeof addTargetSchema>
}); });
useEffect(() => { useEffect(() => {

View file

@ -51,13 +51,17 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { subdomainSchema } from "@server/schemas/subdomainSchema"; import { subdomainSchema } from "@server/schemas/subdomainSchema";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { pullEnv } from "@app/lib/pullEnv";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
const GeneralFormSchema = z const GeneralFormSchema = z
.object({ .object({
subdomain: z.string().optional(), subdomain: z.string().optional(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
proxyPort: z.number().optional(), proxyPort: z.number().optional(),
http: z.boolean() http: z.boolean(),
isBaseDomain: z.boolean().optional()
}) })
.refine( .refine(
(data) => { (data) => {
@ -78,7 +82,7 @@ const GeneralFormSchema = z
) )
.refine( .refine(
(data) => { (data) => {
if (data.http) { if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success; return subdomainSchema.safeParse(data.subdomain).success;
} }
return true; return true;
@ -103,9 +107,11 @@ export default function GeneralForm() {
const { org } = useOrgContext(); const { org } = useOrgContext();
const router = useRouter(); const router = useRouter();
const { env } = useEnvContext();
const orgId = params.orgId; const orgId = params.orgId;
const api = createApiClient(useEnvContext()); const api = createApiClient({ env });
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]); const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [saveLoading, setSaveLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false);
@ -113,13 +119,18 @@ export default function GeneralForm() {
const [transferLoading, setTransferLoading] = useState(false); const [transferLoading, setTransferLoading] = useState(false);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
resource.isBaseDomain ? "basedomain" : "subdomain"
);
const form = useForm<GeneralFormValues>({ const form = useForm<GeneralFormValues>({
resolver: zodResolver(GeneralFormSchema), resolver: zodResolver(GeneralFormSchema),
defaultValues: { defaultValues: {
name: resource.name, name: resource.name,
subdomain: resource.subdomain ? resource.subdomain : undefined, subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined, proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http http: resource.http,
isBaseDomain: resource.isBaseDomain ? true : false
}, },
mode: "onChange" mode: "onChange"
}); });
@ -148,7 +159,8 @@ export default function GeneralForm() {
.post(`resource/${resource?.resourceId}`, { .post(`resource/${resource?.resourceId}`, {
name: data.name, name: data.name,
subdomain: data.subdomain, subdomain: data.subdomain,
proxyPort: data.proxyPort proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain
}) })
.catch((e) => { .catch((e) => {
toast({ toast({
@ -170,7 +182,8 @@ export default function GeneralForm() {
updateResource({ updateResource({
name: data.name, name: data.name,
subdomain: data.subdomain, subdomain: data.subdomain,
proxyPort: data.proxyPort proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain
}); });
} }
setSaveLoading(false); setSaveLoading(false);
@ -242,40 +255,103 @@ export default function GeneralForm() {
)} )}
/> />
{resource.http ? ( {resource.http && (
<FormField <>
control={form.control} {env.flags.allowBaseDomainResources && (
name="subdomain" <div>
render={({ field }) => ( <RadioGroup
<FormItem> className="flex space-x-4"
<FormLabel>Subdomain</FormLabel> defaultValue={domainType}
<FormControl> onValueChange={(val) => {
<CustomDomainInput setDomainType(
value={ val as any
field.value || "" );
} form.setValue(
domainSuffix={ "isBaseDomain",
domainSuffix val === "basedomain"
} );
placeholder="Enter subdomain" }}
onChange={(value) => >
form.setValue( <div className="flex items-center space-x-2">
"subdomain", <RadioGroupItem
value value="subdomain"
) id="r1"
} />
/> <Label htmlFor="r1">
</FormControl> Subdomain
<FormDescription> </Label>
This is the subdomain that </div>
will be used to access the <div className="flex items-center space-x-2">
resource. <RadioGroupItem
</FormDescription> value="basedomain"
<FormMessage /> id="r2"
</FormItem> />
<Label htmlFor="r2">
Base Domain
</Label>
</div>
</RadioGroup>
</div>
)} )}
/>
) : ( <FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
{!env.flags
.allowBaseDomainResources && (
<FormLabel>
Subdomain
</FormLabel>
)}
{domainType ===
"subdomain" ? (
<FormControl>
<CustomDomainInput
value={
field.value ||
""
}
domainSuffix={
domainSuffix
}
placeholder="Enter subdomain"
onChange={(
value
) =>
form.setValue(
"subdomain",
value
)
}
/>
</FormControl>
) : (
<FormControl>
<Input
value={
domainSuffix
}
readOnly
disabled
/>
</FormControl>
)}
<FormDescription>
This is the subdomain
that will be used to
access the resource.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{!resource.http && (
<FormField <FormField
control={form.control} control={form.control}
name="proxyPort" name="proxyPort"

View file

@ -1,30 +1,53 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react" import { Check } from "lucide-react";
import { cn } from "@app/lib/cn" import { cn } from "@app/lib/cn";
const Checkbox = React.forwardRef< const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>, React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className className
)} )}
{...props} {...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
> >
<Check className="h-4 w-4" /> <CheckboxPrimitive.Indicator
</CheckboxPrimitive.Indicator> className={cn("flex items-center justify-center text-current")}
</CheckboxPrimitive.Root> >
)) <Check className="h-4 w-4" />
Checkbox.displayName = CheckboxPrimitive.Root.displayName </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox } interface CheckboxWithLabelProps
extends React.ComponentPropsWithoutRef<typeof Checkbox> {
label: string;
}
const CheckboxWithLabel = React.forwardRef<
React.ElementRef<typeof Checkbox>,
CheckboxWithLabelProps
>(({ className, label, id, ...props }, ref) => {
return (
<div className={cn("flex items-center space-x-2", className)}>
<Checkbox id={id} ref={ref} {...props} />
<label
htmlFor={id}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
</label>
</div>
);
});
CheckboxWithLabel.displayName = "CheckboxWithLabel";
export { Checkbox, CheckboxWithLabel };

View file

@ -6,8 +6,10 @@ export function pullEnv(): Env {
nextPort: process.env.NEXT_PORT as string, nextPort: process.env.NEXT_PORT as string,
externalPort: process.env.SERVER_EXTERNAL_PORT as string, externalPort: process.env.SERVER_EXTERNAL_PORT as string,
sessionCookieName: process.env.SESSION_COOKIE_NAME as string, sessionCookieName: process.env.SESSION_COOKIE_NAME as string,
resourceAccessTokenParam: process.env.RESOURCE_ACCESS_TOKEN_PARAM as string, resourceAccessTokenParam: process.env
resourceSessionRequestParam: process.env.RESOURCE_SESSION_REQUEST_PARAM as string .RESOURCE_ACCESS_TOKEN_PARAM as string,
resourceSessionRequestParam: process.env
.RESOURCE_SESSION_REQUEST_PARAM as string
}, },
app: { app: {
environment: process.env.ENVIRONMENT as string, environment: process.env.ENVIRONMENT as string,
@ -29,6 +31,10 @@ export function pullEnv(): Env {
: false, : false,
allowRawResources: allowRawResources:
process.env.FLAGS_ALLOW_RAW_RESOURCES === "true" ? true : false, process.env.FLAGS_ALLOW_RAW_RESOURCES === "true" ? true : false,
allowBaseDomainResources:
process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES === "true"
? true
: false
} }
}; };
} }

View file

@ -18,5 +18,6 @@ export type Env = {
disableUserCreateOrg: boolean; disableUserCreateOrg: boolean;
emailVerificationRequired: boolean; emailVerificationRequired: boolean;
allowRawResources: boolean; allowRawResources: boolean;
allowBaseDomainResources: boolean;
} }
}; };