Implement admin theme support (checkpoint)

This commit is contained in:
Joseph Garrone 2024-10-26 21:23:18 +02:00
parent dc4eac1a04
commit 0e93d4ed09
20 changed files with 401 additions and 73 deletions

View File

@ -37,7 +37,7 @@ async function generateI18nMessages() {
const record: { [themeType: string]: { [language: string]: Dictionary } } = {};
for (const themeType of THEME_TYPES) {
for (const themeType of THEME_TYPES.filter(themeType => themeType !== "admin")) {
const { extractedDirPath } = await downloadKeycloakDefaultTheme({
keycloakVersionId: (() => {
switch (themeType) {

View File

@ -5,8 +5,7 @@ import {
ACCOUNT_THEME_PAGE_IDS,
type LoginThemePageId,
type AccountThemePageId,
THEME_TYPES,
type ThemeType
THEME_TYPES
} from "./shared/constants";
import { capitalize } from "tsafe/capitalize";
import * as fs from "fs";
@ -39,6 +38,8 @@ export async function command(params: { buildContext: BuildContext }) {
return buildContext.implementedThemeTypes.account.isImplemented;
case "login":
return buildContext.implementedThemeTypes.login.isImplemented;
case "admin":
return buildContext.implementedThemeTypes.admin.isImplemented;
}
assert<Equals<typeof themeType, never>>(false);
});
@ -49,7 +50,7 @@ export async function command(params: { buildContext: BuildContext }) {
return values[0];
}
const { value } = await cliSelect<ThemeType>({
const { value } = await cliSelect({
values
}).catch(() => {
process.exit(-1);
@ -68,6 +69,16 @@ export async function command(params: { buildContext: BuildContext }) {
);
process.exit(0);
return;
}
if (themeType === "admin") {
console.log(
`${chalk.red("✗")} Sorry, there is no Storybook support for the Account UI.`
);
process.exit(0);
return;
}
console.log(`${themeType}`);

View File

@ -7,8 +7,7 @@ import {
ACCOUNT_THEME_PAGE_IDS,
type LoginThemePageId,
type AccountThemePageId,
THEME_TYPES,
type ThemeType
THEME_TYPES
} from "./shared/constants";
import { capitalize } from "tsafe/capitalize";
import * as fs from "fs";
@ -46,6 +45,8 @@ export async function command(params: { buildContext: BuildContext }) {
return buildContext.implementedThemeTypes.account.isImplemented;
case "login":
return buildContext.implementedThemeTypes.login.isImplemented;
case "admin":
return buildContext.implementedThemeTypes.admin.isImplemented;
}
assert<Equals<typeof themeType, never>>(false);
});
@ -56,7 +57,7 @@ export async function command(params: { buildContext: BuildContext }) {
return values[0];
}
const { value } = await cliSelect<ThemeType>({
const { value } = await cliSelect({
values
}).catch(() => {
process.exit(-1);
@ -66,21 +67,22 @@ export async function command(params: { buildContext: BuildContext }) {
})();
if (
themeType === "account" &&
themeType === "admin" ||
(themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page")
buildContext.implementedThemeTypes.account.type === "Single-Page"))
) {
const srcDirPath = pathJoin(
pathDirname(buildContext.packageJsonFilePath),
"node_modules",
"@keycloakify",
"keycloak-account-ui",
`keycloak-${themeType}-ui`,
"src"
);
console.log(
[
`There isn't an interactive CLI to eject components of the Single-Page Account theme.`,
`There isn't an interactive CLI to eject components of the ${themeType} UI.`,
`You can however copy paste into your codebase the any file or directory from the following source directory:`,
``,
`${chalk.bold(pathJoin(pathRelative(process.cwd(), srcDirPath)))}`,
@ -89,7 +91,8 @@ export async function command(params: { buildContext: BuildContext }) {
);
eject_entrypoint: {
const kcAccountUiTsxFileRelativePath = "KcAccountUi.tsx";
const kcAccountUiTsxFileRelativePath =
`Kc${capitalize(themeType)}Ui.tsx` as const;
const accountThemeSrcDirPath = pathJoin(
buildContext.themeSrcDirPath,
@ -120,7 +123,7 @@ export async function command(params: { buildContext: BuildContext }) {
).replace(/.tsx$/, "");
const modifiedKcPageTsxCode = kcPageTsxCode.replace(
`@keycloakify/keycloak-account-ui/${componentName}`,
`@keycloakify/keycloak-${themeType}-ui/${componentName}`,
`./${componentName}`
);
@ -146,6 +149,7 @@ export async function command(params: { buildContext: BuildContext }) {
}
process.exit(0);
return;
}
console.log(`${themeType}`);

View File

@ -1,12 +1,12 @@
import type { BuildContext } from "../shared/buildContext";
import cliSelect from "cli-select";
import child_process from "child_process";
import chalk from "chalk";
import { join as pathJoin, relative as pathRelative } from "path";
import * as fs from "fs";
import { updateAccountThemeImplementationInConfig } from "./updateAccountThemeImplementationInConfig";
import { command as updateKcGenCommand } from "../update-kc-gen";
import { maybeDelegateCommandToCustomHandler } from "../shared/customHandler_delegate";
import { exitIfUncommittedChanges } from "../shared/exitIfUncommittedChanges";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
@ -38,37 +38,9 @@ export async function command(params: { buildContext: BuildContext }) {
process.exit(-1);
}
exit_if_uncommitted_changes: {
let hasUncommittedChanges: boolean | undefined = undefined;
try {
hasUncommittedChanges =
child_process
.execSync(`git status --porcelain`, {
cwd: buildContext.projectDirPath
})
.toString()
.trim() !== "";
} catch {
// Probably not a git repository
break exit_if_uncommitted_changes;
}
if (!hasUncommittedChanges) {
break exit_if_uncommitted_changes;
}
console.warn(
[
chalk.red(
"Please commit or stash your changes before running this command.\n"
),
"This command will modify your project's files so it's better to have a clean working directory",
"so that you can easily see what has been changed and revert if needed."
].join(" ")
);
process.exit(-1);
}
exitIfUncommittedChanges({
projectDirPath: buildContext.projectDirPath
});
const { value: accountThemeType } = await cliSelect({
values: ["Single-Page" as const, "Multi-Page" as const]

View File

@ -0,0 +1,19 @@
import * as fs from "fs";
import { join as pathJoin } from "path";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
export function copyBoilerplate(params: { adminThemeSrcDirPath: string }) {
const { adminThemeSrcDirPath } = params;
fs.cpSync(
pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"initialize-admin-theme",
"src"
),
adminThemeSrcDirPath,
{ recursive: true }
);
}

View File

@ -0,0 +1 @@
export * from "./initialize-admin-theme";

View File

@ -0,0 +1,60 @@
import type { BuildContext } from "../shared/buildContext";
import chalk from "chalk";
import { join as pathJoin, relative as pathRelative } from "path";
import * as fs from "fs";
import { command as updateKcGenCommand } from "../update-kc-gen";
import { maybeDelegateCommandToCustomHandler } from "../shared/customHandler_delegate";
import { exitIfUncommittedChanges } from "../shared/exitIfUncommittedChanges";
import { initializeAdminTheme } from "./initializeAdminTheme";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "initialize-admin-theme",
buildContext
});
if (hasBeenHandled) {
return;
}
const adminThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "admin");
if (
fs.existsSync(adminThemeSrcDirPath) &&
fs.readdirSync(adminThemeSrcDirPath).length > 0
) {
console.warn(
chalk.red(
`There is already a ${pathRelative(
process.cwd(),
adminThemeSrcDirPath
)} directory in your project. Aborting.`
)
);
process.exit(-1);
}
exitIfUncommittedChanges({
projectDirPath: buildContext.projectDirPath
});
await initializeAdminTheme({
adminThemeSrcDirPath,
buildContext
});
await updateKcGenCommand({
buildContext: {
...buildContext,
implementedThemeTypes: {
...buildContext.implementedThemeTypes,
admin: {
isImplemented: true
}
}
}
});
}

View File

@ -0,0 +1,150 @@
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import type { BuildContext } from "../shared/buildContext";
import * as fs from "fs";
import chalk from "chalk";
import {
getLatestsSemVersionedTag,
type BuildContextLike as BuildContextLike_getLatestsSemVersionedTag
} from "../shared/getLatestsSemVersionedTag";
import { SemVer } from "../tools/SemVer";
import fetch from "make-fetch-happen";
import { z } from "zod";
import { assert, type Equals } from "tsafe/assert";
import { is } from "tsafe/is";
import { id } from "tsafe/id";
import { npmInstall } from "../tools/npmInstall";
import { copyBoilerplate } from "./copyBoilerplate";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
type BuildContextLike = BuildContextLike_getLatestsSemVersionedTag & {
fetchOptions: BuildContext["fetchOptions"];
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function initializeAdminTheme(params: {
adminThemeSrcDirPath: string;
buildContext: BuildContextLike;
}) {
const { adminThemeSrcDirPath, buildContext } = params;
const OWNER = "keycloakify";
const REPO = "keycloak-admin-ui";
const [semVersionedTag] = await getLatestsSemVersionedTag({
owner: OWNER,
repo: REPO,
count: 1,
doIgnoreReleaseCandidates: false,
buildContext
});
const dependencies = await fetch(
`https://raw.githubusercontent.com/${OWNER}/${REPO}/${semVersionedTag.tag}/dependencies.gen.json`,
buildContext.fetchOptions
)
.then(r => r.json())
.then(
(() => {
type Dependencies = {
dependencies: Record<string, string>;
devDependencies?: Record<string, string>;
};
const zDependencies = (() => {
type TargetType = Dependencies;
const zTargetType = z.object({
dependencies: z.record(z.string()),
devDependencies: z.record(z.string()).optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
return o => zDependencies.parse(o);
})()
);
dependencies.dependencies["@keycloakify/keycloak-admin-ui"] = SemVer.stringify(
semVersionedTag.version
);
const parsedPackageJson = (() => {
type ParsedPackageJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
dependencies: z.record(z.string()).optional(),
devDependencies: z.record(z.string()).optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
const parsedPackageJson = JSON.parse(
fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
})();
parsedPackageJson.dependencies = {
...parsedPackageJson.dependencies,
...dependencies.dependencies
};
parsedPackageJson.devDependencies = {
...parsedPackageJson.devDependencies,
...dependencies.devDependencies
};
if (Object.keys(parsedPackageJson.devDependencies).length === 0) {
delete parsedPackageJson.devDependencies;
}
fs.writeFileSync(
buildContext.packageJsonFilePath,
JSON.stringify(parsedPackageJson, undefined, 4)
);
run_npm_install: {
if (
JSON.parse(
fs
.readFileSync(pathJoin(getThisCodebaseRootDirPath(), "package.json"))
.toString("utf8")
)["version"] === "0.0.0"
) {
//NOTE: Linked version
break run_npm_install;
}
npmInstall({ packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath) });
}
copyBoilerplate({ adminThemeSrcDirPath });
console.log(
[
chalk.green("The Admin theme has been successfully initialized."),
`Using Admin UI of Keycloak version: ${chalk.bold(semVersionedTag.tag.split("-")[0])}`,
`Directory created: ${chalk.bold(pathRelative(process.cwd(), adminThemeSrcDirPath))}`,
`Dependencies added to your project's package.json: `,
chalk.bold(JSON.stringify(dependencies, null, 2))
].join("\n")
);
}

View File

@ -0,0 +1,7 @@
import type { KcContextLike } from "@keycloakify/keycloak-admin-ui";
import type { KcEnvName } from "../kc.gen";
export type KcContext = KcContextLike & {
themeType: "admin";
properties: Record<KcEnvName, string>;
};

View File

@ -0,0 +1,11 @@
import { lazy } from "react";
import { KcAdminUiLoader } from "@keycloakify/keycloak-admin-ui";
import type { KcContext } from "./KcContext";
const KcAdminUi = lazy(() => import("@keycloakify/keycloak-admin-ui/KcAdminUi"));
export default function KcPage(props: { kcContext: KcContext }) {
const { kcContext } = props;
return <KcAdminUiLoader kcContext={kcContext} KcAdminUi={KcAdminUi} />;
}

View File

@ -22,7 +22,7 @@ assert<BuildContext extends BuildContextLike ? true : false>();
export function generateMessageProperties(params: {
buildContext: BuildContextLike;
themeType: ThemeType;
themeType: Exclude<ThemeType, "admin">;
}): {
languageTags: string[];
writeMessagePropertiesFiles: (params: {

View File

@ -19,7 +19,8 @@ import {
type ThemeType,
LOGIN_THEME_PAGE_IDS,
ACCOUNT_THEME_PAGE_IDS,
WELL_KNOWN_DIRECTORY_BASE_NAME
WELL_KNOWN_DIRECTORY_BASE_NAME,
THEME_TYPES
} from "../../shared/constants";
import { assert, type Equals } from "tsafe/assert";
import { readFieldNameUsage } from "./readFieldNameUsage";
@ -78,15 +79,29 @@ export async function generateResources(params: {
Record<ThemeType, (params: { messageDirPath: string; themeName: string }) => void>
> = {};
for (const themeType of ["login", "account"] as const) {
for (const themeType of THEME_TYPES) {
if (!buildContext.implementedThemeTypes[themeType].isImplemented) {
continue;
}
const isForAccountSpa =
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
buildContext.implementedThemeTypes.account.type === "Single-Page");
const getAccountThemeType = () => {
assert(themeType === "account");
assert(buildContext.implementedThemeTypes.account.isImplemented);
return buildContext.implementedThemeTypes.account.type;
};
const isSpa = (() => {
switch (themeType) {
case "login":
return false;
case "account":
return getAccountThemeType() === "Single-Page";
case "admin":
return true;
}
})();
const themeTypeDirPath = getThemeTypeDirPath({ themeName, themeType });
@ -101,7 +116,7 @@ export async function generateResources(params: {
rmSync(destDirPath, { recursive: true, force: true });
if (
themeType === "account" &&
themeType !== "login" &&
buildContext.implementedThemeTypes.login.isImplemented
) {
// NOTE: We prevent doing it twice, it has been done for the login theme.
@ -194,10 +209,14 @@ export async function generateResources(params: {
case "login":
return LOGIN_THEME_PAGE_IDS;
case "account":
return isForAccountSpa ? ["index.ftl"] : ACCOUNT_THEME_PAGE_IDS;
return getAccountThemeType() === "Single-Page"
? ["index.ftl"]
: ACCOUNT_THEME_PAGE_IDS;
case "admin":
return ["index.ftl"];
}
})(),
...(isForAccountSpa
...(isSpa
? []
: readExtraPagesNames({
themeType,
@ -215,10 +234,12 @@ export async function generateResources(params: {
let languageTags: string[] | undefined = undefined;
i18n_messages_generation: {
if (isForAccountSpa) {
if (isSpa) {
break i18n_messages_generation;
}
assert(themeType !== "admin");
const wrap = generateMessageProperties({
buildContext,
themeType
@ -231,16 +252,15 @@ export async function generateResources(params: {
writeMessagePropertiesFiles;
}
bring_in_account_v3_i18n_messages: {
if (!buildContext.implementedThemeTypes.account.isImplemented) {
break bring_in_account_v3_i18n_messages;
}
if (buildContext.implementedThemeTypes.account.type !== "Single-Page") {
break bring_in_account_v3_i18n_messages;
bring_in_spas_messages: {
if (!isSpa) {
break bring_in_spas_messages;
}
assert(themeType !== "login");
const accountUiDirPath = child_process
.execSync("npm list @keycloakify/keycloak-account-ui --parseable", {
.execSync(`npm list @keycloakify/keycloak-${themeType}-ui --parseable`, {
cwd: pathDirname(buildContext.packageJsonFilePath)
})
.toString("utf8")
@ -255,7 +275,7 @@ export async function generateResources(params: {
}
const messagesDirPath_dest = pathJoin(
getThemeTypeDirPath({ themeName, themeType: "account" }),
getThemeTypeDirPath({ themeName, themeType }),
"messages"
);
@ -267,7 +287,7 @@ export async function generateResources(params: {
apply_theme_changes: {
const messagesDirPath_theme = pathJoin(
buildContext.themeSrcDirPath,
"account",
themeType,
"messages"
);
@ -316,7 +336,7 @@ export async function generateResources(params: {
}
keycloak_static_resources: {
if (isForAccountSpa) {
if (isSpa) {
break keycloak_static_resources;
}
@ -339,13 +359,22 @@ export async function generateResources(params: {
`parent=${(() => {
switch (themeType) {
case "account":
return isForAccountSpa ? "base" : "account-v1";
switch (getAccountThemeType()) {
case "Multi-Page":
return "account-v1";
case "Single-Page":
return "base";
}
case "login":
return "keycloak";
case "admin":
return "base";
}
assert<Equals<typeof themeType, never>>(false);
})()}`,
...(isForAccountSpa ? ["deprecatedMode=false"] : []),
...(themeType === "account" && getAccountThemeType() === "Single-Page"
? ["deprecatedMode=false"]
: []),
...(buildContext.extraThemeProperties ?? []),
...buildContext.environmentVariables.map(
({ name, default: defaultValue }) =>

View File

@ -197,6 +197,20 @@ program
}
});
program
.command({
name: "initialize-admin-theme",
description: "Initialize the admin theme."
})
.task({
skip,
handler: async ({ projectDirPath }) => {
const { command } = await import("./initialize-admin-theme");
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
program
.command({
name: "copy-keycloak-resources-to-public",

View File

@ -52,6 +52,7 @@ export type BuildContext = {
account:
| { isImplemented: false }
| { isImplemented: true; type: "Single-Page" | "Multi-Page" };
admin: { isImplemented: boolean };
};
packageJsonFilePath: string;
bundler: "vite" | "webpack";
@ -448,7 +449,10 @@ export function getBuildContext(params: {
isImplemented: true,
type: buildOptions.accountThemeImplementation
};
})()
})(),
admin: {
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "admin"))
}
};
if (

View File

@ -4,7 +4,7 @@ export const WELL_KNOWN_DIRECTORY_BASE_NAME = {
DIST: "dist"
} as const;
export const THEME_TYPES = ["login", "account"] as const;
export const THEME_TYPES = ["login", "account", "admin"] as const;
export type ThemeType = (typeof THEME_TYPES)[number];

View File

@ -11,6 +11,7 @@ export type CommandName =
| "eject-page"
| "add-story"
| "initialize-account-theme"
| "initialize-admin-theme"
| "initialize-email-theme"
| "copy-keycloak-resources-to-public";

View File

@ -0,0 +1,36 @@
import child_process from "child_process";
import chalk from "chalk";
export function exitIfUncommittedChanges(params: { projectDirPath: string }) {
const { projectDirPath } = params;
let hasUncommittedChanges: boolean | undefined = undefined;
try {
hasUncommittedChanges =
child_process
.execSync(`git status --porcelain`, {
cwd: projectDirPath
})
.toString()
.trim() !== "";
} catch {
// Probably not a git repository
return;
}
if (!hasUncommittedChanges) {
return;
}
console.warn(
[
chalk.red(
"Please commit or stash your changes before running this command.\n"
),
"This command will modify your project's files so it's better to have a clean working directory",
"so that you can easily see what has been changed and revert if needed."
].join(" ")
);
process.exit(-1);
}

View File

@ -10,5 +10,5 @@
"rootDir": "."
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["initialize-account-theme/src"]
"exclude": ["initialize-account-theme/src", "initialize-admin-theme/src"]
}

View File

@ -22,6 +22,7 @@ export async function command(params: { buildContext: BuildContext }) {
const hasLoginTheme = buildContext.implementedThemeTypes.login.isImplemented;
const hasAccountTheme = buildContext.implementedThemeTypes.account.isImplemented;
const hasAdminTheme = buildContext.implementedThemeTypes.admin.isImplemented;
const newContent = [
``,
@ -54,6 +55,7 @@ export async function command(params: { buildContext: BuildContext }) {
`export type KcContext =`,
hasLoginTheme && ` | import("./login/KcContext").KcContext`,
hasAccountTheme && ` | import("./account/KcContext").KcContext`,
hasAdminTheme && ` | import("./admin/KcContext").KcContext`,
` ;`,
``,
`declare global {`,
@ -66,6 +68,8 @@ export async function command(params: { buildContext: BuildContext }) {
`export const KcLoginPage = lazy(() => import("./login/KcPage"));`,
hasAccountTheme &&
`export const KcAccountPage = lazy(() => import("./account/KcPage"));`,
hasAdminTheme &&
`export const KcAdminPage = lazy(() => import("./admin/KcPage"));`,
``,
`export function KcPage(`,
` props: {`,
@ -82,6 +86,8 @@ export async function command(params: { buildContext: BuildContext }) {
` case "login": return <KcLoginPage kcContext={kcContext} />;`,
hasAccountTheme &&
` case "account": return <KcAccountPage kcContext={kcContext} />;`,
hasAdminTheme &&
` case "admin": return <KcAdminPage kcContext={kcContext} />;`,
` }`,
` })()}`,
` </Suspense>`,

View File

@ -17,5 +17,8 @@
"skipLibCheck": true
},
"include": ["../src", "."],
"exclude": ["../src/bin/initialize-account-theme/src"]
"exclude": [
"../src/bin/initialize-account-theme/src",
"../src/bin/initialize-admin-theme/src"
]
}