From cfde4e7443f1a5dab20a572df6967ba49d40e8c9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 12 Nov 2025 03:43:19 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 14 + server/auth/actions.ts | 4 +- server/db/pg/schema/privateSchema.ts | 109 ++- server/db/pg/schema/schema.ts | 19 - server/db/sqlite/schema/privateSchema.ts | 127 ++- server/db/sqlite/schema/schema.ts | 21 - server/lib/createResponseBodySchema.ts | 13 - .../routers/authPage/getOrgAuthPage.ts | 107 --- server/private/routers/authPage/index.ts | 15 - .../routers/authPage/updateOrgAuthPage.ts | 141 --- server/private/routers/external.ts | 48 +- .../loginPage/deleteLoginPageBranding.ts | 113 +++ .../routers/loginPage/getLoginPageBranding.ts | 103 +++ server/private/routers/loginPage/index.ts | 3 + .../loginPage/upsertLoginPageBranding.ts | 154 ++++ server/routers/loginPage/types.ts | 6 +- .../settings/general/auth-page/page.tsx | 64 ++ .../settings/general/auth-pages/page.tsx | 36 - src/app/[orgId]/settings/general/layout.tsx | 2 +- src/app/[orgId]/settings/general/page.tsx | 16 +- src/components/AuthPageBrandingForm.tsx | 319 +++++++ src/components/AuthPagesCustomizationForm.tsx | 56 -- src/components/private/AuthPageSettings.tsx | 868 +++++++++--------- 23 files changed, 1380 insertions(+), 978 deletions(-) delete mode 100644 server/lib/createResponseBodySchema.ts delete mode 100644 server/private/routers/authPage/getOrgAuthPage.ts delete mode 100644 server/private/routers/authPage/index.ts delete mode 100644 server/private/routers/authPage/updateOrgAuthPage.ts create mode 100644 server/private/routers/loginPage/deleteLoginPageBranding.ts create mode 100644 server/private/routers/loginPage/getLoginPageBranding.ts create mode 100644 server/private/routers/loginPage/upsertLoginPageBranding.ts create mode 100644 src/app/[orgId]/settings/general/auth-page/page.tsx delete mode 100644 src/app/[orgId]/settings/general/auth-pages/page.tsx create mode 100644 src/components/AuthPageBrandingForm.tsx delete mode 100644 src/components/AuthPagesCustomizationForm.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 178a9bb9..b790ed20 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 411ada44..d08457e5 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -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( diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 17d262c6..f9911095 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -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; export type Account = InferSelectModel; @@ -269,5 +305,6 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type LoginPageBranding = InferSelectModel; export type ActionAuditLog = InferSelectModel; -export type AccessAuditLog = InferSelectModel; \ No newline at end of file +export type AccessAuditLog = InferSelectModel; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 90cd1984..0b750d4e 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -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; export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; export type RequestAuditLog = InferSelectModel; -export type OrgAuthPage = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 65396770..e74964c2 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -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; export type Account = InferSelectModel; @@ -264,5 +306,6 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type LoginPageBranding = InferSelectModel; export type ActionAuditLog = InferSelectModel; -export type AccessAuditLog = InferSelectModel; \ No newline at end of file +export type AccessAuditLog = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5c293ffd..c96fefc5 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -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; export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; export type RequestAuditLog = InferSelectModel; -export type OrgAuthPage = InferSelectModel; diff --git a/server/lib/createResponseBodySchema.ts b/server/lib/createResponseBodySchema.ts deleted file mode 100644 index 478cc0c3..00000000 --- a/server/lib/createResponseBodySchema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import z, { type ZodSchema } from "zod"; - -export function createResponseBodySchema(dataSchema: T) { - return z.object({ - data: dataSchema.nullable(), - success: z.boolean(), - error: z.boolean(), - message: z.string(), - status: z.number() - }); -} - -export default createResponseBodySchema; diff --git a/server/private/routers/authPage/getOrgAuthPage.ts b/server/private/routers/authPage/getOrgAuthPage.ts deleted file mode 100644 index 03c03ac9..00000000 --- a/server/private/routers/authPage/getOrgAuthPage.ts +++ /dev/null @@ -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; - -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 { - 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") - ); - } -} diff --git a/server/private/routers/authPage/index.ts b/server/private/routers/authPage/index.ts deleted file mode 100644 index 2a01a879..00000000 --- a/server/private/routers/authPage/index.ts +++ /dev/null @@ -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"; diff --git a/server/private/routers/authPage/updateOrgAuthPage.ts b/server/private/routers/authPage/updateOrgAuthPage.ts deleted file mode 100644 index e6fa04f4..00000000 --- a/server/private/routers/authPage/updateOrgAuthPage.ts +++ /dev/null @@ -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; - -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 { - 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") - ); - } -} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 6d1cf6df..2a32d9ac 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -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 -); diff --git a/server/private/routers/loginPage/deleteLoginPageBranding.ts b/server/private/routers/loginPage/deleteLoginPageBranding.ts new file mode 100644 index 00000000..03bdf805 --- /dev/null +++ b/server/private/routers/loginPage/deleteLoginPageBranding.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/private/routers/loginPage/getLoginPageBranding.ts b/server/private/routers/loginPage/getLoginPageBranding.ts new file mode 100644 index 00000000..e0670545 --- /dev/null +++ b/server/private/routers/loginPage/getLoginPageBranding.ts @@ -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 { + 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(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") + ); + } +} diff --git a/server/private/routers/loginPage/index.ts b/server/private/routers/loginPage/index.ts index 2372ddfa..d3ae6540 100644 --- a/server/private/routers/loginPage/index.ts +++ b/server/private/routers/loginPage/index.ts @@ -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"; diff --git a/server/private/routers/loginPage/upsertLoginPageBranding.ts b/server/private/routers/loginPage/upsertLoginPageBranding.ts new file mode 100644 index 00000000..51aa7392 --- /dev/null +++ b/server/private/routers/loginPage/upsertLoginPageBranding.ts @@ -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; + +export async function upsertLoginPageBranding( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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(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") + ); + } +} diff --git a/server/routers/loginPage/types.ts b/server/routers/loginPage/types.ts index 26f59cab..652ed4e9 100644 --- a/server/routers/loginPage/types.ts +++ b/server/routers/loginPage/types.ts @@ -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 }; \ No newline at end of file +export type LoadLoginPageResponse = LoginPage & { orgId: string }; + +export type GetLoginPageBrandingResponse = LoginPageBranding; diff --git a/src/app/[orgId]/settings/general/auth-page/page.tsx b/src/app/[orgId]/settings/general/auth-page/page.tsx new file mode 100644 index 00000000..a0c883f0 --- /dev/null +++ b/src/app/[orgId]/settings/general/auth-page/page.tsx @@ -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>( + `/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>( + `/org/${orgId}/login-page-branding` + ); + if (res.status === 200) { + loginPageBranding = res.data.data; + } + } catch (error) {} + + return ( + + + + + ); +} diff --git a/src/app/[orgId]/settings/general/auth-pages/page.tsx b/src/app/[orgId]/settings/general/auth-pages/page.tsx deleted file mode 100644 index 79065513..00000000 --- a/src/app/[orgId]/settings/general/auth-pages/page.tsx +++ /dev/null @@ -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 ( - - - - ); -} diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index 5ace97ca..9472eb52 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -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` }); } diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index fdedba5c..5dd5ccb8 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -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(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() { - + - {build === "saas" && } -
{build !== "saas" && ( + )} */} + +
+ + + ); +} diff --git a/src/components/AuthPagesCustomizationForm.tsx b/src/components/AuthPagesCustomizationForm.tsx deleted file mode 100644 index fc695eeb..00000000 --- a/src/components/AuthPagesCustomizationForm.tsx +++ /dev/null @@ -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 ( -
- -
- ); -} diff --git a/src/components/private/AuthPageSettings.tsx b/src/components/private/AuthPageSettings.tsx index 95097a33..e6cd017d 100644 --- a/src/components/private/AuthPageSettings.tsx +++ b/src/components/private/AuthPageSettings.tsx @@ -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; 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( - ({ 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( - null - ); - const [loginPageExists, setLoginPageExists] = useState(false); - const [editDomainOpen, setEditDomainOpen] = useState(false); - const [baseDomains, setBaseDomains] = useState([]); - 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([]); + 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 - >(`/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 - >(`/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>( + `/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 - >(`/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 + >(`/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 - >(`/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 + >(`/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 ( - <> - - - - {t("authPage")} - - - {t("authPageDescription")} - - - - {build === "saas" && !subscription?.subscribed ? ( - - - {t("orgAuthPageDisabled")}{" "} - {t("subscriptionRequiredToUse")} - - - ) : null} - - - {loadingLoginPage ? ( -
-
- {t("loading")} -
-
- ) : ( -
- -
- -
- - - {loginPage && - !loginPage.domainId ? ( - - ) : loginPage?.fullDomain ? ( - - {`${window.location.protocol}//${loginPage.fullDomain}`} - - ) : 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") - )} - -
- - {form.watch( - "authPageDomainId" - ) && ( - - )} -
-
- - {!form.watch( - "authPageDomainId" - ) && ( -
- {t( - "addDomainToEnableCustomAuthPages" - )} -
- )} - - {env.flags - .usePangolinDns && - (build === "enterprise" || - (build === "saas" && - subscription?.subscribed)) && - loginPage?.domainId && - loginPage?.fullDomain && - !hasUnsavedChanges && ( - - )} -
-
- - )} -
-
-
- - {/* Domain Picker Modal */} - setEditDomainOpen(setOpen)} - > - - - - {loginPage - ? t("editAuthPageDomain") - : t("setAuthPageDomain")} - - - {t("selectDomainForOrgAuthPage")} - - - - { - const selected = { - domainId: res.domainId, - subdomain: res.subdomain, - fullDomain: res.fullDomain, - baseDomain: res.baseDomain - }; - setSelectedDomain(selected); - }} - /> - - - - - - - - - - - ); } -); + + return ( + <> + + + {t("authPage")} + + {t("authPageDescription")} + + + + {build === "saas" && !subscription?.subscribed ? ( + + + {t("orgAuthPageDisabled")}{" "} + {t("subscriptionRequiredToUse")} + + + ) : null} + + +
+ +
+ +
+ + + {loginPage && + !loginPage.domainId ? ( + + ) : loginPage?.fullDomain ? ( + + {`${window.location.protocol}//${loginPage.fullDomain}`} + + ) : 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") + )} + +
+ + {form.watch("authPageDomainId") && ( + + )} +
+
+ + {!form.watch("authPageDomainId") && ( +
+ {t( + "addDomainToEnableCustomAuthPages" + )} +
+ )} + + {env.flags.usePangolinDns && + (build === "enterprise" || + (build === "saas" && + subscription?.subscribed)) && + loginPage?.domainId && + loginPage?.fullDomain && + !hasUnsavedChanges && ( + + )} +
+
+ +
+
+ +
+ +
+
+ + {/* Domain Picker Modal */} + setEditDomainOpen(setOpen)} + > + + + + {loginPage + ? t("editAuthPageDomain") + : t("setAuthPageDomain")} + + + {t("selectDomainForOrgAuthPage")} + + + + { + const selected = { + domainId: res.domainId, + subdomain: res.subdomain, + fullDomain: res.fullDomain, + baseDomain: res.baseDomain + }; + setSelectedDomain(selected); + }} + /> + + + + + + + + + + + ); +} AuthPageSettings.displayName = "AuthPageSettings";