mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-04 09:46:40 +00:00
🚧 WIP
This commit is contained in:
@@ -1735,6 +1735,20 @@
|
|||||||
"authPage": "Auth Page",
|
"authPage": "Auth Page",
|
||||||
"authPageDescription": "Configure the auth page for your organization",
|
"authPageDescription": "Configure the auth page for your organization",
|
||||||
"authPageDomain": "Auth Page Domain",
|
"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",
|
"noDomainSet": "No domain set",
|
||||||
"changeDomain": "Change Domain",
|
"changeDomain": "Change Domain",
|
||||||
"selectDomain": "Select Domain",
|
"selectDomain": "Select Domain",
|
||||||
|
|||||||
@@ -123,9 +123,7 @@ export enum ActionsEnum {
|
|||||||
getBlueprint = "getBlueprint",
|
getBlueprint = "getBlueprint",
|
||||||
applyBlueprint = "applyBlueprint",
|
applyBlueprint = "applyBlueprint",
|
||||||
viewLogs = "viewLogs",
|
viewLogs = "viewLogs",
|
||||||
exportLogs = "exportLogs",
|
exportLogs = "exportLogs"
|
||||||
updateOrgAuthPage = "updateOrgAuthPage",
|
|
||||||
getOrgAuthPage = "getOrgAuthPage"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
@@ -204,6 +204,28 @@ export const loginPageOrg = pgTable("loginPageOrg", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
.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", {
|
export const sessionTransferToken = pgTable("sessionTransferToken", {
|
||||||
token: varchar("token").primaryKey(),
|
token: varchar("token").primaryKey(),
|
||||||
sessionId: varchar("sessionId")
|
sessionId: varchar("sessionId")
|
||||||
@@ -215,42 +237,56 @@ export const sessionTransferToken = pgTable("sessionTransferToken", {
|
|||||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionAuditLog = pgTable("actionAuditLog", {
|
export const actionAuditLog = pgTable(
|
||||||
id: serial("id").primaryKey(),
|
"actionAuditLog",
|
||||||
timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds
|
{
|
||||||
orgId: varchar("orgId")
|
id: serial("id").primaryKey(),
|
||||||
.notNull()
|
timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
orgId: varchar("orgId")
|
||||||
actorType: varchar("actorType", { length: 50 }).notNull(),
|
.notNull()
|
||||||
actor: varchar("actor", { length: 255 }).notNull(),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
actorId: varchar("actorId", { length: 255 }).notNull(),
|
actorType: varchar("actorType", { length: 50 }).notNull(),
|
||||||
action: varchar("action", { length: 100 }).notNull(),
|
actor: varchar("actor", { length: 255 }).notNull(),
|
||||||
metadata: text("metadata")
|
actorId: varchar("actorId", { length: 255 }).notNull(),
|
||||||
}, (table) => ([
|
action: varchar("action", { length: 100 }).notNull(),
|
||||||
index("idx_actionAuditLog_timestamp").on(table.timestamp),
|
metadata: text("metadata")
|
||||||
index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp)
|
},
|
||||||
]));
|
(table) => [
|
||||||
|
index("idx_actionAuditLog_timestamp").on(table.timestamp),
|
||||||
|
index("idx_actionAuditLog_org_timestamp").on(
|
||||||
|
table.orgId,
|
||||||
|
table.timestamp
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const accessAuditLog = pgTable("accessAuditLog", {
|
export const accessAuditLog = pgTable(
|
||||||
id: serial("id").primaryKey(),
|
"accessAuditLog",
|
||||||
timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds
|
{
|
||||||
orgId: varchar("orgId")
|
id: serial("id").primaryKey(),
|
||||||
.notNull()
|
timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
orgId: varchar("orgId")
|
||||||
actorType: varchar("actorType", { length: 50 }),
|
.notNull()
|
||||||
actor: varchar("actor", { length: 255 }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
actorId: varchar("actorId", { length: 255 }),
|
actorType: varchar("actorType", { length: 50 }),
|
||||||
resourceId: integer("resourceId"),
|
actor: varchar("actor", { length: 255 }),
|
||||||
ip: varchar("ip", { length: 45 }),
|
actorId: varchar("actorId", { length: 255 }),
|
||||||
type: varchar("type", { length: 100 }).notNull(),
|
resourceId: integer("resourceId"),
|
||||||
action: boolean("action").notNull(),
|
ip: varchar("ip", { length: 45 }),
|
||||||
location: text("location"),
|
type: varchar("type", { length: 100 }).notNull(),
|
||||||
userAgent: text("userAgent"),
|
action: boolean("action").notNull(),
|
||||||
metadata: text("metadata")
|
location: text("location"),
|
||||||
}, (table) => ([
|
userAgent: text("userAgent"),
|
||||||
index("idx_identityAuditLog_timestamp").on(table.timestamp),
|
metadata: text("metadata")
|
||||||
index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp)
|
},
|
||||||
]));
|
(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 Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -269,5 +305,6 @@ export type RemoteExitNodeSession = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
||||||
export type LoginPage = InferSelectModel<typeof loginPage>;
|
export type LoginPage = InferSelectModel<typeof loginPage>;
|
||||||
|
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
|
||||||
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
||||||
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
||||||
|
|||||||
@@ -65,24 +65,6 @@ export const orgDomains = pgTable("orgDomains", {
|
|||||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
.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", {
|
export const sites = pgTable("sites", {
|
||||||
siteId: serial("siteId").primaryKey(),
|
siteId: serial("siteId").primaryKey(),
|
||||||
orgId: varchar("orgId")
|
orgId: varchar("orgId")
|
||||||
@@ -828,4 +810,3 @@ export type LicenseKey = InferSelectModel<typeof licenseKey>;
|
|||||||
export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
||||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||||
export type OrgAuthPage = InferSelectModel<typeof orgAuthPages>;
|
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export const certificates = sqliteTable("certificates", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const dnsChallenge = sqliteTable("dnsChallenges", {
|
export const dnsChallenge = sqliteTable("dnsChallenges", {
|
||||||
dnsChallengeId: integer("dnsChallengeId").primaryKey({ autoIncrement: true }),
|
dnsChallengeId: integer("dnsChallengeId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
domain: text("domain").notNull(),
|
domain: text("domain").notNull(),
|
||||||
token: text("token").notNull(),
|
token: text("token").notNull(),
|
||||||
keyAuthorization: text("keyAuthorization").notNull(),
|
keyAuthorization: text("keyAuthorization").notNull(),
|
||||||
@@ -61,9 +63,7 @@ export const customers = sqliteTable("customers", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const subscriptions = sqliteTable("subscriptions", {
|
export const subscriptions = sqliteTable("subscriptions", {
|
||||||
subscriptionId: text("subscriptionId")
|
subscriptionId: text("subscriptionId").primaryKey().notNull(),
|
||||||
.primaryKey()
|
|
||||||
.notNull(),
|
|
||||||
customerId: text("customerId")
|
customerId: text("customerId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => customers.customerId, { onDelete: "cascade" }),
|
.references(() => customers.customerId, { onDelete: "cascade" }),
|
||||||
@@ -75,7 +75,9 @@ export const subscriptions = sqliteTable("subscriptions", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const subscriptionItems = sqliteTable("subscriptionItems", {
|
export const subscriptionItems = sqliteTable("subscriptionItems", {
|
||||||
subscriptionItemId: integer("subscriptionItemId").primaryKey({ autoIncrement: true }),
|
subscriptionItemId: integer("subscriptionItemId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
subscriptionId: text("subscriptionId")
|
subscriptionId: text("subscriptionId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => subscriptions.subscriptionId, {
|
.references(() => subscriptions.subscriptionId, {
|
||||||
@@ -129,7 +131,9 @@ export const limits = sqliteTable("limits", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const usageNotifications = sqliteTable("usageNotifications", {
|
export const usageNotifications = sqliteTable("usageNotifications", {
|
||||||
notificationId: integer("notificationId").primaryKey({ autoIncrement: true }),
|
notificationId: integer("notificationId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
orgId: text("orgId")
|
orgId: text("orgId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
@@ -199,6 +203,30 @@ export const loginPageOrg = sqliteTable("loginPageOrg", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
.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", {
|
export const sessionTransferToken = sqliteTable("sessionTransferToken", {
|
||||||
token: text("token").primaryKey(),
|
token: text("token").primaryKey(),
|
||||||
sessionId: text("sessionId")
|
sessionId: text("sessionId")
|
||||||
@@ -210,42 +238,56 @@ export const sessionTransferToken = sqliteTable("sessionTransferToken", {
|
|||||||
expiresAt: integer("expiresAt").notNull()
|
expiresAt: integer("expiresAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionAuditLog = sqliteTable("actionAuditLog", {
|
export const actionAuditLog = sqliteTable(
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
"actionAuditLog",
|
||||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
{
|
||||||
orgId: text("orgId")
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
.notNull()
|
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
orgId: text("orgId")
|
||||||
actorType: text("actorType").notNull(),
|
.notNull()
|
||||||
actor: text("actor").notNull(),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
actorId: text("actorId").notNull(),
|
actorType: text("actorType").notNull(),
|
||||||
action: text("action").notNull(),
|
actor: text("actor").notNull(),
|
||||||
metadata: text("metadata")
|
actorId: text("actorId").notNull(),
|
||||||
}, (table) => ([
|
action: text("action").notNull(),
|
||||||
index("idx_actionAuditLog_timestamp").on(table.timestamp),
|
metadata: text("metadata")
|
||||||
index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp)
|
},
|
||||||
]));
|
(table) => [
|
||||||
|
index("idx_actionAuditLog_timestamp").on(table.timestamp),
|
||||||
|
index("idx_actionAuditLog_org_timestamp").on(
|
||||||
|
table.orgId,
|
||||||
|
table.timestamp
|
||||||
|
)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const accessAuditLog = sqliteTable("accessAuditLog", {
|
export const accessAuditLog = sqliteTable(
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
"accessAuditLog",
|
||||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
{
|
||||||
orgId: text("orgId")
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
.notNull()
|
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
orgId: text("orgId")
|
||||||
actorType: text("actorType"),
|
.notNull()
|
||||||
actor: text("actor"),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
actorId: text("actorId"),
|
actorType: text("actorType"),
|
||||||
resourceId: integer("resourceId"),
|
actor: text("actor"),
|
||||||
ip: text("ip"),
|
actorId: text("actorId"),
|
||||||
location: text("location"),
|
resourceId: integer("resourceId"),
|
||||||
type: text("type").notNull(),
|
ip: text("ip"),
|
||||||
action: integer("action", { mode: "boolean" }).notNull(),
|
location: text("location"),
|
||||||
userAgent: text("userAgent"),
|
type: text("type").notNull(),
|
||||||
metadata: text("metadata")
|
action: integer("action", { mode: "boolean" }).notNull(),
|
||||||
}, (table) => ([
|
userAgent: text("userAgent"),
|
||||||
index("idx_identityAuditLog_timestamp").on(table.timestamp),
|
metadata: text("metadata")
|
||||||
index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp)
|
},
|
||||||
]));
|
(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 Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -264,5 +306,6 @@ export type RemoteExitNodeSession = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
||||||
export type LoginPage = InferSelectModel<typeof loginPage>;
|
export type LoginPage = InferSelectModel<typeof loginPage>;
|
||||||
|
export type LoginPageBranding = InferSelectModel<typeof loginPageBranding>;
|
||||||
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
||||||
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
||||||
|
|||||||
@@ -72,26 +72,6 @@ export const orgDomains = sqliteTable("orgDomains", {
|
|||||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
.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", {
|
export const sites = sqliteTable("sites", {
|
||||||
siteId: integer("siteId").primaryKey({ autoIncrement: true }),
|
siteId: integer("siteId").primaryKey({ autoIncrement: true }),
|
||||||
orgId: text("orgId")
|
orgId: text("orgId")
|
||||||
@@ -885,4 +865,3 @@ export type LicenseKey = InferSelectModel<typeof licenseKey>;
|
|||||||
export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
||||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||||
export type OrgAuthPage = InferSelectModel<typeof orgAuthPages>;
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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";
|
|
||||||
@@ -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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ import * as billing from "#private/routers/billing";
|
|||||||
import * as remoteExitNode from "#private/routers/remoteExitNode";
|
import * as remoteExitNode from "#private/routers/remoteExitNode";
|
||||||
import * as loginPage from "#private/routers/loginPage";
|
import * as loginPage from "#private/routers/loginPage";
|
||||||
import * as orgIdp from "#private/routers/orgIdp";
|
import * as orgIdp from "#private/routers/orgIdp";
|
||||||
import * as authPage from "#private/routers/authPage";
|
|
||||||
import * as domain from "#private/routers/domain";
|
import * as domain from "#private/routers/domain";
|
||||||
import * as auth from "#private/routers/auth";
|
import * as auth from "#private/routers/auth";
|
||||||
import * as license from "#private/routers/license";
|
import * as license from "#private/routers/license";
|
||||||
@@ -309,6 +308,33 @@ authenticated.get(
|
|||||||
loginPage.getLoginPage
|
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(
|
authRouter.post(
|
||||||
"/remoteExitNode/get-token",
|
"/remoteExitNode/get-token",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
@@ -404,23 +430,3 @@ authenticated.get(
|
|||||||
logActionAudit(ActionsEnum.exportLogs),
|
logActionAudit(ActionsEnum.exportLogs),
|
||||||
logs.exportAccessAuditLogs
|
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
|
|
||||||
);
|
|
||||||
|
|||||||
113
server/private/routers/loginPage/deleteLoginPageBranding.ts
Normal file
113
server/private/routers/loginPage/deleteLoginPageBranding.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
103
server/private/routers/loginPage/getLoginPageBranding.ts
Normal file
103
server/private/routers/loginPage/getLoginPageBranding.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,3 +17,6 @@ export * from "./getLoginPage";
|
|||||||
export * from "./loadLoginPage";
|
export * from "./loadLoginPage";
|
||||||
export * from "./updateLoginPage";
|
export * from "./updateLoginPage";
|
||||||
export * from "./deleteLoginPage";
|
export * from "./deleteLoginPage";
|
||||||
|
export * from "./upsertLoginPageBranding";
|
||||||
|
export * from "./deleteLoginPageBranding";
|
||||||
|
export * from "./getLoginPageBranding";
|
||||||
|
|||||||
154
server/private/routers/loginPage/upsertLoginPageBranding.ts
Normal file
154
server/private/routers/loginPage/upsertLoginPageBranding.ts
Normal 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LoginPage } from "@server/db";
|
import type { LoginPage, LoginPageBranding } from "@server/db";
|
||||||
|
|
||||||
export type CreateLoginPageResponse = LoginPage;
|
export type CreateLoginPageResponse = LoginPage;
|
||||||
|
|
||||||
@@ -8,4 +8,6 @@ export type GetLoginPageResponse = LoginPage;
|
|||||||
|
|
||||||
export type UpdateLoginPageResponse = LoginPage;
|
export type UpdateLoginPageResponse = LoginPage;
|
||||||
|
|
||||||
export type LoadLoginPageResponse = LoginPage & { orgId: string };
|
export type LoadLoginPageResponse = LoginPage & { orgId: string };
|
||||||
|
|
||||||
|
export type GetLoginPageBrandingResponse = LoginPageBranding;
|
||||||
|
|||||||
64
src/app/[orgId]/settings/general/auth-page/page.tsx
Normal file
64
src/app/[orgId]/settings/general/auth-page/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -73,7 +73,7 @@ export default async function GeneralSettingsPage({
|
|||||||
if (subscribed) {
|
if (subscribed) {
|
||||||
navItems.push({
|
navItems.push({
|
||||||
title: t("authPage"),
|
title: t("authPage"),
|
||||||
href: `/{orgId}/settings/general/auth-pages`
|
href: `/{orgId}/settings/general/auth-page`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,7 @@ import {
|
|||||||
SettingsSectionTitle,
|
SettingsSectionTitle,
|
||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
SettingsSectionForm,
|
SettingsSectionForm
|
||||||
SettingsSectionFooter
|
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -129,7 +128,6 @@ export default function GeneralPage() {
|
|||||||
const [loadingSave, setLoadingSave] = useState(false);
|
const [loadingSave, setLoadingSave] = useState(false);
|
||||||
const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] =
|
const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const authPageSettingsRef = useRef<AuthPageSettingsRef>(null);
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(GeneralFormSchema),
|
resolver: zodResolver(GeneralFormSchema),
|
||||||
@@ -252,14 +250,6 @@ export default function GeneralPage() {
|
|||||||
// Update organization
|
// Update organization
|
||||||
await api.post(`/org/${org?.org.orgId}`, reqData);
|
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({
|
toast({
|
||||||
title: t("orgUpdated"),
|
title: t("orgUpdated"),
|
||||||
description: t("orgUpdatedDescription")
|
description: t("orgUpdatedDescription")
|
||||||
@@ -600,7 +590,7 @@ export default function GeneralPage() {
|
|||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<SecurityFeaturesAlert />
|
<SecurityFeaturesAlert />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="requireTwoFactor"
|
name="requireTwoFactor"
|
||||||
@@ -832,8 +822,6 @@ export default function GeneralPage() {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{build !== "saas" && (
|
{build !== "saas" && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
319
src/components/AuthPageBrandingForm.tsx
Normal file
319
src/components/AuthPageBrandingForm.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,15 @@
|
|||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useState, useEffect, forwardRef, useImperativeHandle } from "react";
|
import {
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
RefObject,
|
||||||
|
Ref,
|
||||||
|
useActionState
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -52,7 +60,6 @@ import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
|||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
|
||||||
// Auth page form schema
|
// Auth page form schema
|
||||||
@@ -66,6 +73,7 @@ type AuthPageFormValues = z.infer<typeof AuthPageFormSchema>;
|
|||||||
interface AuthPageSettingsProps {
|
interface AuthPageSettingsProps {
|
||||||
onSaveSuccess?: () => void;
|
onSaveSuccess?: () => void;
|
||||||
onSaveError?: (error: any) => void;
|
onSaveError?: (error: any) => void;
|
||||||
|
loginPage: GetLoginPageResponse | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthPageSettingsRef {
|
export interface AuthPageSettingsRef {
|
||||||
@@ -73,476 +81,434 @@ export interface AuthPageSettingsRef {
|
|||||||
hasUnsavedChanges: () => boolean;
|
hasUnsavedChanges: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthPageSettings = forwardRef<AuthPageSettingsRef, AuthPageSettingsProps>(
|
function AuthPageSettings({
|
||||||
({ onSaveSuccess, onSaveError }, ref) => {
|
onSaveSuccess,
|
||||||
const { org } = useOrgContext();
|
onSaveError,
|
||||||
const api = createApiClient(useEnvContext());
|
loginPage: defaultLoginPage
|
||||||
const router = useRouter();
|
}: AuthPageSettingsProps) {
|
||||||
const t = useTranslations();
|
const { org } = useOrgContext();
|
||||||
const { env } = useEnvContext();
|
const api = createApiClient(useEnvContext());
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const subscription = useSubscriptionStatusContext();
|
const subscription = useSubscriptionStatusContext();
|
||||||
|
|
||||||
// Auth page domain state
|
// Auth page domain state
|
||||||
const [loginPage, setLoginPage] = useState<GetLoginPageResponse | null>(
|
const [loginPage, setLoginPage] = useState(defaultLoginPage);
|
||||||
null
|
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
|
||||||
);
|
const [loginPageExists, setLoginPageExists] = useState(
|
||||||
const [loginPageExists, setLoginPageExists] = useState(false);
|
Boolean(defaultLoginPage)
|
||||||
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
);
|
||||||
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
|
const [editDomainOpen, setEditDomainOpen] = useState(false);
|
||||||
const [selectedDomain, setSelectedDomain] = useState<{
|
const [baseDomains, setBaseDomains] = useState<DomainRow[]>([]);
|
||||||
domainId: string;
|
const [selectedDomain, setSelectedDomain] = useState<{
|
||||||
subdomain?: string;
|
domainId: string;
|
||||||
fullDomain: string;
|
subdomain?: string;
|
||||||
baseDomain: string;
|
fullDomain: string;
|
||||||
} | null>(null);
|
baseDomain: string;
|
||||||
const [loadingLoginPage, setLoadingLoginPage] = useState(true);
|
} | null>(null);
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
const [loadingSave, setLoadingSave] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(AuthPageFormSchema),
|
resolver: zodResolver(AuthPageFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
authPageDomainId: loginPage?.domainId || "",
|
authPageDomainId: loginPage?.domainId || "",
|
||||||
authPageSubdomain: loginPage?.subdomain || ""
|
authPageSubdomain: loginPage?.subdomain || ""
|
||||||
},
|
},
|
||||||
mode: "onChange"
|
mode: "onChange"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expose save function to parent component
|
// Expose save function to parent component
|
||||||
useImperativeHandle(
|
// useImperativeHandle(
|
||||||
ref,
|
// ref,
|
||||||
() => ({
|
// () => ({
|
||||||
saveAuthSettings: async () => {
|
// saveAuthSettings: async () => {
|
||||||
await form.handleSubmit(onSubmit)();
|
// await form.handleSubmit(onSubmit)();
|
||||||
},
|
// },
|
||||||
hasUnsavedChanges: () => hasUnsavedChanges
|
// hasUnsavedChanges: () => hasUnsavedChanges
|
||||||
}),
|
// }),
|
||||||
[form, 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);
|
|
||||||
|
|
||||||
|
// Fetch login page and domains data
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchDomains = async () => {
|
||||||
try {
|
try {
|
||||||
// Handle auth page domain
|
const res = await api.get<AxiosResponse<ListDomainsResponse>>(
|
||||||
if (data.authPageDomainId) {
|
`/org/${org?.org.orgId}/domains/`
|
||||||
if (
|
);
|
||||||
build === "enterprise" ||
|
if (res.status === 200) {
|
||||||
(build === "saas" && subscription?.subscribed)
|
const rawDomains = res.data.data.domains as DomainRow[];
|
||||||
) {
|
const domains = rawDomains.map((domain) => ({
|
||||||
const sanitizedSubdomain = data.authPageSubdomain
|
...domain,
|
||||||
? finalizeSubdomainSanitize(data.authPageSubdomain)
|
baseDomain: toUnicode(domain.baseDomain)
|
||||||
: "";
|
}));
|
||||||
|
setBaseDomains(domains);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch domains:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loginPageExists) {
|
if (org?.org.orgId) {
|
||||||
// Login page exists on server - need to update it
|
fetchDomains();
|
||||||
// First, we need to get the loginPageId from the server since loginPage might be null locally
|
}
|
||||||
let loginPageId: number;
|
}, []);
|
||||||
|
|
||||||
if (loginPage) {
|
// Handle domain selection from modal
|
||||||
// We have the loginPage data locally
|
function handleDomainSelection(domain: {
|
||||||
loginPageId = loginPage.loginPageId;
|
domainId: string;
|
||||||
} else {
|
subdomain?: string;
|
||||||
// User cleared selection locally, but login page still exists on server
|
fullDomain: string;
|
||||||
// We need to fetch it to get the loginPageId
|
baseDomain: string;
|
||||||
const fetchRes = await api.get<
|
}) {
|
||||||
AxiosResponse<GetLoginPageResponse>
|
form.setValue("authPageDomainId", domain.domainId);
|
||||||
>(`/org/${org?.org.orgId}/login-page`);
|
form.setValue("authPageSubdomain", domain.subdomain || "");
|
||||||
loginPageId = fetchRes.data.data.loginPageId;
|
setEditDomainOpen(false);
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing auth page domain
|
// Update loginPage state to show the selected domain immediately
|
||||||
const updateRes = await api.post(
|
const sanitizedSubdomain = domain.subdomain
|
||||||
`/org/${org?.org.orgId}/login-page/${loginPageId}`,
|
? finalizeSubdomainSanitize(domain.subdomain)
|
||||||
{
|
: "";
|
||||||
domainId: data.authPageDomainId,
|
|
||||||
subdomain: sanitizedSubdomain || null
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (updateRes.status === 201) {
|
const sanitizedFullDomain = sanitizedSubdomain
|
||||||
setLoginPage(updateRes.data.data);
|
? `${sanitizedSubdomain}.${domain.baseDomain}`
|
||||||
setLoginPageExists(true);
|
: 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 {
|
} else {
|
||||||
// No login page exists on server - create new one
|
// User cleared selection locally, but login page still exists on server
|
||||||
const createRes = await api.put(
|
// We need to fetch it to get the loginPageId
|
||||||
`/org/${org?.org.orgId}/login-page`,
|
const fetchRes = await api.get<
|
||||||
{
|
AxiosResponse<GetLoginPageResponse>
|
||||||
domainId: data.authPageDomainId,
|
>(`/org/${org?.org.orgId}/login-page`);
|
||||||
subdomain: sanitizedSubdomain || null
|
loginPageId = fetchRes.data.data.loginPageId;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (createRes.status === 201) {
|
// Update existing auth page domain
|
||||||
setLoginPage(createRes.data.data);
|
const updateRes = await api.post(
|
||||||
setLoginPageExists(true);
|
`/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
|
} else if (loginPageExists) {
|
||||||
let loginPageId: number;
|
// Delete existing auth page domain if no domain selected
|
||||||
|
let loginPageId: number;
|
||||||
|
|
||||||
if (loginPage) {
|
if (loginPage) {
|
||||||
// We have the loginPage data locally
|
// We have the loginPage data locally
|
||||||
loginPageId = loginPage.loginPageId;
|
loginPageId = loginPage.loginPageId;
|
||||||
} else {
|
} else {
|
||||||
// User cleared selection locally, but login page still exists on server
|
// User cleared selection locally, but login page still exists on server
|
||||||
// We need to fetch it to get the loginPageId
|
// We need to fetch it to get the loginPageId
|
||||||
const fetchRes = await api.get<
|
const fetchRes = await api.get<
|
||||||
AxiosResponse<GetLoginPageResponse>
|
AxiosResponse<GetLoginPageResponse>
|
||||||
>(`/org/${org?.org.orgId}/login-page`);
|
>(`/org/${org?.org.orgId}/login-page`);
|
||||||
loginPageId = fetchRes.data.data.loginPageId;
|
loginPageId = fetchRes.data.data.loginPageId;
|
||||||
}
|
|
||||||
|
|
||||||
await api.delete(
|
|
||||||
`/org/${org?.org.orgId}/login-page/${loginPageId}`
|
|
||||||
);
|
|
||||||
setLoginPage(null);
|
|
||||||
setLoginPageExists(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasUnsavedChanges(false);
|
await api.delete(
|
||||||
router.refresh();
|
`/org/${org?.org.orgId}/login-page/${loginPageId}`
|
||||||
onSaveSuccess?.();
|
);
|
||||||
} catch (e) {
|
setLoginPage(null);
|
||||||
toast({
|
setLoginPageExists(false);
|
||||||
variant: "destructive",
|
|
||||||
title: t("authPageErrorUpdate"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("authPageErrorUpdateMessage")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
onSaveError?.(e);
|
|
||||||
} finally {
|
|
||||||
setLoadingSave(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";
|
AuthPageSettings.displayName = "AuthPageSettings";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user