This commit is contained in:
Fred KISSIE
2025-11-12 03:43:19 +01:00
parent f58cf68f7c
commit cfde4e7443
23 changed files with 1380 additions and 978 deletions

View File

@@ -1735,6 +1735,20 @@
"authPage": "Auth Page",
"authPageDescription": "Configure the auth page for your organization",
"authPageDomain": "Auth Page Domain",
"authPageBranding": "Branding",
"authPageBrandingDescription": "Configure the branding for the auth page for your organization",
"brandingLogoURL": "Logo URL",
"brandingLogoWidth": "Width (px)",
"brandingLogoHeight": "Height (px)",
"brandingOrgTitle": "Title for Organization Auth Page",
"brandingOrgDescription": "{orgName} will be replaced with the organization's name",
"brandingOrgSubtitle": "Subtitle for Organization Auth Page",
"brandingResourceTitle": "Title for Resource Auth Page",
"brandingResourceSubtitle": "Subtitle for Resource Auth Page",
"brandingResourceDescription": "{resourceName} will be replaced with the organization's name",
"saveAuthPage": "Save Auth Page",
"saveAuthPageBranding": "Save Branding",
"removeAuthPageBranding": "Remove Branding",
"noDomainSet": "No domain set",
"changeDomain": "Change Domain",
"selectDomain": "Select Domain",

View File

@@ -123,9 +123,7 @@ export enum ActionsEnum {
getBlueprint = "getBlueprint",
applyBlueprint = "applyBlueprint",
viewLogs = "viewLogs",
exportLogs = "exportLogs",
updateOrgAuthPage = "updateOrgAuthPage",
getOrgAuthPage = "getOrgAuthPage"
exportLogs = "exportLogs"
}
export async function checkUserActionPermission(

View File

@@ -204,6 +204,28 @@ export const loginPageOrg = pgTable("loginPageOrg", {
.references(() => orgs.orgId, { onDelete: "cascade" })
});
export const loginPageBranding = pgTable("loginPageBranding", {
loginPageBrandingId: serial("loginPageBrandingId").primaryKey(),
logoUrl: text("logoUrl").notNull(),
logoWidth: integer("logoWidth").notNull(),
logoHeight: integer("logoHeight").notNull(),
title: text("title").notNull(),
subtitle: text("subtitle"),
resourceTitle: text("resourceTitle").notNull(),
resourceSubtitle: text("resourceSubtitle")
});
export const loginPageBrandingOrg = pgTable("loginPageBrandingOrg", {
loginPageBrandingId: integer("loginPageBrandingId")
.notNull()
.references(() => loginPageBranding.loginPageBrandingId, {
onDelete: "cascade"
}),
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
});
export const sessionTransferToken = pgTable("sessionTransferToken", {
token: varchar("token").primaryKey(),
sessionId: varchar("sessionId")
@@ -215,42 +237,56 @@ export const sessionTransferToken = pgTable("sessionTransferToken", {
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
});
export const actionAuditLog = pgTable("actionAuditLog", {
id: serial("id").primaryKey(),
timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
actorType: varchar("actorType", { length: 50 }).notNull(),
actor: varchar("actor", { length: 255 }).notNull(),
actorId: varchar("actorId", { length: 255 }).notNull(),
action: varchar("action", { length: 100 }).notNull(),
metadata: text("metadata")
}, (table) => ([
index("idx_actionAuditLog_timestamp").on(table.timestamp),
index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp)
]));
export const actionAuditLog = pgTable(
"actionAuditLog",
{
id: serial("id").primaryKey(),
timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
actorType: varchar("actorType", { length: 50 }).notNull(),
actor: varchar("actor", { length: 255 }).notNull(),
actorId: varchar("actorId", { length: 255 }).notNull(),
action: varchar("action", { length: 100 }).notNull(),
metadata: text("metadata")
},
(table) => [
index("idx_actionAuditLog_timestamp").on(table.timestamp),
index("idx_actionAuditLog_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export const accessAuditLog = pgTable("accessAuditLog", {
id: serial("id").primaryKey(),
timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
actorType: varchar("actorType", { length: 50 }),
actor: varchar("actor", { length: 255 }),
actorId: varchar("actorId", { length: 255 }),
resourceId: integer("resourceId"),
ip: varchar("ip", { length: 45 }),
type: varchar("type", { length: 100 }).notNull(),
action: boolean("action").notNull(),
location: text("location"),
userAgent: text("userAgent"),
metadata: text("metadata")
}, (table) => ([
index("idx_identityAuditLog_timestamp").on(table.timestamp),
index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp)
]));
export const accessAuditLog = pgTable(
"accessAuditLog",
{
id: serial("id").primaryKey(),
timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
actorType: varchar("actorType", { length: 50 }),
actor: varchar("actor", { length: 255 }),
actorId: varchar("actorId", { length: 255 }),
resourceId: integer("resourceId"),
ip: varchar("ip", { length: 45 }),
type: varchar("type", { length: 100 }).notNull(),
action: boolean("action").notNull(),
location: text("location"),
userAgent: text("userAgent"),
metadata: text("metadata")
},
(table) => [
index("idx_identityAuditLog_timestamp").on(table.timestamp),
index("idx_identityAuditLog_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -269,5 +305,6 @@ export type RemoteExitNodeSession = InferSelectModel<
>;
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
export type LoginPage = InferSelectModel<typeof loginPage>;
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;

View File

@@ -65,24 +65,6 @@ export const orgDomains = pgTable("orgDomains", {
.references(() => domains.domainId, { onDelete: "cascade" })
});
export const orgAuthPages = pgTable(
"orgAuthPages",
{
orgId: varchar("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
orgAuthPageId: serial("orgAuthPageId").primaryKey(),
logoUrl: text("logoUrl").notNull(),
logoWidth: integer("logoWidth").notNull(),
logoHeight: integer("logoHeight").notNull(),
title: text("title").notNull(),
subtitle: text("subtitle"),
resourceTitle: text("resourceTitle").notNull(),
resourceSubtitle: text("resourceSubtitle")
},
(t) => [uniqueIndex("uniqueAuthPagePerOrgIdx").on(t.orgId)]
);
export const sites = pgTable("sites", {
siteId: serial("siteId").primaryKey(),
orgId: varchar("orgId")
@@ -828,4 +810,3 @@ export type LicenseKey = InferSelectModel<typeof licenseKey>;
export type SecurityKey = InferSelectModel<typeof securityKeys>;
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
export type OrgAuthPage = InferSelectModel<typeof orgAuthPages>;

View File

@@ -29,7 +29,9 @@ export const certificates = sqliteTable("certificates", {
});
export const dnsChallenge = sqliteTable("dnsChallenges", {
dnsChallengeId: integer("dnsChallengeId").primaryKey({ autoIncrement: true }),
dnsChallengeId: integer("dnsChallengeId").primaryKey({
autoIncrement: true
}),
domain: text("domain").notNull(),
token: text("token").notNull(),
keyAuthorization: text("keyAuthorization").notNull(),
@@ -61,9 +63,7 @@ export const customers = sqliteTable("customers", {
});
export const subscriptions = sqliteTable("subscriptions", {
subscriptionId: text("subscriptionId")
.primaryKey()
.notNull(),
subscriptionId: text("subscriptionId").primaryKey().notNull(),
customerId: text("customerId")
.notNull()
.references(() => customers.customerId, { onDelete: "cascade" }),
@@ -75,7 +75,9 @@ export const subscriptions = sqliteTable("subscriptions", {
});
export const subscriptionItems = sqliteTable("subscriptionItems", {
subscriptionItemId: integer("subscriptionItemId").primaryKey({ autoIncrement: true }),
subscriptionItemId: integer("subscriptionItemId").primaryKey({
autoIncrement: true
}),
subscriptionId: text("subscriptionId")
.notNull()
.references(() => subscriptions.subscriptionId, {
@@ -129,7 +131,9 @@ export const limits = sqliteTable("limits", {
});
export const usageNotifications = sqliteTable("usageNotifications", {
notificationId: integer("notificationId").primaryKey({ autoIncrement: true }),
notificationId: integer("notificationId").primaryKey({
autoIncrement: true
}),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
@@ -199,6 +203,30 @@ export const loginPageOrg = sqliteTable("loginPageOrg", {
.references(() => orgs.orgId, { onDelete: "cascade" })
});
export const loginPageBranding = sqliteTable("loginPageBranding", {
loginPageBrandingId: integer("loginPageBrandingId").primaryKey({
autoIncrement: true
}),
logoUrl: text("logoUrl").notNull(),
logoWidth: integer("logoWidth").notNull(),
logoHeight: integer("logoHeight").notNull(),
title: text("title").notNull(),
subtitle: text("subtitle"),
resourceTitle: text("resourceTitle").notNull(),
resourceSubtitle: text("resourceSubtitle")
});
export const loginPageBrandingOrg = sqliteTable("loginPageBrandingOrg", {
loginPageBrandingId: integer("loginPageBrandingId")
.notNull()
.references(() => loginPageBranding.loginPageBrandingId, {
onDelete: "cascade"
}),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
});
export const sessionTransferToken = sqliteTable("sessionTransferToken", {
token: text("token").primaryKey(),
sessionId: text("sessionId")
@@ -210,42 +238,56 @@ export const sessionTransferToken = sqliteTable("sessionTransferToken", {
expiresAt: integer("expiresAt").notNull()
});
export const actionAuditLog = sqliteTable("actionAuditLog", {
id: integer("id").primaryKey({ autoIncrement: true }),
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
actorType: text("actorType").notNull(),
actor: text("actor").notNull(),
actorId: text("actorId").notNull(),
action: text("action").notNull(),
metadata: text("metadata")
}, (table) => ([
index("idx_actionAuditLog_timestamp").on(table.timestamp),
index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp)
]));
export const actionAuditLog = sqliteTable(
"actionAuditLog",
{
id: integer("id").primaryKey({ autoIncrement: true }),
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
actorType: text("actorType").notNull(),
actor: text("actor").notNull(),
actorId: text("actorId").notNull(),
action: text("action").notNull(),
metadata: text("metadata")
},
(table) => [
index("idx_actionAuditLog_timestamp").on(table.timestamp),
index("idx_actionAuditLog_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export const accessAuditLog = sqliteTable("accessAuditLog", {
id: integer("id").primaryKey({ autoIncrement: true }),
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
actorType: text("actorType"),
actor: text("actor"),
actorId: text("actorId"),
resourceId: integer("resourceId"),
ip: text("ip"),
location: text("location"),
type: text("type").notNull(),
action: integer("action", { mode: "boolean" }).notNull(),
userAgent: text("userAgent"),
metadata: text("metadata")
}, (table) => ([
index("idx_identityAuditLog_timestamp").on(table.timestamp),
index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp)
]));
export const accessAuditLog = sqliteTable(
"accessAuditLog",
{
id: integer("id").primaryKey({ autoIncrement: true }),
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
actorType: text("actorType"),
actor: text("actor"),
actorId: text("actorId"),
resourceId: integer("resourceId"),
ip: text("ip"),
location: text("location"),
type: text("type").notNull(),
action: integer("action", { mode: "boolean" }).notNull(),
userAgent: text("userAgent"),
metadata: text("metadata")
},
(table) => [
index("idx_identityAuditLog_timestamp").on(table.timestamp),
index("idx_identityAuditLog_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -264,5 +306,6 @@ export type RemoteExitNodeSession = InferSelectModel<
>;
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
export type LoginPage = InferSelectModel<typeof loginPage>;
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;

View File

@@ -72,26 +72,6 @@ export const orgDomains = sqliteTable("orgDomains", {
.references(() => domains.domainId, { onDelete: "cascade" })
});
export const orgAuthPages = sqliteTable(
"orgAuthPages",
{
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
orgAuthPageId: integer("orgAuthPageId").primaryKey({
autoIncrement: true
}),
logoUrl: text("logoUrl").notNull(),
logoWidth: integer("logoWidth").notNull(),
logoHeight: integer("logoHeight").notNull(),
title: text("title").notNull(),
subtitle: text("subtitle"),
resourceTitle: text("resourceTitle").notNull(),
resourceSubtitle: text("resourceSubtitle")
},
(t) => [uniqueIndex("uniqueAuthPagePerOrgIdx").on(t.orgId)]
);
export const sites = sqliteTable("sites", {
siteId: integer("siteId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
@@ -885,4 +865,3 @@ export type LicenseKey = InferSelectModel<typeof licenseKey>;
export type SecurityKey = InferSelectModel<typeof securityKeys>;
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
export type OrgAuthPage = InferSelectModel<typeof orgAuthPages>;

View File

@@ -1,13 +0,0 @@
import z, { type ZodSchema } from "zod";
export function createResponseBodySchema<T extends ZodSchema>(dataSchema: T) {
return z.object({
data: dataSchema.nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
});
}
export default createResponseBodySchema;

View File

@@ -1,107 +0,0 @@
import { eq } from "drizzle-orm";
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, orgAuthPages } from "@server/db";
import { orgs } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import createResponseBodySchema from "@server/lib/createResponseBodySchema";
const getOrgAuthPageParamsSchema = z
.object({
orgId: z.string()
})
.strict();
const reponseSchema = createResponseBodySchema(
z
.object({
logoUrl: z.string().url(),
logoWidth: z.number().min(1),
logoHeight: z.number().min(1),
title: z.string(),
subtitle: z.string().optional(),
resourceTitle: z.string(),
resourceSubtitle: z.string().optional()
})
.strict()
);
export type GetOrgAuthPageResponse = z.infer<typeof reponseSchema>;
registry.registerPath({
method: "get",
path: "/org/{orgId}/auth-page",
description: "Get an organization auth page",
tags: [OpenAPITags.Org],
request: {
params: getOrgAuthPageParamsSchema
},
responses: {
200: {
description: "",
content: {
"application/json": {
schema: reponseSchema
}
}
}
}
});
export async function getOrgAuthPage(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getOrgAuthPageParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const [orgAuthPage] = await db
.select()
.from(orgAuthPages)
.leftJoin(orgs, eq(orgs.orgId, orgAuthPages.orgId))
.where(eq(orgs.orgId, orgId))
.limit(1);
return response(res, {
data: orgAuthPage?.orgAuthPages ?? null,
success: true,
error: false,
message: "Organization auth page retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,15 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./updateOrgAuthPage";
export * from "./getOrgAuthPage";

View File

@@ -1,141 +0,0 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, orgAuthPages } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import createResponseBodySchema from "@server/lib/createResponseBodySchema";
const updateOrgAuthPageParamsSchema = z
.object({
orgId: z.string()
})
.strict();
const updateOrgAuthPageBodySchema = z
.object({
logoUrl: z.string().url(),
logoWidth: z.number().min(1),
logoHeight: z.number().min(1),
title: z.string(),
subtitle: z.string().optional(),
resourceTitle: z.string(),
resourceSubtitle: z.string().optional()
})
.strict();
const reponseSchema = createResponseBodySchema(updateOrgAuthPageBodySchema);
export type UpdateOrgAuthPageResponse = z.infer<typeof reponseSchema>;
registry.registerPath({
method: "put",
path: "/org/{orgId}/auth-page",
description: "Update an organization auth page",
tags: [OpenAPITags.Org],
request: {
params: updateOrgAuthPageParamsSchema,
body: {
content: {
"application/json": {
schema: updateOrgAuthPageBodySchema
}
}
}
},
responses: {
200: {
description: "",
content: {
"application/json": {
schema: reponseSchema
}
}
}
}
});
export async function updateOrgAuthPage(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = updateOrgAuthPageParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = updateOrgAuthPageBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { orgId } = parsedParams.data;
const body = parsedBody.data;
const updatedOrgAuthPages = await db
.insert(orgAuthPages)
.values({
...body,
orgId
})
.onConflictDoUpdate({
target: orgAuthPages.orgId,
set: {
...body
}
})
.returning();
if (updatedOrgAuthPages.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
return response(res, {
data: updatedOrgAuthPages[0],
success: true,
error: false,
message: "Organization auth page updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -17,7 +17,6 @@ import * as billing from "#private/routers/billing";
import * as remoteExitNode from "#private/routers/remoteExitNode";
import * as loginPage from "#private/routers/loginPage";
import * as orgIdp from "#private/routers/orgIdp";
import * as authPage from "#private/routers/authPage";
import * as domain from "#private/routers/domain";
import * as auth from "#private/routers/auth";
import * as license from "#private/routers/license";
@@ -309,6 +308,33 @@ authenticated.get(
loginPage.getLoginPage
);
authenticated.get(
"/org/:orgId/login-page-branding",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getLoginPage),
logActionAudit(ActionsEnum.getLoginPage),
loginPage.getLoginPageBranding
);
authenticated.put(
"/org/:orgId/login-page-branding",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateLoginPage),
logActionAudit(ActionsEnum.updateLoginPage),
loginPage.upsertLoginPageBranding
);
authenticated.delete(
"/org/:orgId/login-page-branding",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteLoginPage),
logActionAudit(ActionsEnum.deleteLoginPage),
loginPage.deleteLoginPageBranding
);
authRouter.post(
"/remoteExitNode/get-token",
verifyValidLicense,
@@ -404,23 +430,3 @@ authenticated.get(
logActionAudit(ActionsEnum.exportLogs),
logs.exportAccessAuditLogs
);
authenticated.put(
"/org/:orgId/auth-page",
verifyValidLicense,
verifyValidSubscription,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateOrgAuthPage),
logActionAudit(ActionsEnum.updateOrgAuthPage),
authPage.updateOrgAuthPage
);
authenticated.get(
"/org/:orgId/auth-page",
verifyValidLicense,
verifyValidSubscription,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getOrgAuthPage),
logActionAudit(ActionsEnum.getOrgAuthPage),
authPage.getOrgAuthPage
);

View File

@@ -0,0 +1,113 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
LoginPageBranding,
loginPageBranding,
loginPageBrandingOrg
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
const paramsSchema = z
.object({
orgId: z.string()
})
.strict();
export async function deleteLoginPageBranding(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const [existingLoginPageBranding] = await db
.select()
.from(loginPageBranding)
.innerJoin(
loginPageBrandingOrg,
eq(
loginPageBrandingOrg.loginPageBrandingId,
loginPageBranding.loginPageBrandingId
)
)
.where(eq(loginPageBrandingOrg.orgId, orgId));
if (!existingLoginPageBranding) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Login page branding not found"
)
);
}
await db
.delete(loginPageBranding)
.where(
eq(
loginPageBranding.loginPageBrandingId,
existingLoginPageBranding.loginPageBranding
.loginPageBrandingId
)
);
return response<LoginPageBranding>(res, {
data: existingLoginPageBranding.loginPageBranding,
success: true,
error: false,
message: "Login page branding deleted successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,103 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
LoginPageBranding,
loginPageBranding,
loginPageBrandingOrg
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
const paramsSchema = z
.object({
orgId: z.string()
})
.strict();
export async function getLoginPageBranding(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const [existingLoginPageBranding] = await db
.select()
.from(loginPageBranding)
.innerJoin(
loginPageBrandingOrg,
eq(
loginPageBrandingOrg.loginPageBrandingId,
loginPageBranding.loginPageBrandingId
)
)
.where(eq(loginPageBrandingOrg.orgId, orgId));
if (!existingLoginPageBranding) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Login page branding not found"
)
);
}
return response<LoginPageBranding>(res, {
data: existingLoginPageBranding.loginPageBranding,
success: true,
error: false,
message: "Login page branding retrieved successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -17,3 +17,6 @@ export * from "./getLoginPage";
export * from "./loadLoginPage";
export * from "./updateLoginPage";
export * from "./deleteLoginPage";
export * from "./upsertLoginPageBranding";
export * from "./deleteLoginPageBranding";
export * from "./getLoginPageBranding";

View File

@@ -0,0 +1,154 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
db,
LoginPageBranding,
loginPageBranding,
loginPageBrandingOrg
} from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
const paramsSchema = z
.object({
orgId: z.string()
})
.strict();
const bodySchema = z
.object({
logoUrl: z.string().url(),
logoWidth: z.number().min(1),
logoHeight: z.number().min(1),
title: z.string(),
subtitle: z.string().optional(),
resourceTitle: z.string(),
resourceSubtitle: z.string().optional()
})
.strict();
export type UpdateLoginPageBrandingBody = z.infer<typeof bodySchema>;
export async function upsertLoginPageBranding(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const updateData = parsedBody.data;
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
const subscribed = tier === TierId.STANDARD;
if (!subscribed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"This organization's current plan does not support this feature."
)
);
}
}
const [existingLoginPageBranding] = await db
.select()
.from(loginPageBranding)
.innerJoin(
loginPageBrandingOrg,
eq(
loginPageBrandingOrg.loginPageBrandingId,
loginPageBranding.loginPageBrandingId
)
)
.where(eq(loginPageBrandingOrg.orgId, orgId));
let updatedLoginPageBranding: LoginPageBranding;
if (existingLoginPageBranding) {
updatedLoginPageBranding = await db.transaction(async (tx) => {
const [branding] = await tx
.update(loginPageBranding)
.set({ ...updateData })
.where(
eq(
loginPageBranding.loginPageBrandingId,
existingLoginPageBranding.loginPageBranding
.loginPageBrandingId
)
)
.returning();
return branding;
});
} else {
updatedLoginPageBranding = await db.transaction(async (tx) => {
const [branding] = await tx
.insert(loginPageBranding)
.values({ ...updateData })
.returning();
await tx.insert(loginPageBrandingOrg).values({
loginPageBrandingId: branding.loginPageBrandingId,
orgId: orgId
});
return branding;
});
}
return response<LoginPageBranding>(res, {
data: updatedLoginPageBranding,
success: true,
error: false,
message: existingLoginPageBranding
? "Login page branding updated successfully"
: "Login page branding created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -1,4 +1,4 @@
import { LoginPage } from "@server/db";
import type { LoginPage, LoginPageBranding } from "@server/db";
export type CreateLoginPageResponse = LoginPage;
@@ -8,4 +8,6 @@ export type GetLoginPageResponse = LoginPage;
export type UpdateLoginPageResponse = LoginPage;
export type LoadLoginPageResponse = LoginPage & { orgId: string };
export type LoadLoginPageResponse = LoginPage & { orgId: string };
export type GetLoginPageBrandingResponse = LoginPageBranding;

View File

@@ -0,0 +1,64 @@
import AuthPageBrandingForm from "@app/components/AuthPageBrandingForm";
import AuthPageSettings from "@app/components/private/AuthPageSettings";
import { SettingsContainer } from "@app/components/Settings";
import { priv } from "@app/lib/api";
import { getCachedSubscription } from "@app/lib/api/getCachedSubscription";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
import { TierId } from "@server/lib/billing/tiers";
import type { GetOrgTierResponse } from "@server/routers/billing/types";
import {
GetLoginPageBrandingResponse,
GetLoginPageResponse
} from "@server/routers/loginPage/types";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
export interface AuthPageProps {
params: Promise<{ orgId: string }>;
}
export default async function AuthPage(props: AuthPageProps) {
const orgId = (await props.params).orgId;
const env = pullEnv();
let subscriptionStatus: GetOrgTierResponse | null = null;
try {
const subRes = await getCachedSubscription(orgId);
subscriptionStatus = subRes.data.data;
} catch {}
const subscribed =
build === "enterprise"
? true
: subscriptionStatus?.tier === TierId.STANDARD;
if (!subscribed) {
redirect(env.app.dashboardUrl);
}
let loginPage: GetLoginPageResponse | null = null;
try {
const res = await priv.get<AxiosResponse<GetLoginPageResponse>>(
`/org/${orgId}/login-page`
);
if (res.status === 200) {
loginPage = res.data.data;
}
} catch (error) {}
let loginPageBranding: GetLoginPageBrandingResponse | null = null;
try {
const res = await priv.get<AxiosResponse<GetLoginPageBrandingResponse>>(
`/org/${orgId}/login-page-branding`
);
if (res.status === 200) {
loginPageBranding = res.data.data;
}
} catch (error) {}
return (
<SettingsContainer>
<AuthPageSettings loginPage={loginPage} />
<AuthPageBrandingForm orgId={orgId} branding={loginPageBranding} />
</SettingsContainer>
);
}

View File

@@ -1,36 +0,0 @@
import AuthPageCustomizationForm from "@app/components/AuthPagesCustomizationForm";
import { SettingsContainer } from "@app/components/Settings";
import { getCachedSubscription } from "@app/lib/api/getCachedSubscription";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
import { TierId } from "@server/lib/billing/tiers";
import type { GetOrgTierResponse } from "@server/routers/billing/types";
import { redirect } from "next/navigation";
export interface AuthPageProps {
params: Promise<{ orgId: string }>;
}
export default async function AuthPage(props: AuthPageProps) {
const orgId = (await props.params).orgId;
const env = pullEnv();
let subscriptionStatus: GetOrgTierResponse | null = null;
try {
const subRes = await getCachedSubscription(orgId);
subscriptionStatus = subRes.data.data;
} catch {}
const subscribed =
build === "enterprise"
? true
: subscriptionStatus?.tier === TierId.STANDARD;
if (!subscribed) {
redirect(env.app.dashboardUrl);
}
return (
<SettingsContainer>
<AuthPageCustomizationForm orgId={orgId} />
</SettingsContainer>
);
}

View File

@@ -73,7 +73,7 @@ export default async function GeneralSettingsPage({
if (subscribed) {
navItems.push({
title: t("authPage"),
href: `/{orgId}/settings/general/auth-pages`
href: `/{orgId}/settings/general/auth-page`
});
}

View File

@@ -43,8 +43,7 @@ import {
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionForm,
SettingsSectionFooter
SettingsSectionForm
} from "@app/components/Settings";
import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
@@ -129,7 +128,6 @@ export default function GeneralPage() {
const [loadingSave, setLoadingSave] = useState(false);
const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] =
useState(false);
const authPageSettingsRef = useRef<AuthPageSettingsRef>(null);
const form = useForm({
resolver: zodResolver(GeneralFormSchema),
@@ -252,14 +250,6 @@ export default function GeneralPage() {
// Update organization
await api.post(`/org/${org?.org.orgId}`, reqData);
// Also save auth page settings if they have unsaved changes
if (
build === "saas" &&
authPageSettingsRef.current?.hasUnsavedChanges()
) {
await authPageSettingsRef.current.saveAuthSettings();
}
toast({
title: t("orgUpdated"),
description: t("orgUpdatedDescription")
@@ -600,7 +590,7 @@ export default function GeneralPage() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<SecurityFeaturesAlert />
<SecurityFeaturesAlert />
<FormField
control={form.control}
name="requireTwoFactor"
@@ -832,8 +822,6 @@ export default function GeneralPage() {
</form>
</Form>
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}
<div className="flex justify-end gap-2">
{build !== "saas" && (
<Button

View File

@@ -0,0 +1,319 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import * as React from "react";
import { useForm } from "react-hook-form";
import z from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "./Settings";
import { useTranslations } from "next-intl";
import AuthPageSettings, {
AuthPageSettingsRef
} from "./private/AuthPageSettings";
import type {
GetLoginPageBrandingResponse,
GetLoginPageResponse
} from "@server/routers/loginPage/types";
import { Input } from "./ui/input";
import { XIcon } from "lucide-react";
import { Button } from "./ui/button";
import { Separator } from "./ui/separator";
import { build } from "@server/build";
export type AuthPageCustomizationProps = {
orgId: string;
branding: GetLoginPageBrandingResponse | null;
};
const AuthPageFormSchema = z.object({
logoUrl: z
.string()
.url()
.refine(
async (url) => {
try {
const response = await fetch(url);
return (
response.status === 200 &&
(response.headers.get("content-type") ?? "").startsWith(
"image/"
)
);
} catch (error) {
return false;
}
},
{
message: "Invalid logo URL, must be a valid image URL"
}
),
logoWidth: z.number().min(1),
logoHeight: z.number().min(1),
title: z.string(),
subtitle: z.string().optional(),
resourceTitle: z.string(),
resourceSubtitle: z.string().optional()
});
export default function AuthPageBrandingForm({
orgId,
branding
}: AuthPageCustomizationProps) {
const [, formAction, isSubmitting] = React.useActionState(onSubmit, null);
const t = useTranslations();
const form = useForm({
resolver: zodResolver(AuthPageFormSchema),
defaultValues: {
logoUrl: branding?.logoUrl ?? "",
logoWidth: branding?.logoWidth ?? 500,
logoHeight: branding?.logoHeight ?? 500,
title: branding?.title ?? `Log in to {{orgName}}`,
subtitle: branding?.subtitle ?? `Log in to {{orgName}}`,
resourceTitle:
branding?.resourceTitle ??
`Authenticate to access {{resourceName}}`,
resourceSubtitle:
branding?.resourceSubtitle ??
`Choose your preferred authentication method for {{resourceName}}`
}
});
async function onSubmit() {
console.log({
dirty: form.formState.isDirty
});
const isValid = await form.trigger();
if (!isValid) return;
// ...
}
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authPageBranding")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("authPageBrandingDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
id="auth-page-branding-form"
className="flex flex-col gap-8 items-stretch"
>
<div className="grid md:grid-cols-5 gap-3 items-start">
<FormField
control={form.control}
name="logoUrl"
render={({ field }) => (
<FormItem className="md:col-span-3">
<FormLabel>
{t("brandingLogoURL")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="md:col-span-2 flex gap-3 items-start">
<FormField
control={form.control}
name="logoWidth"
render={({ field }) => (
<FormItem className="grow">
<FormLabel>
{t("brandingLogoWidth")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<span className="self-center relative top-2.5">
<XIcon className="text-muted-foreground size-4" />
</span>
<FormField
control={form.control}
name="logoWidth"
render={({ field }) => (
<FormItem className="grow">
<FormLabel>
{t(
"brandingLogoHeight"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Separator />
<div className="flex flex-col gap-3">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="md:col-span-3">
<FormLabel>
{t("brandingOrgTitle")}
</FormLabel>
<FormDescription>
{t(
"brandingOrgDescription",
{
orgName:
"{{orgName}}"
}
)}
</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subtitle"
render={({ field }) => (
<FormItem className="md:col-span-3">
<FormLabel>
{t("brandingOrgSubtitle")}
</FormLabel>
<FormDescription>
{t(
"brandingOrgDescription",
{
orgName:
"{{orgName}}"
}
)}
</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
<div className="flex flex-col gap-3">
<FormField
control={form.control}
name="resourceTitle"
render={({ field }) => (
<FormItem className="md:col-span-3">
<FormLabel>
{t("brandingResourceTitle")}
</FormLabel>
<FormDescription>
{t(
"brandingResourceDescription",
{
resourceName:
"{{resourceName}}"
}
)}
</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="resourceSubtitle"
render={({ field }) => (
<FormItem className="md:col-span-3">
<FormLabel>
{t(
"brandingResourceSubtitle"
)}
</FormLabel>
<FormDescription>
{t(
"brandingResourceDescription",
{
resourceName:
"{{resourceName}}"
}
)}
</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end gap-2 mt-6">
{/* {branding && (
<Button
type="submit"
form="auth-page-branding-form"
loading={isSubmitting}
disabled={isSubmitting}
>
{t("saveSettings")}
</Button>
)} */}
<Button
type="submit"
form="auth-page-branding-form"
loading={isSubmitting}
disabled={isSubmitting}
>
{t("saveAuthPageBranding")}
</Button>
</div>
</SettingsSection>
</>
);
}

View File

@@ -1,56 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import * as React from "react";
import { useForm } from "react-hook-form";
import z from "zod";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
export type AuthPageCustomizationProps = {
orgId: string;
};
const AuthPageFormSchema = z.object({
logoUrl: z.string().url(),
logoWidth: z.number().min(1),
logoHeight: z.number().min(1),
title: z.string(),
subtitle: z.string().optional(),
resourceTitle: z.string(),
resourceSubtitle: z.string().optional()
});
export default function AuthPageCustomizationForm({
orgId
}: AuthPageCustomizationProps) {
const [, formAction, isSubmitting] = React.useActionState(onSubmit, null);
const form = useForm({
resolver: zodResolver(AuthPageFormSchema),
defaultValues: {
title: `Log in to {{orgName}}`,
resourceTitle: `Authenticate to access {{resourceName}}`
}
});
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
// ...
}
return (
<Form {...form}>
<button>Hello</button>
</Form>
);
}

View File

@@ -3,7 +3,15 @@
import { Button } from "@app/components/ui/button";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
import {
useState,
useEffect,
forwardRef,
useImperativeHandle,
RefObject,
Ref,
useActionState
} from "react";
import {
Form,
FormControl,
@@ -52,7 +60,6 @@ import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build";
// Auth page form schema
@@ -66,6 +73,7 @@ type AuthPageFormValues = z.infer<typeof AuthPageFormSchema>;
interface AuthPageSettingsProps {
onSaveSuccess?: () => void;
onSaveError?: (error: any) => void;
loginPage: GetLoginPageResponse | null;
}
export interface AuthPageSettingsRef {
@@ -73,476 +81,434 @@ export interface AuthPageSettingsRef {
hasUnsavedChanges: () => boolean;
}
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
({ onSaveSuccess, onSaveError }, ref) => {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
function AuthPageSettings({
onSaveSuccess,
onSaveError,
loginPage: defaultLoginPage
}: AuthPageSettingsProps) {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const subscription = useSubscriptionStatusContext();
const subscription = useSubscriptionStatusContext();
// Auth page domain state
const [loginPage, setLoginPage] = useState<GetLoginPageResponse | null>(
null
);
const [loginPageExists, setLoginPageExists] = useState(false);
const [editDomainOpen, setEditDomainOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
} | null>(null);
const [loadingLoginPage, setLoadingLoginPage] = useState(true);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [loadingSave, setLoadingSave] = useState(false);
// Auth page domain state
const [loginPage, setLoginPage] = useState(defaultLoginPage);
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
const [loginPageExists, setLoginPageExists] = useState(
Boolean(defaultLoginPage)
);
const [editDomainOpen, setEditDomainOpen] = useState(false);
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
} | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const form = useForm({
resolver: zodResolver(AuthPageFormSchema),
defaultValues: {
authPageDomainId: loginPage?.domainId || "",
authPageSubdomain: loginPage?.subdomain || ""
},
mode: "onChange"
});
const form = useForm({
resolver: zodResolver(AuthPageFormSchema),
defaultValues: {
authPageDomainId: loginPage?.domainId || "",
authPageSubdomain: loginPage?.subdomain || ""
},
mode: "onChange"
});
// Expose save function to parent component
useImperativeHandle(
ref,
() => ({
saveAuthSettings: async () => {
await form.handleSubmit(onSubmit)();
},
hasUnsavedChanges: () => hasUnsavedChanges
}),
[form, hasUnsavedChanges]
);
// Fetch login page and domains data
useEffect(() => {
const fetchLoginPage = async () => {
try {
const res = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
if (res.status === 200) {
setLoginPage(res.data.data);
setLoginPageExists(true);
// Update form with login page data
form.setValue(
"authPageDomainId",
res.data.data.domainId || ""
);
form.setValue(
"authPageSubdomain",
res.data.data.subdomain || ""
);
}
} catch (err) {
// Login page doesn't exist yet, that's okay
setLoginPage(null);
setLoginPageExists(false);
} finally {
setLoadingLoginPage(false);
}
};
const fetchDomains = async () => {
try {
const res = await api.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${org?.org.orgId}/domains/`);
if (res.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
}
} catch (err) {
console.error("Failed to fetch domains:", err);
}
};
if (org?.org.orgId) {
fetchLoginPage();
fetchDomains();
}
}, []);
// Handle domain selection from modal
function handleDomainSelection(domain: {
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
}) {
form.setValue("authPageDomainId", domain.domainId);
form.setValue("authPageSubdomain", domain.subdomain || "");
setEditDomainOpen(false);
// Update loginPage state to show the selected domain immediately
const sanitizedSubdomain = domain.subdomain
? finalizeSubdomainSanitize(domain.subdomain)
: "";
const sanitizedFullDomain = sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
// Only update loginPage state if a login page already exists
if (loginPageExists && loginPage) {
setLoginPage({
...loginPage,
domainId: domain.domainId,
subdomain: sanitizedSubdomain,
fullDomain: sanitizedFullDomain
});
}
setHasUnsavedChanges(true);
}
// Clear auth page domain
function clearAuthPageDomain() {
form.setValue("authPageDomainId", "");
form.setValue("authPageSubdomain", "");
setLoginPage(null);
setHasUnsavedChanges(true);
}
async function onSubmit(data: AuthPageFormValues) {
setLoadingSave(true);
// Expose save function to parent component
// useImperativeHandle(
// ref,
// () => ({
// saveAuthSettings: async () => {
// await form.handleSubmit(onSubmit)();
// },
// hasUnsavedChanges: () => hasUnsavedChanges
// }),
// [form, hasUnsavedChanges]
// );
// Fetch login page and domains data
useEffect(() => {
const fetchDomains = async () => {
try {
// Handle auth page domain
if (data.authPageDomainId) {
if (
build === "enterprise" ||
(build === "saas" && subscription?.subscribed)
) {
const sanitizedSubdomain = data.authPageSubdomain
? finalizeSubdomainSanitize(data.authPageSubdomain)
: "";
const res = await api.get<AxiosResponse<ListDomainsResponse>>(
`/org/${org?.org.orgId}/domains/`
);
if (res.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
}
} catch (err) {
console.error("Failed to fetch domains:", err);
}
};
if (loginPageExists) {
// Login page exists on server - need to update it
// First, we need to get the loginPageId from the server since loginPage might be null locally
let loginPageId: number;
if (org?.org.orgId) {
fetchDomains();
}
}, []);
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
// Handle domain selection from modal
function handleDomainSelection(domain: {
domainId: string;
subdomain?: string;
fullDomain: string;
baseDomain: string;
}) {
form.setValue("authPageDomainId", domain.domainId);
form.setValue("authPageSubdomain", domain.subdomain || "");
setEditDomainOpen(false);
// Update existing auth page domain
const updateRes = await api.post(
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
// Update loginPage state to show the selected domain immediately
const sanitizedSubdomain = domain.subdomain
? finalizeSubdomainSanitize(domain.subdomain)
: "";
if (updateRes.status === 201) {
setLoginPage(updateRes.data.data);
setLoginPageExists(true);
}
const sanitizedFullDomain = sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
// Only update loginPage state if a login page already exists
if (loginPageExists && loginPage) {
setLoginPage({
...loginPage,
domainId: domain.domainId,
subdomain: sanitizedSubdomain,
fullDomain: sanitizedFullDomain
});
}
setHasUnsavedChanges(true);
}
// Clear auth page domain
function clearAuthPageDomain() {
form.setValue("authPageDomainId", "");
form.setValue("authPageSubdomain", "");
setLoginPage(null);
setHasUnsavedChanges(true);
}
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
// Handle auth page domain
if (data.authPageDomainId) {
if (
build === "enterprise" ||
(build === "saas" && subscription?.subscribed)
) {
const sanitizedSubdomain = data.authPageSubdomain
? finalizeSubdomainSanitize(data.authPageSubdomain)
: "";
if (loginPageExists) {
// Login page exists on server - need to update it
// First, we need to get the loginPageId from the server since loginPage might be null locally
let loginPageId: number;
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// No login page exists on server - create new one
const createRes = await api.put(
`/org/${org?.org.orgId}/login-page`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
if (createRes.status === 201) {
setLoginPage(createRes.data.data);
setLoginPageExists(true);
// Update existing auth page domain
const updateRes = await api.post(
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
if (updateRes.status === 201) {
setLoginPage(updateRes.data.data);
setLoginPageExists(true);
}
} else {
// No login page exists on server - create new one
const createRes = await api.put(
`/org/${org?.org.orgId}/login-page`,
{
domainId: data.authPageDomainId,
subdomain: sanitizedSubdomain || null
}
);
if (createRes.status === 201) {
setLoginPage(createRes.data.data);
setLoginPageExists(true);
}
}
} else if (loginPageExists) {
// Delete existing auth page domain if no domain selected
let loginPageId: number;
}
} else if (loginPageExists) {
// Delete existing auth page domain if no domain selected
let loginPageId: number;
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
await api.delete(
`/org/${org?.org.orgId}/login-page/${loginPageId}`
);
setLoginPage(null);
setLoginPageExists(false);
if (loginPage) {
// We have the loginPage data locally
loginPageId = loginPage.loginPageId;
} else {
// User cleared selection locally, but login page still exists on server
// We need to fetch it to get the loginPageId
const fetchRes = await api.get<
AxiosResponse<GetLoginPageResponse>
>(`/org/${org?.org.orgId}/login-page`);
loginPageId = fetchRes.data.data.loginPageId;
}
setHasUnsavedChanges(false);
router.refresh();
onSaveSuccess?.();
} catch (e) {
toast({
variant: "destructive",
title: t("authPageErrorUpdate"),
description: formatAxiosError(
e,
t("authPageErrorUpdateMessage")
)
});
onSaveError?.(e);
} finally {
setLoadingSave(false);
await api.delete(
`/org/${org?.org.orgId}/login-page/${loginPageId}`
);
setLoginPage(null);
setLoginPageExists(false);
}
setHasUnsavedChanges(false);
router.refresh();
onSaveSuccess?.();
} catch (e) {
toast({
variant: "destructive",
title: t("authPageErrorUpdate"),
description: formatAxiosError(
e,
t("authPageErrorUpdateMessage")
)
});
onSaveError?.(e);
}
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authPage")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("authPageDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{build === "saas" && !subscription?.subscribed ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("orgAuthPageDisabled")}{" "}
{t("subscriptionRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
<SettingsSectionForm>
{loadingLoginPage ? (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground">
{t("loading")}
</div>
</div>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="auth-page-settings-form"
>
<div className="space-y-3">
<Label>{t("authPageDomain")}</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14" />
{loginPage &&
!loginPage.domainId ? (
<InfoPopup
info={t(
"domainNotFoundDescription"
)}
text={t(
"domainNotFound"
)}
/>
) : loginPage?.fullDomain ? (
<a
href={`${window.location.protocol}//${loginPage.fullDomain}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{`${window.location.protocol}//${loginPage.fullDomain}`}
</a>
) : form.watch(
"authPageDomainId"
) ? (
// Show selected domain from form state when no loginPage exists yet
(() => {
const selectedDomainId =
form.watch(
"authPageDomainId"
);
const selectedSubdomain =
form.watch(
"authPageSubdomain"
);
const domain =
baseDomains.find(
(d) =>
d.domainId ===
selectedDomainId
);
if (domain) {
const sanitizedSubdomain =
selectedSubdomain
? finalizeSubdomainSanitize(
selectedSubdomain
)
: "";
const fullDomain =
sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
return fullDomain;
}
return t(
"noDomainSet"
);
})()
) : (
t("noDomainSet")
)}
</span>
<div className="flex items-center gap-2">
<Button
variant="secondary"
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(
true
)
}
>
{form.watch(
"authPageDomainId"
)
? t("changeDomain")
: t("selectDomain")}
</Button>
{form.watch(
"authPageDomainId"
) && (
<Button
variant="destructive"
type="button"
size="sm"
onClick={
clearAuthPageDomain
}
>
<Trash2 size="14" />
</Button>
)}
</div>
</div>
{!form.watch(
"authPageDomainId"
) && (
<div className="text-sm text-muted-foreground">
{t(
"addDomainToEnableCustomAuthPages"
)}
</div>
)}
{env.flags
.usePangolinDns &&
(build === "enterprise" ||
(build === "saas" &&
subscription?.subscribed)) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (
<CertificateStatus
orgId={
org?.org.orgId || ""
}
domainId={
loginPage.domainId
}
fullDomain={
loginPage.fullDomain
}
autoFetch={true}
showLabel={true}
polling={true}
/>
)}
</div>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{/* Domain Picker Modal */}
<Credenza
open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{loginPage
? t("editAuthPageDomain")
: t("setAuthPageDomain")}
</CredenzaTitle>
<CredenzaDescription>
{t("selectDomainForOrgAuthPage")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<DomainPicker
hideFreeDomain={true}
orgId={org?.org.orgId as string}
cols={1}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain
};
setSelectedDomain(selected);
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={() => {
if (selectedDomain) {
handleDomainSelection(selectedDomain);
}
}}
disabled={!selectedDomain}
>
{t("selectDomain")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}
);
return (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("authPage")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("authPageDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{build === "saas" && !subscription?.subscribed ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("orgAuthPageDisabled")}{" "}
{t("subscriptionRequiredToUse")}
</AlertDescription>
</Alert>
) : null}
<SettingsSectionForm>
<Form {...form}>
<form
action={formAction}
className="space-y-4"
id="auth-page-settings-form"
>
<div className="space-y-3">
<Label>{t("authPageDomain")}</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14" />
{loginPage &&
!loginPage.domainId ? (
<InfoPopup
info={t(
"domainNotFoundDescription"
)}
text={t("domainNotFound")}
/>
) : loginPage?.fullDomain ? (
<a
href={`${window.location.protocol}//${loginPage.fullDomain}`}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
{`${window.location.protocol}//${loginPage.fullDomain}`}
</a>
) : form.watch(
"authPageDomainId"
) ? (
// Show selected domain from form state when no loginPage exists yet
(() => {
const selectedDomainId =
form.watch(
"authPageDomainId"
);
const selectedSubdomain =
form.watch(
"authPageSubdomain"
);
const domain =
baseDomains.find(
(d) =>
d.domainId ===
selectedDomainId
);
if (domain) {
const sanitizedSubdomain =
selectedSubdomain
? finalizeSubdomainSanitize(
selectedSubdomain
)
: "";
const fullDomain =
sanitizedSubdomain
? `${sanitizedSubdomain}.${domain.baseDomain}`
: domain.baseDomain;
return fullDomain;
}
return t("noDomainSet");
})()
) : (
t("noDomainSet")
)}
</span>
<div className="flex items-center gap-2">
<Button
variant="secondary"
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(true)
}
>
{form.watch("authPageDomainId")
? t("changeDomain")
: t("selectDomain")}
</Button>
{form.watch("authPageDomainId") && (
<Button
variant="destructive"
type="button"
size="sm"
onClick={
clearAuthPageDomain
}
>
<Trash2 size="14" />
</Button>
)}
</div>
</div>
{!form.watch("authPageDomainId") && (
<div className="text-sm text-muted-foreground">
{t(
"addDomainToEnableCustomAuthPages"
)}
</div>
)}
{env.flags.usePangolinDns &&
(build === "enterprise" ||
(build === "saas" &&
subscription?.subscribed)) &&
loginPage?.domainId &&
loginPage?.fullDomain &&
!hasUnsavedChanges && (
<CertificateStatus
orgId={org?.org.orgId || ""}
domainId={loginPage.domainId}
fullDomain={
loginPage.fullDomain
}
autoFetch={true}
showLabel={true}
polling={true}
/>
)}
</div>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<div className="flex justify-end mt-6">
<Button
type="submit"
form="auth-page-settings-form"
loading={isSubmitting}
disabled={isSubmitting || !hasUnsavedChanges}
>
{t("saveAuthPage")}
</Button>
</div>
</SettingsSection>
{/* Domain Picker Modal */}
<Credenza
open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{loginPage
? t("editAuthPageDomain")
: t("setAuthPageDomain")}
</CredenzaTitle>
<CredenzaDescription>
{t("selectDomainForOrgAuthPage")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<DomainPicker
hideFreeDomain={true}
orgId={org?.org.orgId as string}
cols={1}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain,
baseDomain: res.baseDomain
};
setSelectedDomain(selected);
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button
onClick={() => {
if (selectedDomain) {
handleDomainSelection(selectedDomain);
}
}}
disabled={!selectedDomain}
>
{t("selectDomain")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}
AuthPageSettings.displayName = "AuthPageSettings";