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", "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", "setupNewOrg": "New Organization",
"setupCreateOrg": "Create Organization", "setupCreateOrg": "Create Organization",
"setupCreateResources": "Create Resources", "setupCreateResources": "Create Resources",

View File

@@ -456,6 +456,14 @@ export const resourceHeaderAuth = pgTable("resourceHeaderAuth", {
headerAuthHash: varchar("headerAuthHash").notNull() 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", { export const resourceAccessToken = pgTable("resourceAccessToken", {
accessTokenId: varchar("accessTokenId").primaryKey(), accessTokenId: varchar("accessTokenId").primaryKey(),
orgId: varchar("orgId") orgId: varchar("orgId")
@@ -856,6 +864,7 @@ export type ResourceSession = InferSelectModel<typeof resourceSessions>;
export type ResourcePincode = InferSelectModel<typeof resourcePincode>; export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
export type ResourcePassword = InferSelectModel<typeof resourcePassword>; export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
export type ResourceHeaderAuth = InferSelectModel<typeof resourceHeaderAuth>; export type ResourceHeaderAuth = InferSelectModel<typeof resourceHeaderAuth>;
export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<typeof resourceHeaderAuthExtendedCompatibility>;
export type ResourceOtp = InferSelectModel<typeof resourceOtp>; export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>; export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>; 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 { import {
Resource, Resource,
ResourcePassword, ResourcePassword,
@@ -14,7 +16,9 @@ import {
sessions, sessions,
userOrgs, userOrgs,
userResources, userResources,
users users,
ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility
} from "@server/db"; } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
@@ -23,6 +27,7 @@ export type ResourceWithAuth = {
pincode: ResourcePincode | null; pincode: ResourcePincode | null;
password: ResourcePassword | null; password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null; headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null
org: Org; org: Org;
}; };
@@ -52,7 +57,14 @@ export async function getResourceByDomain(
resourceHeaderAuth, resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId) 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)) .where(eq(resources.fullDomain, domain))
.limit(1); .limit(1);
@@ -65,6 +77,7 @@ export async function getResourceByDomain(
pincode: result.resourcePincode, pincode: result.resourcePincode,
password: result.resourcePassword, password: result.resourcePassword,
headerAuth: result.resourceHeaderAuth, headerAuth: result.resourceHeaderAuth,
headerAuthExtendedCompatibility: result.resourceHeaderAuthExtendedCompatibility,
org: result.orgs org: result.orgs
}; };
} }

View File

@@ -628,6 +628,16 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
headerAuthHash: text("headerAuthHash").notNull() 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", { export const resourceAccessToken = sqliteTable("resourceAccessToken", {
accessTokenId: text("accessTokenId").primaryKey(), accessTokenId: text("accessTokenId").primaryKey(),
orgId: text("orgId") orgId: text("orgId")
@@ -913,6 +923,7 @@ export type ResourceSession = InferSelectModel<typeof resourceSessions>;
export type ResourcePincode = InferSelectModel<typeof resourcePincode>; export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
export type ResourcePassword = InferSelectModel<typeof resourcePassword>; export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
export type ResourceHeaderAuth = InferSelectModel<typeof resourceHeaderAuth>; export type ResourceHeaderAuth = InferSelectModel<typeof resourceHeaderAuth>;
export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<typeof resourceHeaderAuthExtendedCompatibility>;
export type ResourceOtp = InferSelectModel<typeof resourceOtp>; export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>; export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>; export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;

View File

@@ -2,7 +2,7 @@ import {
domains, domains,
orgDomains, orgDomains,
Resource, Resource,
resourceHeaderAuth, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility,
resourcePincode, resourcePincode,
resourceRules, resourceRules,
resourceWhitelist, resourceWhitelist,
@@ -287,21 +287,39 @@ export async function updateProxyResources(
existingResource.resourceId existingResource.resourceId
) )
); );
await trx
.delete(resourceHeaderAuthExtendedCompatibility)
.where(
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
existingResource.resourceId
)
);
if (resourceData.auth?.["basic-auth"]) { if (resourceData.auth?.["basic-auth"]) {
const headerAuthUser = const headerAuthUser =
resourceData.auth?.["basic-auth"]?.user; resourceData.auth?.["basic-auth"]?.user;
const headerAuthPassword = const headerAuthPassword =
resourceData.auth?.["basic-auth"]?.password; resourceData.auth?.["basic-auth"]?.password;
if (headerAuthUser && headerAuthPassword) { const headerAuthExtendedCompatibility =
resourceData.auth?.["basic-auth"]?.extendedCompatibility;
if (headerAuthUser && headerAuthPassword && headerAuthExtendedCompatibility !== null) {
const headerAuthHash = await hashPassword( const headerAuthHash = await hashPassword(
Buffer.from( Buffer.from(
`${headerAuthUser}:${headerAuthPassword}` `${headerAuthUser}:${headerAuthPassword}`
).toString("base64") ).toString("base64")
); );
await trx.insert(resourceHeaderAuth).values({ await Promise.all([
trx.insert(resourceHeaderAuth).values({
resourceId: existingResource.resourceId, resourceId: existingResource.resourceId,
headerAuthHash 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 headerAuthUser = resourceData.auth?.["basic-auth"]?.user;
const headerAuthPassword = const headerAuthPassword =
resourceData.auth?.["basic-auth"]?.password; resourceData.auth?.["basic-auth"]?.password;
const headerAuthExtendedCompatibility = resourceData.auth?.["basic-auth"]?.extendedCompatibility;
if (headerAuthUser && headerAuthPassword) { if (headerAuthUser && headerAuthPassword && headerAuthExtendedCompatibility !== null) {
const headerAuthHash = await hashPassword( const headerAuthHash = await hashPassword(
Buffer.from( Buffer.from(
`${headerAuthUser}:${headerAuthPassword}` `${headerAuthUser}:${headerAuthPassword}`
).toString("base64") ).toString("base64")
); );
await trx.insert(resourceHeaderAuth).values({ await Promise.all([
trx.insert(resourceHeaderAuth).values({
resourceId: newResource.resourceId, resourceId: newResource.resourceId,
headerAuthHash 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 has to have 6 digits
pincode: z.number().min(100000).max(999999).optional(), pincode: z.number().min(100000).max(999999).optional(),
password: z.string().min(1).optional(), password: z.string().min(1).optional(),
"basic-auth": z "basic-auth": z.object({
.object({
user: z.string().min(1), user: z.string().min(1),
password: z.string().min(1) password: z.string().min(1),
}) extendedCompatibility: z.boolean().default(false)
.optional(), }).optional(),
"sso-enabled": z.boolean().optional().default(false), "sso-enabled": z.boolean().optional().default(false),
"sso-roles": z "sso-roles": z
.array(z.string()) .array(z.string())

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ import {
LoginPage, LoginPage,
Org, Org,
Resource, Resource,
ResourceHeaderAuth, ResourceHeaderAuth, ResourceHeaderAuthExtendedCompatibility,
ResourcePassword, ResourcePassword,
ResourcePincode, ResourcePincode,
ResourceRule, ResourceRule,
@@ -66,6 +66,7 @@ type BasicUserData = {
export type VerifyUserResponse = { export type VerifyUserResponse = {
valid: boolean; valid: boolean;
headerAuthChallenged?: boolean;
redirectUrl?: string; redirectUrl?: string;
userData?: BasicUserData; userData?: BasicUserData;
}; };
@@ -147,6 +148,7 @@ export async function verifyResourceSession(
pincode: ResourcePincode | null; pincode: ResourcePincode | null;
password: ResourcePassword | null; password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null; headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
org: Org; org: Org;
} }
| undefined = cache.get(resourceCacheKey); | undefined = cache.get(resourceCacheKey);
@@ -176,7 +178,7 @@ export async function verifyResourceSession(
cache.set(resourceCacheKey, resourceData, 5); cache.set(resourceCacheKey, resourceData, 5);
} }
const { resource, pincode, password, headerAuth } = resourceData; const { resource, pincode, password, headerAuth, headerAuthExtendedCompatibility } = resourceData;
if (!resource) { if (!resource) {
logger.debug(`Resource not found ${cleanHost}`); logger.debug(`Resource not found ${cleanHost}`);
@@ -456,7 +458,8 @@ export async function verifyResourceSession(
!sso && !sso &&
!pincode && !pincode &&
!password && !password &&
!resource.emailWhitelistEnabled !resource.emailWhitelistEnabled &&
!headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated
) { ) {
logRequestAudit( logRequestAudit(
{ {
@@ -471,13 +474,15 @@ export async function verifyResourceSession(
return notAllowed(res); 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 there are no other auth methods we need to return unauthorized if nothing is provided
if ( if (
!sso && !sso &&
!pincode && !pincode &&
!password && !password &&
!resource.emailWhitelistEnabled !resource.emailWhitelistEnabled &&
!headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated
) { ) {
logRequestAudit( logRequestAudit(
{ {
@@ -563,7 +568,7 @@ export async function verifyResourceSession(
} }
if (resourceSession) { 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( const accessPolicy = await enforceResourceSessionLength(
resourceSession, resourceSession,
resourceData.org 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"); logger.debug("No more auth to check, resource not allowed");
if (config.getRawConfig().app.log_failed_attempts) { if (config.getRawConfig().app.log_failed_attempts) {
@@ -839,6 +849,46 @@ function allowed(res: Response, userData?: BasicUserData) {
return response<VerifyUserResponse>(res, data); 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( async function isUserAllowedToAccessResource(
userSessionId: string, userSessionId: string,
resource: Resource, resource: Resource,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,16 @@
import React from "react"; import React from "react";
import {Switch} from "./ui/switch"; import {Switch} from "./ui/switch";
import {Label} from "./ui/label"; 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 { interface SwitchComponentProps {
id: string; id: string;
label?: string; label?: string;
description?: string; description?: string;
info?: string;
checked?: boolean; checked?: boolean;
defaultChecked?: boolean; defaultChecked?: boolean;
disabled?: boolean; disabled?: boolean;
@@ -16,11 +21,23 @@ export function SwitchInput({
id, id,
label, label,
description, description,
info,
disabled, disabled,
checked, checked,
defaultChecked = false, defaultChecked = false,
onCheckedChange onCheckedChange
}: SwitchComponentProps) { }: 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 ( return (
<div> <div>
<div className="flex items-center space-x-2 mb-2"> <div className="flex items-center space-x-2 mb-2">
@@ -32,6 +49,18 @@ export function SwitchInput({
disabled={disabled} disabled={disabled}
/> />
{label && <Label htmlFor={id}>{label}</Label>} {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> </div>
{description && ( {description && (
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">