mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-05 18:26:40 +00:00
Merge pull request #1846 from Fredkiss3/feat/login-page-customization
feat: login page customization
This commit is contained in:
@@ -1810,6 +1810,26 @@
|
|||||||
"authPage": "Auth Page",
|
"authPage": "Auth Page",
|
||||||
"authPageDescription": "Configure the auth page for the organization",
|
"authPageDescription": "Configure the auth page for the organization",
|
||||||
"authPageDomain": "Auth Page Domain",
|
"authPageDomain": "Auth Page Domain",
|
||||||
|
"authPageBranding": "Branding",
|
||||||
|
"authPageBrandingDescription": "Configure the branding for the auth page for your organization",
|
||||||
|
"authPageBrandingUpdated": "Auth page Branding updated successfully",
|
||||||
|
"authPageBrandingRemoved": "Auth page Branding removed successfully",
|
||||||
|
"authPageBrandingRemoveTitle": "Remove Auth Page Branding",
|
||||||
|
"authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?",
|
||||||
|
"authPageBrandingDeleteConfirm": "Confirm Delete Branding",
|
||||||
|
"brandingLogoURL": "Logo URL",
|
||||||
|
"brandingPrimaryColor": "Primary Color",
|
||||||
|
"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",
|
||||||
|
"saveAuthPageDomain": "Save Domain",
|
||||||
|
"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",
|
||||||
@@ -1888,7 +1908,7 @@
|
|||||||
"securityPolicyChangeWarningText": "This will affect all users in the organization",
|
"securityPolicyChangeWarningText": "This will affect all users in the organization",
|
||||||
"authPageErrorUpdateMessage": "An error occurred while updating the auth page settings",
|
"authPageErrorUpdateMessage": "An error occurred while updating the auth page settings",
|
||||||
"authPageErrorUpdate": "Unable to update auth page",
|
"authPageErrorUpdate": "Unable to update auth page",
|
||||||
"authPageUpdated": "Auth page updated successfully",
|
"authPageDomainUpdated": "Auth page Domain updated successfully",
|
||||||
"healthCheckNotAvailable": "Local",
|
"healthCheckNotAvailable": "Local",
|
||||||
"rewritePath": "Rewrite Path",
|
"rewritePath": "Rewrite Path",
|
||||||
"rewritePathDescription": "Optionally rewrite the path before forwarding to the target.",
|
"rewritePathDescription": "Optionally rewrite the path before forwarding to the target.",
|
||||||
|
|||||||
2272
package-lock.json
generated
2272
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,9 +19,9 @@
|
|||||||
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
|
||||||
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
|
||||||
"db:clear-migrations": "rm -rf server/migrations",
|
"db:clear-migrations": "rm -rf server/migrations",
|
||||||
"set:oss": "echo 'export const build = \"oss\" as any;' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
|
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
|
||||||
"set:saas": "echo 'export const build = \"saas\" as any;' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
||||||
"set:enterprise": "echo 'export const build = \"enterprise\" as any;' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
|
"set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
|
||||||
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts",
|
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts",
|
||||||
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
|
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
|
||||||
"next:build": "next build",
|
"next:build": "next build",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const runMigrations = async () => {
|
|||||||
await migrate(db as any, {
|
await migrate(db as any, {
|
||||||
migrationsFolder: migrationsFolder
|
migrationsFolder: migrationsFolder
|
||||||
});
|
});
|
||||||
console.log("Migrations completed successfully.");
|
console.log("Migrations completed successfully. ✅");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error running migrations:", error);
|
console.error("Error running migrations:", error);
|
||||||
|
|||||||
@@ -204,6 +204,29 @@ 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(),
|
||||||
|
primaryColor: text("primaryColor"),
|
||||||
|
resourceTitle: text("resourceTitle").notNull(),
|
||||||
|
resourceSubtitle: text("resourceSubtitle"),
|
||||||
|
orgTitle: text("orgTitle"),
|
||||||
|
orgSubtitle: text("orgSubtitle")
|
||||||
|
});
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -283,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>;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
bigint,
|
bigint,
|
||||||
real,
|
real,
|
||||||
text,
|
text,
|
||||||
index
|
index,
|
||||||
|
uniqueIndex
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import {
|
|
||||||
sqliteTable,
|
|
||||||
integer,
|
|
||||||
text,
|
|
||||||
real,
|
|
||||||
index
|
|
||||||
} from "drizzle-orm/sqlite-core";
|
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import { domains, orgs, targets, users, exitNodes, sessions } from "./schema";
|
import {
|
||||||
import { metadata } from "@app/app/[orgId]/settings/layout";
|
index,
|
||||||
|
integer,
|
||||||
|
real,
|
||||||
|
sqliteTable,
|
||||||
|
text
|
||||||
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
import { domains, exitNodes, orgs, sessions, users } from "./schema";
|
||||||
|
|
||||||
export const certificates = sqliteTable("certificates", {
|
export const certificates = sqliteTable("certificates", {
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||||
@@ -203,6 +202,31 @@ 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(),
|
||||||
|
primaryColor: text("primaryColor"),
|
||||||
|
resourceTitle: text("resourceTitle").notNull(),
|
||||||
|
resourceSubtitle: text("resourceSubtitle"),
|
||||||
|
orgTitle: text("orgTitle"),
|
||||||
|
orgSubtitle: text("orgSubtitle")
|
||||||
|
});
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -282,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>;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
import {
|
||||||
|
sqliteTable,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
index,
|
||||||
|
uniqueIndex
|
||||||
|
} from "drizzle-orm/sqlite-core";
|
||||||
import { no } from "zod/v4/locales";
|
import { no } from "zod/v4/locales";
|
||||||
|
|
||||||
export const domains = sqliteTable("domains", {
|
export const domains = sqliteTable("domains", {
|
||||||
|
|||||||
@@ -311,6 +311,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,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ internalRouter.get("/org/:orgId/idp", orgIdp.listOrgIdps);
|
|||||||
internalRouter.get("/org/:orgId/billing/tier", billing.getOrgTier);
|
internalRouter.get("/org/:orgId/billing/tier", billing.getOrgTier);
|
||||||
|
|
||||||
internalRouter.get("/login-page", loginPage.loadLoginPage);
|
internalRouter.get("/login-page", loginPage.loadLoginPage);
|
||||||
|
internalRouter.get("/login-page-branding", loginPage.loadLoginPageBranding);
|
||||||
|
|
||||||
internalRouter.post(
|
internalRouter.post(
|
||||||
"/get-session-transfer-token",
|
"/get-session-transfer-token",
|
||||||
|
|||||||
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.OK
|
||||||
|
});
|
||||||
|
} 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.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,3 +17,7 @@ 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";
|
||||||
|
export * from "./loadLoginPageBranding";
|
||||||
|
|||||||
100
server/private/routers/loginPage/loadLoginPageBranding.ts
Normal file
100
server/private/routers/loginPage/loadLoginPageBranding.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* 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, loginPageBrandingOrg, orgs } from "@server/db";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
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 type { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
orgId: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
async function query(orgId: string) {
|
||||||
|
const [orgLink] = await db
|
||||||
|
.select()
|
||||||
|
.from(loginPageBrandingOrg)
|
||||||
|
.where(eq(loginPageBrandingOrg.orgId, orgId))
|
||||||
|
.innerJoin(orgs, eq(loginPageBrandingOrg.orgId, orgs.orgId));
|
||||||
|
if (!orgLink) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [res] = await db
|
||||||
|
.select()
|
||||||
|
.from(loginPageBranding)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
loginPageBranding.loginPageBrandingId,
|
||||||
|
orgLink.loginPageBrandingOrg.loginPageBrandingId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
orgId: orgLink.orgs.orgId,
|
||||||
|
orgName: orgLink.orgs.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLoginPageBranding(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedQuery = querySchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedQuery.data;
|
||||||
|
|
||||||
|
const branding = await query(orgId);
|
||||||
|
|
||||||
|
if (!branding) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Branding for Login page not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<LoadLoginPageBrandingResponse>(res, {
|
||||||
|
data: branding,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Login page branding retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
162
server/private/routers/loginPage/upsertLoginPageBranding.ts
Normal file
162
server/private/routers/loginPage/upsertLoginPageBranding.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
* 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, InferInsertModel } from "drizzle-orm";
|
||||||
|
import { getOrgTierData } from "#private/lib/billing";
|
||||||
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.strictObject({
|
||||||
|
logoUrl: z.url(),
|
||||||
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
|
resourceTitle: z.string(),
|
||||||
|
resourceSubtitle: z.string().optional(),
|
||||||
|
orgTitle: z.string().optional(),
|
||||||
|
orgSubtitle: z.string().optional(),
|
||||||
|
primaryColor: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updateData = parsedBody.data satisfies InferInsertModel<
|
||||||
|
typeof loginPageBranding
|
||||||
|
>;
|
||||||
|
|
||||||
|
if (build !== "saas") {
|
||||||
|
// org branding settings are only considered in the saas build
|
||||||
|
const { orgTitle, orgSubtitle, ...rest } = updateData;
|
||||||
|
updateData = rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: existingLoginPageBranding ? HttpCode.OK : 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;
|
||||||
|
|
||||||
@@ -9,3 +9,10 @@ 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 LoadLoginPageBrandingResponse = LoginPageBranding & {
|
||||||
|
orgId: string;
|
||||||
|
orgName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetLoginPageBrandingResponse = LoginPageBranding;
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ export async function getResourceAuthInfo(
|
|||||||
resourcePassword,
|
resourcePassword,
|
||||||
eq(resourcePassword.resourceId, resources.resourceId)
|
eq(resourcePassword.resourceId, resources.resourceId)
|
||||||
)
|
)
|
||||||
|
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
eq(
|
eq(
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { internal } from "@app/lib/api";
|
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import OrgUserProvider from "@app/providers/OrgUserProvider";
|
import OrgUserProvider from "@app/providers/OrgUserProvider";
|
||||||
import { GetOrgResponse } from "@server/routers/org";
|
|
||||||
import { GetOrgUserResponse } from "@server/routers/user";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||||
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
|
|
||||||
type BillingSettingsProps = {
|
type BillingSettingsProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -23,8 +18,7 @@ export default async function BillingSettingsPage({
|
|||||||
}: BillingSettingsProps) {
|
}: BillingSettingsProps) {
|
||||||
const { orgId } = await params;
|
const { orgId } = await params;
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession();
|
||||||
const user = await getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(`/`);
|
redirect(`/`);
|
||||||
@@ -32,13 +26,7 @@ export default async function BillingSettingsPage({
|
|||||||
|
|
||||||
let orgUser = null;
|
let orgUser = null;
|
||||||
try {
|
try {
|
||||||
const getOrgUser = cache(async () =>
|
const res = await getCachedOrgUser(orgId, user.userId);
|
||||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
|
||||||
`/org/${orgId}/user/${user.userId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrgUser();
|
|
||||||
orgUser = res.data.data;
|
orgUser = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
@@ -46,13 +34,7 @@ export default async function BillingSettingsPage({
|
|||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
try {
|
try {
|
||||||
const getOrg = cache(async () =>
|
const res = await getCachedOrg(orgId);
|
||||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
|
||||||
`/org/${orgId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrg();
|
|
||||||
org = res.data.data;
|
org = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { GetIdpResponse as GetOrgIdpResponse } from "@server/routers/idp";
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
redirect(`/${params.orgId}/settings/idp`);
|
redirect(`/${params.orgId}/settings/idp`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: HorizontalTabs = [
|
const navItems: TabItem[] = [
|
||||||
{
|
{
|
||||||
title: t("general"),
|
title: t("general"),
|
||||||
href: `/${params.orgId}/settings/idp/${params.idpId}/general`
|
href: `/${params.orgId}/settings/idp/${params.idpId}/general`
|
||||||
|
|||||||
68
src/app/[orgId]/settings/general/auth-page/page.tsx
Normal file
68
src/app/[orgId]/settings/general/auth-page/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import AuthPageBrandingForm from "@app/components/AuthPageBrandingForm";
|
||||||
|
import AuthPageSettings from "@app/components/private/AuthPageSettings";
|
||||||
|
import { SettingsContainer } from "@app/components/Settings";
|
||||||
|
import { internal, priv } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
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 {
|
||||||
|
if (build === "saas") {
|
||||||
|
const res = await internal.get<AxiosResponse<GetLoginPageResponse>>(
|
||||||
|
`/org/${orgId}/login-page`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
if (res.status === 200) {
|
||||||
|
loginPage = res.data.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
let loginPageBranding: GetLoginPageBrandingResponse | null = null;
|
||||||
|
try {
|
||||||
|
const res = await internal.get<
|
||||||
|
AxiosResponse<GetLoginPageBrandingResponse>
|
||||||
|
>(`/org/${orgId}/login-page-branding`, await authCookieHeader());
|
||||||
|
if (res.status === 200) {
|
||||||
|
loginPageBranding = res.data.data;
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
{build === "saas" && <AuthPageSettings loginPage={loginPage} />}
|
||||||
|
<AuthPageBrandingForm orgId={orgId} branding={loginPageBranding} />
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
import { internal } from "@app/lib/api";
|
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
import { HorizontalTabs, type TabItem } from "@app/components/HorizontalTabs";
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import OrgUserProvider from "@app/providers/OrgUserProvider";
|
import OrgUserProvider from "@app/providers/OrgUserProvider";
|
||||||
import { GetOrgResponse } from "@server/routers/org";
|
|
||||||
import { GetOrgUserResponse } from "@server/routers/user";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { cache } from "react";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
|
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
type GeneralSettingsProps = {
|
type GeneralSettingsProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -23,8 +21,7 @@ export default async function GeneralSettingsPage({
|
|||||||
}: GeneralSettingsProps) {
|
}: GeneralSettingsProps) {
|
||||||
const { orgId } = await params;
|
const { orgId } = await params;
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession();
|
||||||
const user = await getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
redirect(`/`);
|
redirect(`/`);
|
||||||
@@ -32,13 +29,7 @@ export default async function GeneralSettingsPage({
|
|||||||
|
|
||||||
let orgUser = null;
|
let orgUser = null;
|
||||||
try {
|
try {
|
||||||
const getOrgUser = cache(async () =>
|
const res = await getCachedOrgUser(orgId, user.userId);
|
||||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
|
||||||
`/org/${orgId}/user/${user.userId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrgUser();
|
|
||||||
orgUser = res.data.data;
|
orgUser = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
@@ -46,13 +37,7 @@ export default async function GeneralSettingsPage({
|
|||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
try {
|
try {
|
||||||
const getOrg = cache(async () =>
|
const res = await getCachedOrg(orgId);
|
||||||
internal.get<AxiosResponse<GetOrgResponse>>(
|
|
||||||
`/org/${orgId}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const res = await getOrg();
|
|
||||||
org = res.data.data;
|
org = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${orgId}`);
|
redirect(`/${orgId}`);
|
||||||
@@ -60,12 +45,19 @@ export default async function GeneralSettingsPage({
|
|||||||
|
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
const navItems = [
|
const navItems: TabItem[] = [
|
||||||
{
|
{
|
||||||
title: t("general"),
|
title: t("general"),
|
||||||
href: `/{orgId}/settings/general`
|
href: `/{orgId}/settings/general`,
|
||||||
|
exact: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
if (build !== "oss") {
|
||||||
|
navItems.push({
|
||||||
|
title: t("authPage"),
|
||||||
|
href: `/{orgId}/settings/general/auth-page`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -43,16 +43,16 @@ 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";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
|
||||||
// Session length options in hours
|
// Session length options in hours
|
||||||
const SESSION_LENGTH_OPTIONS = [
|
const SESSION_LENGTH_OPTIONS = [
|
||||||
@@ -112,29 +112,18 @@ const LOG_RETENTION_OPTIONS = [
|
|||||||
|
|
||||||
export default function GeneralPage() {
|
export default function GeneralPage() {
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const { orgUser } = userOrgUserContext();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
const { isPaidUser, hasSaasSubscription } = usePaidStatus();
|
||||||
const subscription = useSubscriptionStatusContext();
|
|
||||||
|
|
||||||
// Check if security features are disabled due to licensing/subscription
|
|
||||||
const isSecurityFeatureDisabled = () => {
|
|
||||||
const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked();
|
|
||||||
const isSaasNotSubscribed =
|
|
||||||
build === "saas" && !subscription?.isSubscribed();
|
|
||||||
return isEnterpriseNotLicensed || isSaasNotSubscribed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||||
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),
|
||||||
@@ -257,14 +246,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")
|
||||||
@@ -409,9 +390,7 @@ export default function GeneralPage() {
|
|||||||
{LOG_RETENTION_OPTIONS.filter(
|
{LOG_RETENTION_OPTIONS.filter(
|
||||||
(option) => {
|
(option) => {
|
||||||
if (
|
if (
|
||||||
build ==
|
hasSaasSubscription &&
|
||||||
"saas" &&
|
|
||||||
!subscription?.subscribed &&
|
|
||||||
option.value >
|
option.value >
|
||||||
30
|
30
|
||||||
) {
|
) {
|
||||||
@@ -439,19 +418,15 @@ export default function GeneralPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{build != "oss" && (
|
{build !== "oss" && (
|
||||||
<>
|
<>
|
||||||
<SecurityFeaturesAlert />
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="settingsLogRetentionDaysAccess"
|
name="settingsLogRetentionDaysAccess"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isDisabled =
|
const isDisabled = !isPaidUser;
|
||||||
(build == "saas" &&
|
|
||||||
!subscription?.subscribed) ||
|
|
||||||
(build == "enterprise" &&
|
|
||||||
!isUnlocked());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
@@ -517,11 +492,7 @@ export default function GeneralPage() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="settingsLogRetentionDaysAction"
|
name="settingsLogRetentionDaysAction"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isDisabled =
|
const isDisabled = !isPaidUser;
|
||||||
(build == "saas" &&
|
|
||||||
!subscription?.subscribed) ||
|
|
||||||
(build == "enterprise" &&
|
|
||||||
!isUnlocked());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
@@ -601,13 +572,12 @@ export default function GeneralPage() {
|
|||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<SecurityFeaturesAlert />
|
<PaidFeaturesAlert />
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="requireTwoFactor"
|
name="requireTwoFactor"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isDisabled =
|
const isDisabled = !isPaidUser;
|
||||||
isSecurityFeatureDisabled();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem className="col-span-2">
|
<FormItem className="col-span-2">
|
||||||
@@ -654,8 +624,7 @@ export default function GeneralPage() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="maxSessionLengthHours"
|
name="maxSessionLengthHours"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isDisabled =
|
const isDisabled = !isPaidUser;
|
||||||
isSecurityFeatureDisabled();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem className="col-span-2">
|
<FormItem className="col-span-2">
|
||||||
@@ -741,8 +710,7 @@ export default function GeneralPage() {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="passwordExpiryDays"
|
name="passwordExpiryDays"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const isDisabled =
|
const isDisabled = !isPaidUser;
|
||||||
isSecurityFeatureDisabled();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem className="col-span-2">
|
<FormItem className="col-span-2">
|
||||||
@@ -833,8 +801,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
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { GetIdpResponse } from "@server/routers/idp";
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
redirect("/admin/idp");
|
redirect("/admin/idp");
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems: HorizontalTabs = [
|
const navItems: TabItem[] = [
|
||||||
{
|
{
|
||||||
title: t("general"),
|
title: t("general"),
|
||||||
href: `/admin/idp/${params.idpId}/general`
|
href: `/admin/idp/${params.idpId}/general`
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { LoginFormIDP } from "@app/components/LoginForm";
|
|||||||
import { ListOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
import { ListOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { LoadLoginPageResponse } from "@server/routers/loginPage/types";
|
import {
|
||||||
|
LoadLoginPageBrandingResponse,
|
||||||
|
LoadLoginPageResponse
|
||||||
|
} from "@server/routers/loginPage/types";
|
||||||
import IdpLoginButtons from "@app/components/private/IdpLoginButtons";
|
import IdpLoginButtons from "@app/components/private/IdpLoginButtons";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -23,8 +26,8 @@ import Link from "next/link";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { GetSessionTransferTokenRenponse } from "@server/routers/auth/types";
|
import { GetSessionTransferTokenRenponse } from "@server/routers/auth/types";
|
||||||
import ValidateSessionTransferToken from "@app/components/private/ValidateSessionTransferToken";
|
import ValidateSessionTransferToken from "@app/components/private/ValidateSessionTransferToken";
|
||||||
import { GetOrgTierResponse } from "@server/routers/billing/types";
|
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -32,7 +35,6 @@ export default async function OrgAuthPage(props: {
|
|||||||
params: Promise<{}>;
|
params: Promise<{}>;
|
||||||
searchParams: Promise<{ token?: string }>;
|
searchParams: Promise<{ token?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const params = await props.params;
|
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
|
|
||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
@@ -73,22 +75,7 @@ export default async function OrgAuthPage(props: {
|
|||||||
redirect(env.app.dashboardUrl);
|
redirect(env.app.dashboardUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
let subscriptionStatus: GetOrgTierResponse | null = null;
|
const subscribed = await isOrgSubscribed(loginPage.orgId);
|
||||||
if (build === "saas") {
|
|
||||||
try {
|
|
||||||
const getSubscription = cache(() =>
|
|
||||||
priv.get<AxiosResponse<GetOrgTierResponse>>(
|
|
||||||
`/org/${loginPage!.orgId}/billing/tier`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const subRes = await getSubscription();
|
|
||||||
subscriptionStatus = subRes.data.data;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
const subscribed =
|
|
||||||
build === "enterprise"
|
|
||||||
? true
|
|
||||||
: subscriptionStatus?.tier === TierId.STANDARD;
|
|
||||||
|
|
||||||
if (build === "saas" && !subscribed) {
|
if (build === "saas" && !subscribed) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -126,12 +113,10 @@ export default async function OrgAuthPage(props: {
|
|||||||
|
|
||||||
let loginIdps: LoginFormIDP[] = [];
|
let loginIdps: LoginFormIDP[] = [];
|
||||||
if (build === "saas") {
|
if (build === "saas") {
|
||||||
const idpsRes = await cache(
|
const idpsRes = await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
|
||||||
async () =>
|
`/org/${loginPage.orgId}/idp`
|
||||||
await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
|
);
|
||||||
`/org/${loginPage!.orgId}/idp`
|
|
||||||
)
|
|
||||||
)();
|
|
||||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||||
idpId: idp.idpId,
|
idpId: idp.idpId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
@@ -139,6 +124,18 @@ export default async function OrgAuthPage(props: {
|
|||||||
})) as LoginFormIDP[];
|
})) as LoginFormIDP[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let branding: LoadLoginPageBrandingResponse | null = null;
|
||||||
|
if (build === "saas") {
|
||||||
|
try {
|
||||||
|
const res = await priv.get<
|
||||||
|
AxiosResponse<LoadLoginPageBrandingResponse>
|
||||||
|
>(`/login-page-branding?orgId=${loginPage.orgId}`);
|
||||||
|
if (res.status === 200) {
|
||||||
|
branding = res.data.data;
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-center mb-2">
|
<div className="text-center mb-2">
|
||||||
@@ -156,11 +153,30 @@ export default async function OrgAuthPage(props: {
|
|||||||
</div>
|
</div>
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("orgAuthSignInTitle")}</CardTitle>
|
{branding?.logoUrl && (
|
||||||
|
<div className="flex flex-row items-center justify-center mb-3">
|
||||||
|
<img
|
||||||
|
src={branding.logoUrl}
|
||||||
|
height={branding.logoHeight}
|
||||||
|
width={branding.logoWidth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CardTitle>
|
||||||
|
{branding?.orgTitle
|
||||||
|
? replacePlaceholder(branding.orgTitle, {
|
||||||
|
orgName: branding.orgName
|
||||||
|
})
|
||||||
|
: t("orgAuthSignInTitle")}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{loginIdps.length > 0
|
{branding?.orgSubtitle
|
||||||
? t("orgAuthChooseIdpDescription")
|
? replacePlaceholder(branding.orgSubtitle, {
|
||||||
: ""}
|
orgName: branding.orgName
|
||||||
|
})
|
||||||
|
: loginIdps.length > 0
|
||||||
|
? t("orgAuthChooseIdpDescription")
|
||||||
|
: ""}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ import { ListOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
|||||||
import AutoLoginHandler from "@app/components/AutoLoginHandler";
|
import AutoLoginHandler from "@app/components/AutoLoginHandler";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { GetLoginPageResponse } from "@server/routers/loginPage/types";
|
import type {
|
||||||
|
LoadLoginPageBrandingResponse,
|
||||||
|
LoadLoginPageResponse
|
||||||
|
} from "@server/routers/loginPage/types";
|
||||||
import { GetOrgTierResponse } from "@server/routers/billing/types";
|
import { GetOrgTierResponse } from "@server/routers/billing/types";
|
||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
||||||
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
||||||
|
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -52,8 +56,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const getUser = cache(verifySession);
|
const user = await verifySession({ skipCheckVerifyEmail: true });
|
||||||
const user = await getUser({ skipCheckVerifyEmail: true });
|
|
||||||
|
|
||||||
if (!authInfo) {
|
if (!authInfo) {
|
||||||
return (
|
return (
|
||||||
@@ -63,22 +66,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let subscriptionStatus: GetOrgTierResponse | null = null;
|
const subscribed = await isOrgSubscribed(authInfo.orgId);
|
||||||
if (build == "saas") {
|
|
||||||
try {
|
|
||||||
const getSubscription = cache(() =>
|
|
||||||
priv.get<AxiosResponse<GetOrgTierResponse>>(
|
|
||||||
`/org/${authInfo.orgId}/billing/tier`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const subRes = await getSubscription();
|
|
||||||
subscriptionStatus = subRes.data.data;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
const subscribed =
|
|
||||||
build === "enterprise"
|
|
||||||
? true
|
|
||||||
: subscriptionStatus?.tier === TierId.STANDARD;
|
|
||||||
|
|
||||||
const allHeaders = await headers();
|
const allHeaders = await headers();
|
||||||
const host = allHeaders.get("host");
|
const host = allHeaders.get("host");
|
||||||
@@ -89,9 +77,9 @@ export default async function ResourceAuthPage(props: {
|
|||||||
redirect(env.app.dashboardUrl);
|
redirect(env.app.dashboardUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
let loginPage: GetLoginPageResponse | undefined;
|
let loginPage: LoadLoginPageResponse | undefined;
|
||||||
try {
|
try {
|
||||||
const res = await priv.get<AxiosResponse<GetLoginPageResponse>>(
|
const res = await priv.get<AxiosResponse<LoadLoginPageResponse>>(
|
||||||
`/login-page?resourceId=${authInfo.resourceId}&fullDomain=${host}`
|
`/login-page?resourceId=${authInfo.resourceId}&fullDomain=${host}`
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -106,6 +94,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let redirectUrl = authInfo.url;
|
let redirectUrl = authInfo.url;
|
||||||
|
|
||||||
if (searchParams.redirect) {
|
if (searchParams.redirect) {
|
||||||
try {
|
try {
|
||||||
const serverResourceHost = new URL(authInfo.url).host;
|
const serverResourceHost = new URL(authInfo.url).host;
|
||||||
@@ -230,9 +219,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
})) as LoginFormIDP[];
|
})) as LoginFormIDP[];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const idpsRes = await cache(
|
const idpsRes = await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
|
||||||
async () => await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
|
|
||||||
)();
|
|
||||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||||
idpId: idp.idpId,
|
idpId: idp.idpId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
@@ -253,12 +240,24 @@ export default async function ResourceAuthPage(props: {
|
|||||||
resourceId={authInfo.resourceId}
|
resourceId={authInfo.resourceId}
|
||||||
skipToIdpId={authInfo.skipToIdpId}
|
skipToIdpId={authInfo.skipToIdpId}
|
||||||
redirectUrl={redirectUrl}
|
redirectUrl={redirectUrl}
|
||||||
orgId={build == "saas" ? authInfo.orgId : undefined}
|
orgId={build === "saas" ? authInfo.orgId : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let branding: LoadLoginPageBrandingResponse | null = null;
|
||||||
|
try {
|
||||||
|
if (subscribed) {
|
||||||
|
const res = await priv.get<
|
||||||
|
AxiosResponse<LoadLoginPageBrandingResponse>
|
||||||
|
>(`/login-page-branding?orgId=${authInfo.orgId}`);
|
||||||
|
if (res.status === 200) {
|
||||||
|
branding = res.data.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{userIsUnauthorized && isSSOOnly ? (
|
{userIsUnauthorized && isSSOOnly ? (
|
||||||
@@ -281,6 +280,19 @@ export default async function ResourceAuthPage(props: {
|
|||||||
redirect={redirectUrl}
|
redirect={redirectUrl}
|
||||||
idps={loginIdps}
|
idps={loginIdps}
|
||||||
orgId={build === "saas" ? authInfo.orgId : undefined}
|
orgId={build === "saas" ? authInfo.orgId : undefined}
|
||||||
|
branding={
|
||||||
|
!branding || build === "oss"
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
logoHeight: branding.logoHeight,
|
||||||
|
logoUrl: branding.logoUrl,
|
||||||
|
logoWidth: branding.logoWidth,
|
||||||
|
primaryColor: branding.primaryColor,
|
||||||
|
resourceTitle: branding.resourceTitle,
|
||||||
|
resourceSubtitle:
|
||||||
|
branding.resourceSubtitle
|
||||||
|
}
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
499
src/components/AuthPageBrandingForm.tsx
Normal file
499
src/components/AuthPageBrandingForm.tsx
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useActionState, useState } 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 type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { Trash2, XIcon } from "lucide-react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Separator } from "./ui/separator";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "./Credenza";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||||
|
|
||||||
|
export type AuthPageCustomizationProps = {
|
||||||
|
orgId: string;
|
||||||
|
branding: GetLoginPageBrandingResponse | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthPageFormSchema = z.object({
|
||||||
|
logoUrl: z.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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
error: "Invalid logo URL, must be a valid image URL"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
|
orgTitle: z.string().optional(),
|
||||||
|
orgSubtitle: z.string().optional(),
|
||||||
|
resourceTitle: z.string(),
|
||||||
|
resourceSubtitle: z.string().optional(),
|
||||||
|
primaryColor: z
|
||||||
|
.string()
|
||||||
|
.regex(/^#([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AuthPageBrandingForm({
|
||||||
|
orgId,
|
||||||
|
branding
|
||||||
|
}: AuthPageCustomizationProps) {
|
||||||
|
const env = useEnvContext();
|
||||||
|
const api = createApiClient(env);
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [, updateFormAction, isUpdatingBranding] = useActionState(
|
||||||
|
updateBranding,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [, deleteFormAction, isDeletingBranding] = useActionState(
|
||||||
|
deleteBranding,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
resolver: zodResolver(AuthPageFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
logoUrl: branding?.logoUrl ?? "",
|
||||||
|
logoWidth: branding?.logoWidth ?? 100,
|
||||||
|
logoHeight: branding?.logoHeight ?? 100,
|
||||||
|
orgTitle: branding?.orgTitle ?? `Log in to {{orgName}}`,
|
||||||
|
orgSubtitle: branding?.orgSubtitle ?? `Log in to {{orgName}}`,
|
||||||
|
resourceTitle:
|
||||||
|
branding?.resourceTitle ??
|
||||||
|
`Authenticate to access {{resourceName}}`,
|
||||||
|
resourceSubtitle:
|
||||||
|
branding?.resourceSubtitle ??
|
||||||
|
`Choose your preferred authentication method for {{resourceName}}`,
|
||||||
|
primaryColor: branding?.primaryColor ?? `#f36117` // default pangolin primary color
|
||||||
|
},
|
||||||
|
disabled: !isPaidUser
|
||||||
|
});
|
||||||
|
|
||||||
|
async function updateBranding() {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
const brandingData = form.getValues();
|
||||||
|
|
||||||
|
if (!isValid || !isPaidUser) return;
|
||||||
|
try {
|
||||||
|
const updateRes = await api.put(
|
||||||
|
`/org/${orgId}/login-page-branding`,
|
||||||
|
{
|
||||||
|
...brandingData
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateRes.status === 200 || updateRes.status === 201) {
|
||||||
|
router.refresh();
|
||||||
|
toast({
|
||||||
|
variant: "default",
|
||||||
|
title: t("success"),
|
||||||
|
description: t("authPageBrandingUpdated")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("authPageErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t("authPageErrorUpdateMessage")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBranding() {
|
||||||
|
if (!isPaidUser) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updateRes = await api.delete(
|
||||||
|
`/org/${orgId}/login-page-branding`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateRes.status === 200) {
|
||||||
|
router.refresh();
|
||||||
|
form.reset();
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
variant: "default",
|
||||||
|
title: t("success"),
|
||||||
|
description: t("authPageBrandingRemoved")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("authPageErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
t("authPageErrorUpdateMessage")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("authPageBranding")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{t("authPageBrandingDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<SettingsSectionForm>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
action={updateFormAction}
|
||||||
|
id="auth-page-branding-form"
|
||||||
|
className="flex flex-col gap-8 items-stretch"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="primaryColor"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="">
|
||||||
|
<FormLabel>
|
||||||
|
{t("brandingPrimaryColor")}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
className="size-8 rounded-sm"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
field.value
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
{...field}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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="relative top-8">
|
||||||
|
<XIcon className="text-muted-foreground size-4" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="logoHeight"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="grow">
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"brandingLogoHeight"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{build === "saas" && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="orgTitle"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-3">
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"brandingOrgTitle"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"brandingOrgDescription",
|
||||||
|
{
|
||||||
|
orgName:
|
||||||
|
"{{orgName}}"
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="orgSubtitle"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-3">
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"brandingOrgSubtitle"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"brandingOrgDescription",
|
||||||
|
{
|
||||||
|
orgName:
|
||||||
|
"{{orgName}}"
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="resourceTitle"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-3">
|
||||||
|
<FormLabel>
|
||||||
|
{t("brandingResourceTitle")}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"brandingResourceDescription",
|
||||||
|
{
|
||||||
|
resourceName:
|
||||||
|
"{{resourceName}}"
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="resourceSubtitle"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-3">
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"brandingResourceSubtitle"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"brandingResourceDescription",
|
||||||
|
{
|
||||||
|
resourceName:
|
||||||
|
"{{resourceName}}"
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</SettingsSectionForm>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<Credenza
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
onOpenChange={setIsDeleteModalOpen}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("authPageBrandingRemoveTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody className="mb-0 space-y-0 flex flex-col gap-1">
|
||||||
|
<p>{t("authPageBrandingQuestionRemove")}</p>
|
||||||
|
<div className="font-bold text-destructive">
|
||||||
|
{t("cannotbeUndone")}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
action={deleteFormAction}
|
||||||
|
id="confirm-delete-branding-form"
|
||||||
|
className="sr-only"
|
||||||
|
></form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
variant={"destructive"}
|
||||||
|
type="submit"
|
||||||
|
form="confirm-delete-branding-form"
|
||||||
|
loading={isDeletingBranding}
|
||||||
|
disabled={isDeletingBranding || !isPaidUser}
|
||||||
|
>
|
||||||
|
{t("authPageBrandingDeleteConfirm")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-6 items-center">
|
||||||
|
{branding && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
type="button"
|
||||||
|
loading={isUpdatingBranding || isDeletingBranding}
|
||||||
|
disabled={
|
||||||
|
isUpdatingBranding ||
|
||||||
|
isDeletingBranding ||
|
||||||
|
!isPaidUser
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{t("removeAuthPageBranding")}
|
||||||
|
<Trash2 size="14" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="auth-page-branding-form"
|
||||||
|
loading={isUpdatingBranding || isDeletingBranding}
|
||||||
|
disabled={
|
||||||
|
isUpdatingBranding ||
|
||||||
|
isDeletingBranding ||
|
||||||
|
!isPaidUser
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("saveAuthPageBranding")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import Image from "next/image";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type BrandingLogoProps = {
|
type BrandingLogoProps = {
|
||||||
|
logoPath?: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
@@ -41,13 +42,16 @@ export default function BrandingLogo(props: BrandingLogoProps) {
|
|||||||
return "/logo/word_mark_white.png";
|
return "/logo/word_mark_white.png";
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = getPath();
|
setPath(props.logoPath ?? getPath());
|
||||||
setPath(path);
|
}, [theme, env, props.logoPath]);
|
||||||
}, [theme, env]);
|
|
||||||
|
// we use `img` tag here because the `logoPath` could be any URL
|
||||||
|
// and next.js `Image` component only accepts a restricted number of domains
|
||||||
|
const Component = props.logoPath ? "img" : Image;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
path && (
|
path && (
|
||||||
<Image
|
<Component
|
||||||
src={path}
|
src={path}
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={props.width}
|
width={props.width}
|
||||||
|
|||||||
@@ -6,43 +6,22 @@ import {
|
|||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "@app/components/ui/select";
|
|
||||||
import { useToast } from "@app/hooks/useToast";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import React, { useActionState } from "react";
|
||||||
InviteUserBody,
|
|
||||||
InviteUserResponse,
|
|
||||||
ListUsersResponse
|
|
||||||
} from "@server/routers/user";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
|
||||||
import {
|
import {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaBody,
|
CredenzaBody,
|
||||||
CredenzaClose,
|
CredenzaClose,
|
||||||
CredenzaContent,
|
CredenzaContent,
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
CredenzaFooter,
|
||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
|
||||||
import { Description } from "@radix-ui/react-toast";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import CopyToClipboard from "./CopyToClipboard";
|
import CopyToClipboard from "./CopyToClipboard";
|
||||||
|
|
||||||
@@ -57,7 +36,7 @@ type InviteUserFormProps = {
|
|||||||
warningText?: string;
|
warningText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function InviteUserForm({
|
export default function ConfirmDeleteDialog({
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
string,
|
string,
|
||||||
@@ -67,9 +46,7 @@ export default function InviteUserForm({
|
|||||||
dialog,
|
dialog,
|
||||||
warningText
|
warningText
|
||||||
}: InviteUserFormProps) {
|
}: InviteUserFormProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [, formAction, loading] = useActionState(onSubmit, null);
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
@@ -86,21 +63,14 @@ export default function InviteUserForm({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function reset() {
|
async function onSubmit() {
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
await onConfirm();
|
await onConfirm();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
reset();
|
form.reset();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle error if needed
|
// Handle error if needed
|
||||||
console.error("Confirmation failed:", error);
|
console.error("Confirmation failed:", error);
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +80,7 @@ export default function InviteUserForm({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(val) => {
|
onOpenChange={(val) => {
|
||||||
setOpen(val);
|
setOpen(val);
|
||||||
reset();
|
form.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
@@ -136,7 +106,7 @@ export default function InviteUserForm({
|
|||||||
</div>
|
</div>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
action={formAction}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="confirm-delete-form"
|
id="confirm-delete-form"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -732,7 +732,11 @@ export default function DomainPicker({
|
|||||||
handleProvidedDomainSelect(option);
|
handleProvidedDomainSelect(option);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`grid gap-2 grid-cols-1 sm:grid-cols-${cols}`}
|
style={{
|
||||||
|
// @ts-expect-error CSS variable
|
||||||
|
"--cols": `repeat(${cols}, minmax(0, 1fr))`
|
||||||
|
}}
|
||||||
|
className="grid gap-2 grid-cols-1 sm:grid-cols-(--cols)"
|
||||||
>
|
>
|
||||||
{displayedProvidedOptions.map((option) => {
|
{displayedProvidedOptions.map((option) => {
|
||||||
const isSelected =
|
const isSelected =
|
||||||
|
|||||||
@@ -8,16 +8,17 @@ import { Badge } from "@app/components/ui/badge";
|
|||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
export type HorizontalTabs = Array<{
|
export type TabItem = {
|
||||||
title: string;
|
title: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
showProfessional?: boolean;
|
showProfessional?: boolean;
|
||||||
}>;
|
exact?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface HorizontalTabsProps {
|
interface HorizontalTabsProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
items: HorizontalTabs;
|
items: TabItem[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +50,11 @@ export function HorizontalTabs({
|
|||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const hydratedHref = hydrateHref(item.href);
|
const hydratedHref = hydrateHref(item.href);
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname.startsWith(hydratedHref) &&
|
(item.exact
|
||||||
|
? pathname === hydratedHref
|
||||||
|
: pathname.startsWith(hydratedHref)) &&
|
||||||
!pathname.includes("create");
|
!pathname.includes("create");
|
||||||
|
|
||||||
const isProfessional =
|
const isProfessional =
|
||||||
item.showProfessional && !isUnlocked();
|
item.showProfessional && !isUnlocked();
|
||||||
const isDisabled =
|
const isDisabled =
|
||||||
|
|||||||
@@ -91,15 +91,12 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const percentBlocked = stats
|
const percentBlocked =
|
||||||
? new Intl.NumberFormat(navigator.language, {
|
stats && stats.totalRequests > 0
|
||||||
maximumFractionDigits: 2
|
? new Intl.NumberFormat(navigator.language, {
|
||||||
}).format(
|
maximumFractionDigits: 2
|
||||||
stats.totalRequests
|
}).format((stats.totalBlocked / stats.totalRequests) * 100)
|
||||||
? (stats.totalBlocked / stats.totalRequests) * 100
|
: null;
|
||||||
: 0
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const totalRequests = stats
|
const totalRequests = stats
|
||||||
? new Intl.NumberFormat(navigator.language, {
|
? new Intl.NumberFormat(navigator.language, {
|
||||||
maximumFractionDigits: 0
|
maximumFractionDigits: 0
|
||||||
|
|||||||
@@ -2,17 +2,14 @@
|
|||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
|
||||||
|
|
||||||
export function SecurityFeaturesAlert() {
|
export function PaidFeaturesAlert() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { isUnlocked } = useLicenseStatusContext();
|
const { hasSaasSubscription, hasEnterpriseLicense } = usePaidStatus();
|
||||||
const subscriptionStatus = useSubscriptionStatusContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{build === "saas" && !subscriptionStatus?.isSubscribed() ? (
|
{build === "saas" && !hasSaasSubscription ? (
|
||||||
<Alert variant="info" className="mb-6">
|
<Alert variant="info" className="mb-6">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t("subscriptionRequiredToUse")}
|
{t("subscriptionRequiredToUse")}
|
||||||
@@ -20,7 +17,7 @@ export function SecurityFeaturesAlert() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{build === "enterprise" && !isUnlocked() ? (
|
{build === "enterprise" && !hasEnterpriseLicense ? (
|
||||||
<Alert variant="info" className="mb-6">
|
<Alert variant="info" className="mb-6">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t("licenseRequiredToUse")}
|
{t("licenseRequiredToUse")}
|
||||||
@@ -39,16 +39,15 @@ import {
|
|||||||
resourceWhitelistProxy,
|
resourceWhitelistProxy,
|
||||||
resourceAccessProxy
|
resourceAccessProxy
|
||||||
} from "@app/actions/server";
|
} from "@app/actions/server";
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
|
||||||
import BrandingLogo from "@app/components/BrandingLogo";
|
import BrandingLogo from "@app/components/BrandingLogo";
|
||||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
import { replacePlaceholder } from "@app/lib/replacePlaceholder";
|
||||||
|
|
||||||
const pinSchema = z.object({
|
const pinSchema = z.object({
|
||||||
pin: z
|
pin: z
|
||||||
@@ -88,6 +87,14 @@ type ResourceAuthPortalProps = {
|
|||||||
redirect: string;
|
redirect: string;
|
||||||
idps?: LoginFormIDP[];
|
idps?: LoginFormIDP[];
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
|
branding?: {
|
||||||
|
logoUrl: string;
|
||||||
|
logoWidth: number;
|
||||||
|
logoHeight: number;
|
||||||
|
primaryColor: string | null;
|
||||||
|
resourceTitle: string;
|
||||||
|
resourceSubtitle: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
@@ -104,7 +111,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
return colLength;
|
return colLength;
|
||||||
};
|
};
|
||||||
|
|
||||||
const [numMethods, setNumMethods] = useState(getNumMethods());
|
const [numMethods] = useState(() => getNumMethods());
|
||||||
|
|
||||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
const [pincodeError, setPincodeError] = useState<string | null>(null);
|
const [pincodeError, setPincodeError] = useState<string | null>(null);
|
||||||
@@ -309,13 +316,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTitle() {
|
function getTitle(resourceName: string) {
|
||||||
if (
|
if (
|
||||||
isUnlocked() &&
|
|
||||||
build !== "oss" &&
|
build !== "oss" &&
|
||||||
env.branding.resourceAuthPage?.titleText
|
isUnlocked() &&
|
||||||
|
(!!env.branding.resourceAuthPage?.titleText ||
|
||||||
|
!!props.branding?.resourceTitle)
|
||||||
) {
|
) {
|
||||||
return env.branding.resourceAuthPage.titleText;
|
if (props.branding?.resourceTitle) {
|
||||||
|
return replacePlaceholder(props.branding?.resourceTitle, {
|
||||||
|
resourceName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return env.branding.resourceAuthPage?.titleText;
|
||||||
}
|
}
|
||||||
return t("authenticationRequired");
|
return t("authenticationRequired");
|
||||||
}
|
}
|
||||||
@@ -324,10 +337,16 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
if (
|
if (
|
||||||
isUnlocked() &&
|
isUnlocked() &&
|
||||||
build !== "oss" &&
|
build !== "oss" &&
|
||||||
env.branding.resourceAuthPage?.subtitleText
|
(env.branding.resourceAuthPage?.subtitleText ||
|
||||||
|
props.branding?.resourceSubtitle)
|
||||||
) {
|
) {
|
||||||
return env.branding.resourceAuthPage.subtitleText
|
if (props.branding?.resourceSubtitle) {
|
||||||
.split("{{resourceName}}")
|
return replacePlaceholder(props.branding?.resourceSubtitle, {
|
||||||
|
resourceName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return env.branding.resourceAuthPage?.subtitleText
|
||||||
|
?.split("{{resourceName}}")
|
||||||
.join(resourceName);
|
.join(resourceName);
|
||||||
}
|
}
|
||||||
return numMethods > 1
|
return numMethods > 1
|
||||||
@@ -336,14 +355,23 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logoWidth = isUnlocked()
|
const logoWidth = isUnlocked()
|
||||||
? env.branding.logo?.authPage?.width || 100
|
? (props.branding?.logoWidth ??
|
||||||
|
env.branding.logo?.authPage?.width ??
|
||||||
|
100)
|
||||||
: 100;
|
: 100;
|
||||||
const logoHeight = isUnlocked()
|
const logoHeight = isUnlocked()
|
||||||
? env.branding.logo?.authPage?.height || 100
|
? (props.branding?.logoHeight ??
|
||||||
|
env.branding.logo?.authPage?.height ??
|
||||||
|
100)
|
||||||
: 100;
|
: 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div
|
||||||
|
style={{
|
||||||
|
// @ts-expect-error CSS variable
|
||||||
|
"--primary": isUnlocked() ? props.branding?.primaryColor : null
|
||||||
|
}}
|
||||||
|
>
|
||||||
{!accessDenied ? (
|
{!accessDenied ? (
|
||||||
<div>
|
<div>
|
||||||
{isUnlocked() && build === "enterprise" ? (
|
{isUnlocked() && build === "enterprise" ? (
|
||||||
@@ -381,15 +409,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
{isUnlocked() &&
|
{isUnlocked() &&
|
||||||
build !== "oss" &&
|
build !== "oss" &&
|
||||||
env.branding?.resourceAuthPage?.showLogo && (
|
(env.branding?.resourceAuthPage?.showLogo ||
|
||||||
|
props.branding) && (
|
||||||
<div className="flex flex-row items-center justify-center mb-3">
|
<div className="flex flex-row items-center justify-center mb-3">
|
||||||
<BrandingLogo
|
<BrandingLogo
|
||||||
height={logoHeight}
|
height={logoHeight}
|
||||||
width={logoWidth}
|
width={logoWidth}
|
||||||
|
logoPath={props.branding?.logoUrl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardTitle>{getTitle()}</CardTitle>
|
<CardTitle>
|
||||||
|
{getTitle(props.resource.name)}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{getSubtitle(props.resource.name)}
|
{getSubtitle(props.resource.name)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|||||||
@@ -3,16 +3,8 @@
|
|||||||
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, useActionState } from "react";
|
||||||
import {
|
import { Form } from "@/components/ui/form";
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -51,9 +43,8 @@ import DomainPicker from "@app/components/DomainPicker";
|
|||||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
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 { TierId } from "@server/lib/billing/tiers";
|
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
|
||||||
// Auth page form schema
|
// Auth page form schema
|
||||||
const AuthPageFormSchema = z.object({
|
const AuthPageFormSchema = z.object({
|
||||||
@@ -61,11 +52,10 @@ const AuthPageFormSchema = z.object({
|
|||||||
authPageSubdomain: z.string().optional()
|
authPageSubdomain: z.string().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
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,486 +63,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 { hasSaasSubscription } = usePaidStatus();
|
||||||
|
|
||||||
// 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
|
|
||||||
useImperativeHandle(
|
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
saveAuthSettings: async () => {
|
|
||||||
await form.handleSubmit(onSubmit)();
|
|
||||||
},
|
|
||||||
hasUnsavedChanges: () => hasUnsavedChanges
|
|
||||||
}),
|
|
||||||
[form, hasUnsavedChanges]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch login page and domains data
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchLoginPage = async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get<
|
|
||||||
AxiosResponse<GetLoginPageResponse>
|
|
||||||
>(`/org/${org?.org.orgId}/login-page`);
|
|
||||||
if (res.status === 200) {
|
|
||||||
setLoginPage(res.data.data);
|
|
||||||
setLoginPageExists(true);
|
|
||||||
// Update form with login page data
|
|
||||||
form.setValue(
|
|
||||||
"authPageDomainId",
|
|
||||||
res.data.data.domainId || ""
|
|
||||||
);
|
|
||||||
form.setValue(
|
|
||||||
"authPageSubdomain",
|
|
||||||
res.data.data.subdomain || ""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Login page doesn't exist yet, that's okay
|
|
||||||
setLoginPage(null);
|
|
||||||
setLoginPageExists(false);
|
|
||||||
} finally {
|
|
||||||
setLoadingLoginPage(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchDomains = async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get<
|
|
||||||
AxiosResponse<ListDomainsResponse>
|
|
||||||
>(`/org/${org?.org.orgId}/domains/`);
|
|
||||||
if (res.status === 200) {
|
|
||||||
const rawDomains = res.data.data.domains as DomainRow[];
|
|
||||||
const domains = rawDomains.map((domain) => ({
|
|
||||||
...domain,
|
|
||||||
baseDomain: toUnicode(domain.baseDomain)
|
|
||||||
}));
|
|
||||||
setBaseDomains(domains);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch domains:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (org?.org.orgId) {
|
|
||||||
fetchLoginPage();
|
|
||||||
fetchDomains();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle domain selection from modal
|
|
||||||
function handleDomainSelection(domain: {
|
|
||||||
domainId: string;
|
|
||||||
subdomain?: string;
|
|
||||||
fullDomain: string;
|
|
||||||
baseDomain: string;
|
|
||||||
}) {
|
|
||||||
form.setValue("authPageDomainId", domain.domainId);
|
|
||||||
form.setValue("authPageSubdomain", domain.subdomain || "");
|
|
||||||
setEditDomainOpen(false);
|
|
||||||
|
|
||||||
// Update loginPage state to show the selected domain immediately
|
|
||||||
const sanitizedSubdomain = domain.subdomain
|
|
||||||
? finalizeSubdomainSanitize(domain.subdomain)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
const sanitizedFullDomain = sanitizedSubdomain
|
|
||||||
? `${sanitizedSubdomain}.${domain.baseDomain}`
|
|
||||||
: domain.baseDomain;
|
|
||||||
|
|
||||||
// Only update loginPage state if a login page already exists
|
|
||||||
if (loginPageExists && loginPage) {
|
|
||||||
setLoginPage({
|
|
||||||
...loginPage,
|
|
||||||
domainId: domain.domainId,
|
|
||||||
subdomain: sanitizedSubdomain,
|
|
||||||
fullDomain: sanitizedFullDomain
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasUnsavedChanges(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear auth page domain
|
|
||||||
function clearAuthPageDomain() {
|
|
||||||
form.setValue("authPageDomainId", "");
|
|
||||||
form.setValue("authPageSubdomain", "");
|
|
||||||
setLoginPage(null);
|
|
||||||
setHasUnsavedChanges(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit(data: AuthPageFormValues) {
|
|
||||||
setLoadingSave(true);
|
|
||||||
|
|
||||||
|
// 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" || hasSaasSubscription) {
|
||||||
|
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?.();
|
||||||
|
toast({
|
||||||
|
variant: "default",
|
||||||
|
title: t("success"),
|
||||||
|
description: t("authPageDomainUpdated")
|
||||||
|
});
|
||||||
|
} 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}
|
|
||||||
defaultDomainId={
|
|
||||||
form.getValues("authPageDomainId") ??
|
|
||||||
loginPage?.domainId
|
|
||||||
}
|
|
||||||
defaultSubdomain={
|
|
||||||
form.getValues("authPageSubdomain") ??
|
|
||||||
loginPage?.subdomain
|
|
||||||
}
|
|
||||||
onDomainChange={(res) => {
|
|
||||||
const selected =
|
|
||||||
res === null
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
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>
|
||||||
|
{!hasSaasSubscription ? (
|
||||||
|
<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)
|
||||||
|
}
|
||||||
|
disabled={!hasSaasSubscription}
|
||||||
|
>
|
||||||
|
{form.watch("authPageDomainId")
|
||||||
|
? t("changeDomain")
|
||||||
|
: t("selectDomain")}
|
||||||
|
</Button>
|
||||||
|
{form.watch("authPageDomainId") && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
onClick={
|
||||||
|
clearAuthPageDomain
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
!hasSaasSubscription
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size="14" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!form.watch("authPageDomainId") && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"addDomainToEnableCustomAuthPages"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{env.flags.usePangolinDns &&
|
||||||
|
(build === "enterprise" ||
|
||||||
|
!hasSaasSubscription) &&
|
||||||
|
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 ||
|
||||||
|
!hasSaasSubscription
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("saveAuthPageDomain")}
|
||||||
|
</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 =
|
||||||
|
res === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
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 || !hasSaasSubscription}
|
||||||
|
>
|
||||||
|
{t("selectDomain")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
AuthPageSettings.displayName = "AuthPageSettings";
|
AuthPageSettings.displayName = "AuthPageSettings";
|
||||||
|
|
||||||
|
|||||||
21
src/hooks/usePaidStatus.ts
Normal file
21
src/hooks/usePaidStatus.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { build } from "@server/build";
|
||||||
|
import { useLicenseStatusContext } from "./useLicenseStatusContext";
|
||||||
|
import { useSubscriptionStatusContext } from "./useSubscriptionStatusContext";
|
||||||
|
|
||||||
|
export function usePaidStatus() {
|
||||||
|
const { isUnlocked } = useLicenseStatusContext();
|
||||||
|
const subscription = useSubscriptionStatusContext();
|
||||||
|
|
||||||
|
// Check if features are disabled due to licensing/subscription
|
||||||
|
const hasEnterpriseLicense = build === "enterprise" && isUnlocked();
|
||||||
|
const hasSaasSubscription =
|
||||||
|
build === "saas" &&
|
||||||
|
subscription?.isSubscribed() &&
|
||||||
|
subscription.isActive();
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasEnterpriseLicense,
|
||||||
|
hasSaasSubscription,
|
||||||
|
isPaidUser: hasEnterpriseLicense || hasSaasSubscription
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/lib/api/getCachedOrgUser.ts
Normal file
13
src/lib/api/getCachedOrgUser.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { GetOrgResponse } from "@server/routers/org";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { cache } from "react";
|
||||||
|
import { authCookieHeader } from "./cookies";
|
||||||
|
import { internal } from ".";
|
||||||
|
import type { GetOrgUserResponse } from "@server/routers/user";
|
||||||
|
|
||||||
|
export const getCachedOrgUser = cache(async (orgId: string, userId: string) =>
|
||||||
|
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
||||||
|
`/org/${orgId}/user/${userId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
)
|
||||||
|
);
|
||||||
8
src/lib/api/getCachedSubscription.ts
Normal file
8
src/lib/api/getCachedSubscription.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { cache } from "react";
|
||||||
|
import { priv } from ".";
|
||||||
|
import type { GetOrgTierResponse } from "@server/routers/billing/types";
|
||||||
|
|
||||||
|
export const getCachedSubscription = cache(async (orgId: string) =>
|
||||||
|
priv.get<AxiosResponse<GetOrgTierResponse>>(`/org/${orgId}/billing/tier`)
|
||||||
|
);
|
||||||
30
src/lib/api/isOrgSubscribed.ts
Normal file
30
src/lib/api/isOrgSubscribed.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { build } from "@server/build";
|
||||||
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
|
import { cache } from "react";
|
||||||
|
import { getCachedSubscription } from "./getCachedSubscription";
|
||||||
|
import { priv } from ".";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { GetLicenseStatusResponse } from "@server/routers/license/types";
|
||||||
|
|
||||||
|
export const isOrgSubscribed = cache(async (orgId: string) => {
|
||||||
|
let subscribed = false;
|
||||||
|
|
||||||
|
if (build === "enterprise") {
|
||||||
|
try {
|
||||||
|
const licenseStatusRes =
|
||||||
|
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
|
||||||
|
"/license/status"
|
||||||
|
);
|
||||||
|
subscribed = licenseStatusRes.data.data.isLicenseValid;
|
||||||
|
} catch (error) {}
|
||||||
|
} else if (build === "saas") {
|
||||||
|
try {
|
||||||
|
const subRes = await getCachedSubscription(orgId);
|
||||||
|
subscribed =
|
||||||
|
subRes.data.data.tier === TierId.STANDARD &&
|
||||||
|
subRes.data.data.active;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscribed;
|
||||||
|
});
|
||||||
@@ -3,8 +3,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
|||||||
import { GetUserResponse } from "@server/routers/user";
|
import { GetUserResponse } from "@server/routers/user";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { pullEnv } from "../pullEnv";
|
import { pullEnv } from "../pullEnv";
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
export async function verifySession({
|
export const verifySession = cache(async function ({
|
||||||
skipCheckVerifyEmail,
|
skipCheckVerifyEmail,
|
||||||
forceLogin
|
forceLogin
|
||||||
}: {
|
}: {
|
||||||
@@ -14,8 +15,12 @@ export async function verifySession({
|
|||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (forceLogin) {
|
||||||
|
search.set("forceLogin", "true");
|
||||||
|
}
|
||||||
const res = await internal.get<AxiosResponse<GetUserResponse>>(
|
const res = await internal.get<AxiosResponse<GetUserResponse>>(
|
||||||
`/user${forceLogin ? "?forceLogin=true" : ""}`,
|
`/user?${search.toString()}`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -37,4 +42,4 @@ export async function verifySession({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export const productUpdatesQueries = {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
enabled: enabled && (build === "oss" || build === "enterprise") // disabled in cloud version
|
enabled: enabled && build !== "saas" // disabled in cloud version
|
||||||
// because we don't need to listen for new versions there
|
// because we don't need to listen for new versions there
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
17
src/lib/replacePlaceholder.ts
Normal file
17
src/lib/replacePlaceholder.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function replacePlaceholder(
|
||||||
|
stringWithPlaceholder: string,
|
||||||
|
data: Record<string, string>
|
||||||
|
) {
|
||||||
|
let newString = stringWithPlaceholder;
|
||||||
|
|
||||||
|
const keys = Object.keys(data);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
newString = newString.replace(
|
||||||
|
new RegExp(`{{${key}}}`, "gm"),
|
||||||
|
data[key]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newString;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user