Merge branch 'jln-brtn-dev' into dev

This commit is contained in:
Owen
2025-12-20 15:34:53 -05:00
17 changed files with 477 additions and 281 deletions

View File

@@ -1,5 +1,7 @@
{
"setupCreate": "Create the organization, site, and resources",
"headerAuthCompatibilityInfo": "Enable this to force a 401 Unauthorized response when an authentication token is missing. This is required for browsers or specific HTTP libraries that do not send credentials without a server challenge.",
"headerAuthCompatibility": "Extended compatibility",
"setupNewOrg": "New Organization",
"setupCreateOrg": "Create Organization",
"setupCreateResources": "Create Resources",

View File

@@ -456,6 +456,14 @@ export const resourceHeaderAuth = pgTable("resourceHeaderAuth", {
headerAuthHash: varchar("headerAuthHash").notNull()
});
export const resourceHeaderAuthExtendedCompatibility = pgTable("resourceHeaderAuthExtendedCompatibility", {
headerAuthExtendedCompatibilityId: serial("headerAuthExtendedCompatibilityId").primaryKey(),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
extendedCompatibilityIsActivated: boolean("extendedCompatibilityIsActivated").notNull().default(false),
});
export const resourceAccessToken = pgTable("resourceAccessToken", {
accessTokenId: varchar("accessTokenId").primaryKey(),
orgId: varchar("orgId")
@@ -856,6 +864,7 @@ export type ResourceSession = InferSelectModel<typeof resourceSessions>;
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
export type ResourceHeaderAuth = InferSelectModel<typeof resourceHeaderAuth>;
export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<typeof resourceHeaderAuthExtendedCompatibility>;
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;

View File

@@ -1,4 +1,6 @@
import { db, loginPage, LoginPage, loginPageOrg, Org, orgs } from "@server/db";
import {
db, loginPage, LoginPage, loginPageOrg, Org, orgs,
} from "@server/db";
import {
Resource,
ResourcePassword,
@@ -14,7 +16,9 @@ import {
sessions,
userOrgs,
userResources,
users
users,
ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility
} from "@server/db";
import { and, eq } from "drizzle-orm";
@@ -23,6 +27,7 @@ export type ResourceWithAuth = {
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null
org: Org;
};
@@ -52,7 +57,14 @@ export async function getResourceByDomain(
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId)
)
.innerJoin(
orgs,
eq(orgs.orgId, resources.orgId)
)
.where(eq(resources.fullDomain, domain))
.limit(1);
@@ -65,6 +77,7 @@ export async function getResourceByDomain(
pincode: result.resourcePincode,
password: result.resourcePassword,
headerAuth: result.resourceHeaderAuth,
headerAuthExtendedCompatibility: result.resourceHeaderAuthExtendedCompatibility,
org: result.orgs
};
}

View File

@@ -628,6 +628,16 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
headerAuthHash: text("headerAuthHash").notNull()
});
export const resourceHeaderAuthExtendedCompatibility = sqliteTable("resourceHeaderAuthExtendedCompatibility", {
headerAuthExtendedCompatibilityId: integer("headerAuthExtendedCompatibilityId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, {onDelete: "cascade"}),
extendedCompatibilityIsActivated: integer("extendedCompatibilityIsActivated", {mode: "boolean"}).notNull()
});
export const resourceAccessToken = sqliteTable("resourceAccessToken", {
accessTokenId: text("accessTokenId").primaryKey(),
orgId: text("orgId")
@@ -913,6 +923,7 @@ export type ResourceSession = InferSelectModel<typeof resourceSessions>;
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
export type ResourceHeaderAuth = InferSelectModel<typeof resourceHeaderAuth>;
export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<typeof resourceHeaderAuthExtendedCompatibility>;
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;

View File

@@ -2,7 +2,7 @@ import {
domains,
orgDomains,
Resource,
resourceHeaderAuth,
resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility,
resourcePincode,
resourceRules,
resourceWhitelist,
@@ -287,21 +287,39 @@ export async function updateProxyResources(
existingResource.resourceId
)
);
await trx
.delete(resourceHeaderAuthExtendedCompatibility)
.where(
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
existingResource.resourceId
)
);
if (resourceData.auth?.["basic-auth"]) {
const headerAuthUser =
resourceData.auth?.["basic-auth"]?.user;
const headerAuthPassword =
resourceData.auth?.["basic-auth"]?.password;
if (headerAuthUser && headerAuthPassword) {
const headerAuthExtendedCompatibility =
resourceData.auth?.["basic-auth"]?.extendedCompatibility;
if (headerAuthUser && headerAuthPassword && headerAuthExtendedCompatibility !== null) {
const headerAuthHash = await hashPassword(
Buffer.from(
`${headerAuthUser}:${headerAuthPassword}`
).toString("base64")
);
await trx.insert(resourceHeaderAuth).values({
await Promise.all([
trx.insert(resourceHeaderAuth).values({
resourceId: existingResource.resourceId,
headerAuthHash
});
}),
trx.insert(resourceHeaderAuthExtendedCompatibility).values({
resourceId: existingResource.resourceId,
extendedCompatibilityIsActivated: headerAuthExtendedCompatibility
})
]);
}
}
@@ -656,18 +674,25 @@ export async function updateProxyResources(
const headerAuthUser = resourceData.auth?.["basic-auth"]?.user;
const headerAuthPassword =
resourceData.auth?.["basic-auth"]?.password;
const headerAuthExtendedCompatibility = resourceData.auth?.["basic-auth"]?.extendedCompatibility;
if (headerAuthUser && headerAuthPassword) {
if (headerAuthUser && headerAuthPassword && headerAuthExtendedCompatibility !== null) {
const headerAuthHash = await hashPassword(
Buffer.from(
`${headerAuthUser}:${headerAuthPassword}`
).toString("base64")
);
await trx.insert(resourceHeaderAuth).values({
await Promise.all([
trx.insert(resourceHeaderAuth).values({
resourceId: newResource.resourceId,
headerAuthHash
});
}),
trx.insert(resourceHeaderAuthExtendedCompatibility).values({
resourceId: newResource.resourceId,
extendedCompatibilityIsActivated: headerAuthExtendedCompatibility
}),
]);
}
}

View File

@@ -53,12 +53,11 @@ export const AuthSchema = z.object({
// pincode has to have 6 digits
pincode: z.number().min(100000).max(999999).optional(),
password: z.string().min(1).optional(),
"basic-auth": z
.object({
"basic-auth": z.object({
user: z.string().min(1),
password: z.string().min(1)
})
.optional(),
password: z.string().min(1),
extendedCompatibility: z.boolean().default(false)
}).optional(),
"sso-enabled": z.boolean().optional().default(false),
"sso-roles": z
.array(z.string())

View File

@@ -36,8 +36,10 @@ import {
LoginPage,
resourceHeaderAuth,
ResourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
ResourceHeaderAuthExtendedCompatibility,
orgs,
requestAuditLog
requestAuditLog,
} from "@server/db";
import {
resources,
@@ -175,6 +177,7 @@ export type ResourceWithAuth = {
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
};
export type UserSessionWithUser = {
@@ -498,6 +501,10 @@ hybridRouter.get(
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, domain))
.limit(1);
@@ -530,7 +537,8 @@ hybridRouter.get(
resource: result.resources,
pincode: result.resourcePincode,
password: result.resourcePassword,
headerAuth: result.resourceHeaderAuth
headerAuth: result.resourceHeaderAuth,
headerAuthExtendedCompatibility: result.resourceHeaderAuthExtendedCompatibility
};
return response<ResourceWithAuth>(res, {

View File

@@ -10,7 +10,7 @@ Reasons:
100 - Allowed by Rule
101 - Allowed No Auth
102 - Valid Access Token
103 - Valid header auth
103 - Valid Header Auth (HTTP Basic Auth)
104 - Valid Pincode
105 - Valid Password
106 - Valid email

View File

@@ -13,7 +13,7 @@ import {
LoginPage,
Org,
Resource,
ResourceHeaderAuth,
ResourceHeaderAuth, ResourceHeaderAuthExtendedCompatibility,
ResourcePassword,
ResourcePincode,
ResourceRule,
@@ -66,6 +66,7 @@ type BasicUserData = {
export type VerifyUserResponse = {
valid: boolean;
headerAuthChallenged?: boolean;
redirectUrl?: string;
userData?: BasicUserData;
};
@@ -147,6 +148,7 @@ export async function verifyResourceSession(
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
org: Org;
}
| undefined = cache.get(resourceCacheKey);
@@ -176,7 +178,7 @@ export async function verifyResourceSession(
cache.set(resourceCacheKey, resourceData, 5);
}
const { resource, pincode, password, headerAuth } = resourceData;
const { resource, pincode, password, headerAuth, headerAuthExtendedCompatibility } = resourceData;
if (!resource) {
logger.debug(`Resource not found ${cleanHost}`);
@@ -456,7 +458,8 @@ export async function verifyResourceSession(
!sso &&
!pincode &&
!password &&
!resource.emailWhitelistEnabled
!resource.emailWhitelistEnabled &&
!headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated
) {
logRequestAudit(
{
@@ -471,13 +474,15 @@ export async function verifyResourceSession(
return notAllowed(res);
}
} else if (headerAuth) {
}
else if (headerAuth) {
// if there are no other auth methods we need to return unauthorized if nothing is provided
if (
!sso &&
!pincode &&
!password &&
!resource.emailWhitelistEnabled
!resource.emailWhitelistEnabled &&
!headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated
) {
logRequestAudit(
{
@@ -563,7 +568,7 @@ export async function verifyResourceSession(
}
if (resourceSession) {
// only run this check if not SSO sesion; SSO session length is checked later
// only run this check if not SSO session; SSO session length is checked later
const accessPolicy = await enforceResourceSessionLength(
resourceSession,
resourceData.org
@@ -707,6 +712,11 @@ export async function verifyResourceSession(
}
}
// If headerAuthExtendedCompatibility is activated but no clientHeaderAuth provided, force client to challenge
if (headerAuthExtendedCompatibility && headerAuthExtendedCompatibility.extendedCompatibilityIsActivated && !clientHeaderAuth){
return headerAuthChallenged(res, redirectPath, resource.orgId);
}
logger.debug("No more auth to check, resource not allowed");
if (config.getRawConfig().app.log_failed_attempts) {
@@ -839,6 +849,46 @@ function allowed(res: Response, userData?: BasicUserData) {
return response<VerifyUserResponse>(res, data);
}
async function headerAuthChallenged(
res: Response,
redirectPath?: string,
orgId?: string
) {
let loginPage: LoginPage | null = null;
if (orgId) {
const { tier } = await getOrgTierData(orgId); // returns null in oss
if (tier === TierId.STANDARD) {
loginPage = await getOrgLoginPage(orgId);
}
}
let redirectUrl: string | undefined = undefined;
if (redirectPath) {
let endpoint: string;
if (loginPage && loginPage.domainId && loginPage.fullDomain) {
const secure = config
.getRawConfig()
.app.dashboard_url?.startsWith("https");
const method = secure ? "https" : "http";
endpoint = `${method}://${loginPage.fullDomain}`;
} else {
endpoint = config.getRawConfig().app.dashboard_url!;
}
redirectUrl = `${endpoint}${redirectPath}`;
}
const data = {
data: { headerAuthChallenged: true, valid: false, redirectUrl },
success: true,
error: false,
message: "Access denied",
status: HttpCode.OK
};
logger.debug(JSON.stringify(data));
return response<VerifyUserResponse>(res, data);
}
async function isUserAllowedToAccessResource(
userSessionId: string,
resource: Resource,

View File

@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
import {z} from "zod";
import {
db,
resourceHeaderAuth,
resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility,
resourcePassword,
resourcePincode,
resources
@@ -27,6 +27,7 @@ export type GetResourceAuthInfoResponse = {
password: boolean;
pincode: boolean;
headerAuth: boolean;
headerAuthExtendedCompatibility: boolean;
sso: boolean;
blockAccess: boolean;
url: string;
@@ -76,6 +77,13 @@ export async function getResourceAuthInfo(
resources.resourceId
)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resources.resourceId
)
)
.where(eq(resources.resourceId, Number(resourceGuid)))
.limit(1)
: await db
@@ -89,6 +97,7 @@ export async function getResourceAuthInfo(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(
@@ -96,6 +105,13 @@ export async function getResourceAuthInfo(
resources.resourceId
)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resources.resourceId
)
)
.where(eq(resources.resourceGuid, resourceGuid))
.limit(1);
@@ -109,6 +125,7 @@ export async function getResourceAuthInfo(
const pincode = result?.resourcePincode;
const password = result?.resourcePassword;
const headerAuth = result?.resourceHeaderAuth;
const headerAuthExtendedCompatibility = result?.resourceHeaderAuthExtendedCompatibility;
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
@@ -121,6 +138,7 @@ export async function getResourceAuthInfo(
password: password !== null,
pincode: pincode !== null,
headerAuth: headerAuth !== null,
headerAuthExtendedCompatibility: headerAuthExtendedCompatibility !== null,
sso: resource.sso,
blockAccess: resource.blockAccess,
url,

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resourceHeaderAuth } from "@server/db";
import {db, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility} from "@server/db";
import {
resources,
userResources,
@@ -109,7 +109,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
domainId: resources.domainId,
niceId: resources.niceId,
headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId: resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
targetId: targets.targetId,
targetIp: targets.ip,
targetPort: targets.port,
@@ -131,6 +131,10 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId)
)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(
targetHealthCheck,

View File

@@ -1,6 +1,6 @@
import {Request, Response, NextFunction} from "express";
import {z} from "zod";
import { db, resourceHeaderAuth } from "@server/db";
import {db, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility} from "@server/db";
import {eq} from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -16,7 +16,8 @@ const setResourceAuthMethodsParamsSchema = z.object({
const setResourceAuthMethodsBodySchema = z.strictObject({
user: z.string().min(4).max(100).nullable(),
password: z.string().min(4).max(100).nullable()
password: z.string().min(4).max(100).nullable(),
extendedCompatibility: z.boolean().nullable()
});
registry.registerPath({
@@ -67,22 +68,28 @@ export async function setResourceHeaderAuth(
}
const {resourceId} = parsedParams.data;
const { user, password } = parsedBody.data;
const {user, password, extendedCompatibility} = parsedBody.data;
await db.transaction(async (trx) => {
await trx
.delete(resourceHeaderAuth)
.where(eq(resourceHeaderAuth.resourceId, resourceId));
await trx.delete(resourceHeaderAuthExtendedCompatibility).where(eq(resourceHeaderAuthExtendedCompatibility.resourceId, resourceId));
if (user && password) {
const headerAuthHash = await hashPassword(
Buffer.from(`${user}:${password}`).toString("base64")
);
if (user && password && extendedCompatibility !== null) {
const headerAuthHash = await hashPassword(Buffer.from(`${user}:${password}`).toString("base64"));
await trx
await Promise.all([
trx
.insert(resourceHeaderAuth)
.values({ resourceId, headerAuthHash });
.values({resourceId, headerAuthHash}),
trx
.insert(resourceHeaderAuthExtendedCompatibility)
.values({resourceId, extendedCompatibilityIsActivated: extendedCompatibility})
]);
}
});
return response(res, {

View File

@@ -397,7 +397,8 @@ export default function ResourceAuthenticationPage() {
api.post(`/resource/${resource.resourceId}/header-auth`, {
user: null,
password: null
password: null,
extendedCompatibility: null,
})
.then(() => {
toast({

View File

@@ -31,17 +31,21 @@ import { Resource } from "@server/db";
import {createApiClient} from "@app/lib/api";
import {useEnvContext} from "@app/hooks/useEnvContext";
import {useTranslations} from "next-intl";
import {SwitchInput} from "@/components/SwitchInput";
import {InfoPopup} from "@/components/ui/info-popup";
const setHeaderAuthFormSchema = z.object({
user: z.string().min(4).max(100),
password: z.string().min(4).max(100)
password: z.string().min(4).max(100),
extendedCompatibility: z.boolean()
});
type SetHeaderAuthFormValues = z.infer<typeof setHeaderAuthFormSchema>;
const defaultValues: Partial<SetHeaderAuthFormValues> = {
user: "",
password: ""
password: "",
extendedCompatibility: false
};
type SetHeaderAuthFormProps = {
@@ -78,22 +82,10 @@ export default function SetResourceHeaderAuthForm({
async function onSubmit(data: SetHeaderAuthFormValues) {
setLoading(true);
api.post<AxiosResponse<Resource>>(
`/resource/${resourceId}/header-auth`,
{
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/header-auth`, {
user: data.user,
password: data.password
}
)
.catch((e) => {
toast({
variant: "destructive",
title: t("resourceErrorHeaderAuthSetup"),
description: formatAxiosError(
e,
t("resourceErrorHeaderAuthSetupDescription")
)
});
password: data.password,
extendedCompatibility: data.extendedCompatibility
})
.then(() => {
toast({
@@ -105,6 +97,16 @@ export default function SetResourceHeaderAuthForm({
onSetHeaderAuth();
}
})
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorHeaderAuthSetup'),
description: formatAxiosError(
e,
t('resourceErrorHeaderAuthSetupDescription')
)
});
})
.finally(() => setLoading(false));
}
@@ -170,6 +172,24 @@ export default function SetResourceHeaderAuthForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="extendedCompatibility"
render={({field}) => (
<FormItem>
<FormControl>
<SwitchInput
id="header-auth-compatibility-toggle"
label={t("headerAuthCompatibility")}
info={t('headerAuthCompatibilityInfo')}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>

View File

@@ -77,16 +77,6 @@ export default function SetResourcePasswordForm({
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/password`, {
password: data.password
})
.catch((e) => {
toast({
variant: "destructive",
title: t("resourceErrorPasswordSetup"),
description: formatAxiosError(
e,
t("resourceErrorPasswordSetupDescription")
)
});
})
.then(() => {
toast({
@@ -98,6 +88,16 @@ export default function SetResourcePasswordForm({
onSetPassword();
}
})
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPasswordSetup'),
description: formatAxiosError(
e,
t('resourceErrorPasswordSetupDescription')
)
});
})
.finally(() => setLoading(false));
}

View File

@@ -83,16 +83,6 @@ export default function SetResourcePincodeForm({
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/pincode`, {
pincode: data.pincode
})
.catch((e) => {
toast({
variant: "destructive",
title: t("resourceErrorPincodeSetup"),
description: formatAxiosError(
e,
t("resourceErrorPincodeSetupDescription")
)
});
})
.then(() => {
toast({
@@ -104,6 +94,16 @@ export default function SetResourcePincodeForm({
onSetPincode();
}
})
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPincodeSetup'),
description: formatAxiosError(
e,
t('resourceErrorPincodeSetupDescription')
)
});
})
.finally(() => setLoading(false));
}

View File

@@ -1,11 +1,16 @@
import React from "react";
import {Switch} from "./ui/switch";
import {Label} from "./ui/label";
import {Button} from "@/components/ui/button";
import {Info} from "lucide-react";
import {info} from "winston";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
interface SwitchComponentProps {
id: string;
label?: string;
description?: string;
info?: string;
checked?: boolean;
defaultChecked?: boolean;
disabled?: boolean;
@@ -16,11 +21,23 @@ export function SwitchInput({
id,
label,
description,
info,
disabled,
checked,
defaultChecked = false,
onCheckedChange
}: SwitchComponentProps) {
const defaultTrigger = (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full p-0"
>
<Info className="h-4 w-4"/>
<span className="sr-only">Show info</span>
</Button>
);
return (
<div>
<div className="flex items-center space-x-2 mb-2">
@@ -32,6 +49,18 @@ export function SwitchInput({
disabled={disabled}
/>
{label && <Label htmlFor={id}>{label}</Label>}
{info && <Popover>
<PopoverTrigger asChild>
{defaultTrigger}
</PopoverTrigger>
<PopoverContent className="w-80">
{info && (
<p className="text-sm text-muted-foreground">
{info}
</p>
)}
</PopoverContent>
</Popover>}
</div>
{description && (
<span className="text-muted-foreground text-sm">