misc: add instance banner and consent support

This commit is contained in:
Sheen Capadngan
2025-02-26 23:58:45 +09:00
parent f4bd48fd1d
commit ce4c5d8ea1
12 changed files with 222 additions and 18 deletions

View File

@@ -0,0 +1,31 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasAuthConsentContentCol = await knex.schema.hasColumn(TableName.SuperAdmin, "authConsentContent");
const hasPageFrameContentCol = await knex.schema.hasColumn(TableName.SuperAdmin, "pageFrameContent");
if (await knex.schema.hasTable(TableName.SuperAdmin)) {
await knex.schema.alterTable(TableName.SuperAdmin, (t) => {
if (!hasAuthConsentContentCol) {
t.text("authConsentContent");
}
if (!hasPageFrameContentCol) {
t.text("pageFrameContent");
}
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasAuthConsentContentCol = await knex.schema.hasColumn(TableName.SuperAdmin, "authConsentContent");
const hasPageFrameContentCol = await knex.schema.hasColumn(TableName.SuperAdmin, "pageFrameContent");
await knex.schema.alterTable(TableName.SuperAdmin, (t) => {
if (hasAuthConsentContentCol) {
t.dropColumn("authConsentContent");
}
if (hasPageFrameContentCol) {
t.dropColumn("pageFrameContent");
}
});
}

View File

@@ -23,7 +23,9 @@ export const SuperAdminSchema = z.object({
defaultAuthOrgId: z.string().uuid().nullable().optional(),
enabledLoginMethods: z.string().array().nullable().optional(),
encryptedSlackClientId: zodBuffer.nullable().optional(),
encryptedSlackClientSecret: zodBuffer.nullable().optional()
encryptedSlackClientSecret: zodBuffer.nullable().optional(),
authConsentContent: z.string().nullable().optional(),
pageFrameContent: z.string().nullable().optional()
});
export type TSuperAdmin = z.infer<typeof SuperAdminSchema>;

View File

@@ -72,7 +72,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
message: "At least one login method should be enabled."
}),
slackClientId: z.string().optional(),
slackClientSecret: z.string().optional()
slackClientSecret: z.string().optional(),
authConsentContent: z.string().optional(),
pageFrameContent: z.string().optional()
}),
response: {
200: z.object({

View File

@@ -54,6 +54,7 @@
"classnames": "^2.5.1",
"cva": "npm:class-variance-authority@^0.7.1",
"date-fns": "^4.1.0",
"dompurify": "^3.2.4",
"file-saver": "^2.0.5",
"framer-motion": "^11.14.1",
"i18next": "^24.1.0",
@@ -4078,6 +4079,12 @@
"@types/react": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.0.tgz",
@@ -5945,11 +5952,12 @@
}
},
"node_modules/dompurify": {
"version": "2.5.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz",
"integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
"integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.0",
@@ -8358,6 +8366,12 @@
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/jspdf/node_modules/dompurify": {
"version": "2.5.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz",
"integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==",
"optional": true
},
"node_modules/jsrp": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/jsrp/-/jsrp-0.2.4.tgz",

View File

@@ -58,6 +58,7 @@
"classnames": "^2.5.1",
"cva": "npm:class-variance-authority@^0.7.1",
"date-fns": "^4.1.0",
"dompurify": "^3.2.4",
"file-saver": "^2.0.5",
"framer-motion": "^11.14.1",
"i18next": "^24.1.0",

View File

@@ -0,0 +1,17 @@
/* eslint-disable react/no-danger */
import DOMPurify from "dompurify";
import { useServerConfig } from "@app/context";
export const Banner = () => {
const { config } = useServerConfig();
// eslint-disable-next-line react/no-danger-with-children
return config.pageFrameContent ? (
<div className="h-[3vh] w-full text-center">
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(config.pageFrameContent) }} />
</div>
) : (
<div />
);
};

View File

@@ -59,7 +59,8 @@ export const leaveConfirmDefaultMessage =
export enum SessionStorageKeys {
CLI_TERMINAL_TOKEN = "CLI_TERMINAL_TOKEN",
ORG_LOGIN_SUCCESS_REDIRECT_URL = "ORG_LOGIN_SUCCESS_REDIRECT_URL"
ORG_LOGIN_SUCCESS_REDIRECT_URL = "ORG_LOGIN_SUCCESS_REDIRECT_URL",
AUTH_CONSENT = "AUTH_CONSENT"
}
export const secretTagsColors = [

View File

@@ -22,6 +22,8 @@ export type TServerConfig = {
defaultAuthOrgAuthMethod?: string | null;
defaultAuthOrgAuthEnforced?: boolean | null;
enabledLoginMethods: LoginMethod[];
authConsentContent?: string;
pageFrameContent?: string;
};
export type TCreateAdminUserDTO = {

View File

@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, Outlet } from "@tanstack/react-router";
import { WishForm } from "@app/components/features/WishForm";
import { Banner } from "@app/components/page-frames/Banner";
import {
DropdownMenu,
DropdownMenuContent,
@@ -11,6 +12,7 @@ import {
DropdownMenuTrigger
} from "@app/components/v2";
import { envConfig } from "@app/config/env";
import { useServerConfig } from "@app/context";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner";
@@ -18,10 +20,14 @@ import { INFISICAL_SUPPORT_OPTIONS } from "../OrganizationLayout/components/Mini
export const AdminLayout = () => {
const { t } = useTranslation();
const { config } = useServerConfig();
const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen";
return (
<>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden md:flex">
<Banner />
<div className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden md:flex`}>
{!window.isSecureContext && <InsecureConnectionBanner />}
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
<aside className="dark w-full border-r border-mineshaft-600 bg-gradient-to-tr from-mineshaft-700 via-mineshaft-800 to-mineshaft-900 md:w-60">
@@ -69,7 +75,6 @@ export const AdminLayout = () => {
</DropdownMenuContent>
</DropdownMenu>
</div>
)
</nav>
</aside>
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 dark:[color-scheme:dark]">
@@ -83,6 +88,7 @@ export const AdminLayout = () => {
{` ${t("common.no-mobile")} `}
</p>
</div>
<Banner />
</>
);
};

View File

@@ -6,6 +6,7 @@ import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { CreateOrgModal } from "@app/components/organization/CreateOrgModal";
import { Banner } from "@app/components/page-frames/Banner";
import {
BreadcrumbContainer,
Menu,
@@ -13,6 +14,7 @@ import {
MenuItem,
TBreadcrumbFormat
} from "@app/components/v2";
import { useServerConfig } from "@app/context";
import { usePopUp } from "@app/hooks";
import { InsecureConnectionBanner } from "./components/InsecureConnectionBanner";
@@ -22,6 +24,7 @@ import { SidebarHeader } from "./components/SidebarHeader";
export const OrganizationLayout = () => {
const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context });
const location = useLocation();
const { config } = useServerConfig();
const isOrganizationSpecificPage = location.pathname.startsWith("/organization");
const breadcrumbs =
isOrganizationSpecificPage && matches && "breadcrumbs" in matches
@@ -45,9 +48,14 @@ export const OrganizationLayout = () => {
] as string[]
).includes(location.pathname);
const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen";
return (
<>
<div className="dark hidden h-screen w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex">
<Banner />
<div
className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`}
>
{!window.isSecureContext && <InsecureConnectionBanner />}
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
<MinimizedOrgSidebar />
@@ -137,6 +145,7 @@ export const OrganizationLayout = () => {
{` ${t("common.no-mobile")} `}
</p>
</div>
<Banner />
</>
);
};

View File

@@ -19,7 +19,8 @@ import {
Tab,
TabList,
TabPanel,
Tabs
Tabs,
TextArea
} from "@app/components/v2";
import { useServerConfig, useUser } from "@app/context";
import {
@@ -55,7 +56,9 @@ const formSchema = z.object({
trustSamlEmails: z.boolean(),
trustLdapEmails: z.boolean(),
trustOidcEmails: z.boolean(),
defaultAuthOrgId: z.string()
defaultAuthOrgId: z.string(),
authConsentContent: z.string().optional(),
pageFrameContent: z.string().optional()
});
type TDashboardForm = z.infer<typeof formSchema>;
@@ -80,7 +83,9 @@ export const OverviewPage = () => {
trustSamlEmails: config.trustSamlEmails,
trustLdapEmails: config.trustLdapEmails,
trustOidcEmails: config.trustOidcEmails,
defaultAuthOrgId: config.defaultAuthOrgId ?? ""
defaultAuthOrgId: config.defaultAuthOrgId ?? "",
authConsentContent: config.authConsentContent,
pageFrameContent: config.pageFrameContent
}
});
@@ -95,7 +100,14 @@ export const OverviewPage = () => {
const isNotAllowed = !user?.superAdmin;
const onFormSubmit = async (formData: TDashboardForm) => {
try {
const { allowedSignUpDomain, trustSamlEmails, trustLdapEmails, trustOidcEmails } = formData;
const {
allowedSignUpDomain,
trustSamlEmails,
trustLdapEmails,
trustOidcEmails,
authConsentContent,
pageFrameContent
} = formData;
await updateServerConfig({
defaultAuthOrgId: defaultAuthOrgId || null,
@@ -103,7 +115,9 @@ export const OverviewPage = () => {
allowedSignUpDomain: signUpMode === SignUpModes.Anyone ? allowedSignUpDomain : null,
trustSamlEmails,
trustLdapEmails,
trustOidcEmails
trustOidcEmails,
authConsentContent,
pageFrameContent
});
createNotification({
text: "Successfully changed sign up setting.",
@@ -324,6 +338,51 @@ export const OverviewPage = () => {
}}
/>
</div>
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">Notices</div>
<div className="mb-4 max-w-lg text-sm text-mineshaft-400">
Configure system-wide notification banners and security messages. These
settings control the text displayed to users during authentication and
throughout their session
</div>
<Controller
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Auth Consent Content"
>
<TextArea
placeholder="Auth Consent Message"
{...field}
rows={3}
className="thin-scrollbar h-48 max-w-lg !resize-none bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="authConsentContent"
/>
<Controller
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Page Frame Content"
>
<TextArea
placeholder="Page Frame Content"
{...field}
rows={3}
className="thin-scrollbar h-48 max-w-lg !resize-none bg-mineshaft-800"
/>
</FormControl>
)}
control={control}
name="pageFrameContent"
/>
</div>
<Button
type="submit"
isLoading={isSubmitting}

View File

@@ -1,7 +1,13 @@
import { createFileRoute, redirect, stripSearchParams } from "@tanstack/react-router";
import { useState } from "react";
import { createFileRoute, Outlet, redirect, stripSearchParams } from "@tanstack/react-router";
import { zodValidator } from "@tanstack/zod-adapter";
import { addSeconds, formatISO } from "date-fns";
import DOMPurify from "dompurify";
import { z } from "zod";
import { Button } from "@app/components/v2";
import { SessionStorageKeys } from "@app/const";
import { useServerConfig } from "@app/context";
import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
import { setAuthToken } from "@app/hooks/api/reactQuery";
import { ProjectType } from "@app/hooks/api/workspace/types";
@@ -10,6 +16,59 @@ const QueryParamsSchema = z.object({
callback_port: z.coerce.number().optional().catch(undefined)
});
export const AuthConsentWrapper = () => {
const { config } = useServerConfig();
const [hasConsented, setHasConsented] = useState(() => {
const consentInfo = sessionStorage.getItem(SessionStorageKeys.AUTH_CONSENT);
if (!consentInfo) {
return false;
}
const { expiry, data } = JSON.parse(consentInfo);
if (new Date() > new Date(expiry)) {
sessionStorage.removeItem(SessionStorageKeys.AUTH_CONSENT);
return false;
}
return data === "true";
});
const handleConsent = () => {
sessionStorage.setItem(
SessionStorageKeys.AUTH_CONSENT,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 60)),
data: "true"
})
);
setHasConsented(true);
};
return (
<>
{config.authConsentContent && !hasConsented && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-mineshaft-700/80 bg-opacity-90">
<div className="max-h-[80vh] w-4/12 overflow-y-auto rounded-lg bg-bunker-800 p-6 text-white">
<div
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(config.authConsentContent)
}}
/>
<div className="mt-6 flex justify-end">
<Button onClick={handleConsent} colorSchema="secondary">
OK
</Button>
</div>
</div>
</div>
)}
<Outlet />
</>
);
};
export const Route = createFileRoute("/_restrict-login-signup")({
validateSearch: zodValidator(QueryParamsSchema),
search: {
@@ -45,5 +104,6 @@ export const Route = createFileRoute("/_restrict-login-signup")({
throw redirect({
to: `/organization/${ProjectType.SecretManager}/overview` as const
});
}
},
component: AuthConsentWrapper
});