Compare commits

..

1 Commits

Author SHA1 Message Date
daf95b3dbb wip 2024-10-05 05:57:14 +02:00
114 changed files with 2081 additions and 6976 deletions

View File

@ -290,43 +290,6 @@
"code",
"test"
]
},
{
"login": "nima70",
"name": "Nima Shokouhfar",
"avatar_url": "https://avatars.githubusercontent.com/u/5094767?v=4",
"profile": "https://github.com/nima70",
"contributions": [
"code",
"test"
]
},
{
"login": "marvinruder",
"name": "Marvin A. Ruder",
"avatar_url": "https://avatars.githubusercontent.com/u/18495294?v=4",
"profile": "https://mruder.dev",
"contributions": [
"bug"
]
},
{
"login": "zvn2060",
"name": "HI_OuO",
"avatar_url": "https://avatars.githubusercontent.com/u/45450852?v=4",
"profile": "https://github.com/zvn2060",
"contributions": [
"code"
]
},
{
"login": "tripheo0412",
"name": "Tri Hoang",
"avatar_url": "https://avatars.githubusercontent.com/u/25382052?v=4",
"profile": "https://github.com/tripheo0412",
"contributions": [
"doc"
]
}
],
"contributorsPerLine": 7,

View File

@ -43,48 +43,21 @@
Keycloakify is fully compatible with Keycloak from version 11 to 26...[and beyond](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)
> 📣 **Keycloakify 26 Released**
> Themes built with Keycloakify versions **prior** to Keycloak 26 are **incompatible** with Keycloak 26.
> To ensure compatibility, simply upgrade to the latest Keycloakify version for your major release (v10 or v11) and rebuild your theme.
> No breaking changes have been introduced, but the target version ranges have been updated. For more details, see [this guide](https://docs.keycloakify.dev/targeting-specific-keycloak-versions).
## Sponsors
Project backers, we trust and recommend their services.
Friends for the project, we trust and recommend their services.
<br/>
<div align="center">
![Logo Dark](https://github.com/user-attachments/assets/d8f6b6f5-3de4-4adc-ba15-cb4074e8309b#gh-dark-mode-only)
![Logo Dark](https://github.com/user-attachments/assets/088f6631-b7ef-42ad-812b-df4870dc16ae#gh-dark-mode-only)
</div>
<div align="center">
![Logo Light](https://github.com/user-attachments/assets/20736d6f-f22d-4a9d-9dfe-93be209a8191#gh-light-mode-only)
</div>
<br/>
<p align="center">
<i><a href="https://phasetwo.io/?utm_source=keycloakify"><strong>Keycloak as a Service</strong></a> - Keycloak community contributors of popular <a href="https://github.com/p2-inc#our-extensions-?utm_source=keycloakify">extensions</a> providing free and dedicated <a href="https://phasetwo.io/hosting/?utm_source=keycloakify">Keycloak hosting</a> and enterprise <a href="https://phasetwo.io/support/?utm_source=keycloakify">Keycloak support</a> to businesses of all sizes.</i>
</p>
<br/>
<br/>
<br/>
<div align="center">
![Logo Dark](https://github.com/user-attachments/assets/dd3925fb-a58a-4e91-b360-69c2fa1f1087#gh-dark-mode-only)
</div>
<div align="center">
![Logo Light](https://github.com/user-attachments/assets/6c00c201-eed7-485a-a887-70891559d69b#gh-light-mode-only)
![Logo Light](https://github.com/user-attachments/assets/53fb16f8-02ef-4523-9c36-b42d6e59837e#gh-light-mode-only)
</div>
@ -109,7 +82,7 @@ Project backers, we trust and recommend their services.
</div>
<p align="center">
<a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github"><strong>Managed Keycloak Provider</strong> - With Cloud-IAM powering your Keycloak clusters, you can sleep easy knowing you've got the software and the experts you need for operational excellence. Cloud IAM is a french company. </a>
<a href="https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github"><strong>Managed Keycloak Provider</strong> - With Cloud-IAM powering your Keycloak clusters, you can sleep easy knowing you've got the software and the experts you need for operational excellence. </a>
<br/>
Use code <code>keycloakify5</code> at checkout for a 5% discount.
</p>
@ -163,10 +136,6 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://www.linkedin.com/in/oes-rioniz/"><img src="https://avatars.githubusercontent.com/u/5172296?v=4?s=100" width="100px;" alt="Omid"/><br /><sub><b>Omid</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=uchar" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=uchar" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/kathari00"><img src="https://avatars.githubusercontent.com/u/42547712?v=4?s=100" width="100px;" alt="Katharina Eiserfey"/><br /><sub><b>Katharina Eiserfey</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=kathari00" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=kathari00" title="Tests">⚠️</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=kathari00" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/luca-peruzzo"><img src="https://avatars.githubusercontent.com/u/69015314?v=4?s=100" width="100px;" alt="Luca Peruzzo"/><br /><sub><b>Luca Peruzzo</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=luca-peruzzo" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=luca-peruzzo" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/nima70"><img src="https://avatars.githubusercontent.com/u/5094767?v=4?s=100" width="100px;" alt="Nima Shokouhfar"/><br /><sub><b>Nima Shokouhfar</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=nima70" title="Code">💻</a> <a href="https://github.com/keycloakify/keycloakify/commits?author=nima70" title="Tests">⚠️</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://mruder.dev"><img src="https://avatars.githubusercontent.com/u/18495294?v=4?s=100" width="100px;" alt="Marvin A. Ruder"/><br /><sub><b>Marvin A. Ruder</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/issues?q=author%3Amarvinruder" title="Bug reports">🐛</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zvn2060"><img src="https://avatars.githubusercontent.com/u/45450852?v=4?s=100" width="100px;" alt="HI_OuO"/><br /><sub><b>HI_OuO</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=zvn2060" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tripheo0412"><img src="https://avatars.githubusercontent.com/u/25382052?v=4?s=100" width="100px;" alt="Tri Hoang"/><br /><sub><b>Tri Hoang</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=tripheo0412" title="Documentation">📖</a></td>
</tr>
</tbody>
</table>

View File

@ -1,6 +1,6 @@
{
"name": "keycloakify",
"version": "11.3.30",
"version": "11.2.10",
"description": "Framework to create custom Keycloak UIs",
"repository": {
"type": "git",
@ -38,14 +38,12 @@
"dist/",
"!dist/tsconfig.tsbuildinfo",
"!dist/bin/",
"dist/bin/**/*.d.ts",
"dist/bin/main.js",
"dist/bin/*.index.js",
"dist/bin/*.node",
"dist/bin/shared/constants.js",
"dist/bin/shared/constants.js.map",
"dist/bin/shared/customHandler.js",
"dist/bin/shared/customHandler.js.map",
"dist/bin/shared/*.d.ts",
"dist/bin/shared/*.js.map",
"!dist/vite-plugin/",
"dist/vite-plugin/index.js",
"dist/vite-plugin/index.d.ts",
@ -64,7 +62,7 @@
],
"homepage": "https://www.keycloakify.dev",
"dependencies": {
"tsafe": "^1.8.5"
"tsafe": "^1.7.5"
},
"devDependencies": {
"@babel/core": "^7.24.5",
@ -73,6 +71,7 @@
"@babel/preset-env": "7.24.8",
"@babel/types": "^7.24.5",
"@emotion/react": "^11.11.4",
"@keycloakify/angular": "^0.0.1-rc.19",
"@octokit/rest": "^20.1.1",
"@storybook/addon-a11y": "^6.5.16",
"@storybook/builder-webpack5": "^6.5.13",
@ -95,14 +94,14 @@
"cli-select": "^1.1.2",
"dompurify": "^3.1.6",
"eslint-plugin-storybook": "^0.6.7",
"evt": "^2.5.8",
"evt": "^2.5.7",
"html-entities": "^2.5.2",
"husky": "^4.3.8",
"isomorphic-dompurify": "^2.15.0",
"lint-staged": "^11.0.0",
"magic-string": "^0.30.7",
"make-fetch-happen": "^11.0.3",
"powerhooks": "^1.0.19",
"powerhooks": "^1.0.10",
"prettier": "^3.2.5",
"properties-parser": "^0.3.1",
"react": "^18.2.0",

View File

@ -40,9 +40,7 @@ import { vendorFrontendDependencies } from "./vendorFrontendDependencies";
);
}
run(
`npx ncc build ${join("dist", "bin", "main.js")} --external prettier -o ${join("dist", "ncc_out")}`
);
run(`npx ncc build ${join("dist", "bin", "main.js")} -o ${join("dist", "ncc_out")}`);
transformCodebase({
srcDirPath: join("dist", "ncc_out"),
@ -115,7 +113,7 @@ import { vendorFrontendDependencies } from "./vendorFrontendDependencies";
}
run(
`npx ncc build ${join("dist", "vite-plugin", "index.js")} --external prettier -o ${join(
`npx ncc build ${join("dist", "vite-plugin", "index.js")} -o ${join(
"dist",
"ncc_out"
)}`

View File

@ -1,5 +1,10 @@
import * as fs from "fs";
import { join as pathJoin, basename as pathBasename, dirname as pathDirname } from "path";
import {
join as pathJoin,
relative as pathRelative,
basename as pathBasename,
dirname as pathDirname
} from "path";
import { assert } from "tsafe/assert";
import { run } from "../shared/run";
import { cacheDirPath as cacheDirPath_base } from "../shared/cacheDirPath";
@ -36,12 +41,13 @@ export function vendorFrontendDependencies(params: { distDirPath: string }) {
webpackConfigJsFilePath,
Buffer.from(
[
`const path = require('path');`,
``,
`module.exports = {`,
` mode: 'production',`,
` entry: Buffer.from("${Buffer.from(filePath, "utf8").toString("base64")}", "base64").toString("utf8"),`,
` entry: '${filePath}',`,
` output: {`,
` path: Buffer.from("${Buffer.from(webpackOutputDirPath, "utf8").toString("base64")}", "base64").toString("utf8"),`,
` path: '${webpackOutputDirPath}',`,
` filename: '${pathBasename(webpackOutputFilePath)}',`,
` libraryTarget: 'module',`,
` },`,

View File

@ -4,7 +4,8 @@ import { SemVer } from "../src/bin/tools/SemVer";
import { join as pathJoin, relative as pathRelative } from "path";
import chalk from "chalk";
import { Deferred } from "evt/tools/Deferred";
import { assert, is } from "tsafe/assert";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import { run } from "./shared/run";
(async () => {

View File

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

View File

@ -55,6 +55,7 @@ const commonThirdPartyDeps = [
Buffer.from(modifiedPackageJsonContent, "utf8")
);
}
const yarnGlobalDirPath = pathJoin(rootDirPath, ".yarn_home");
fs.rmSync(yarnGlobalDirPath, { recursive: true, force: true });
@ -63,21 +64,6 @@ fs.mkdirSync(yarnGlobalDirPath);
const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
const { targetModuleName, cwd } = params;
if (targetModuleName === undefined) {
const packageJsonFilePath = pathJoin(cwd, "package.json");
const packageJson = JSON.parse(
fs.readFileSync(packageJsonFilePath).toString("utf8")
);
delete packageJson["packageManager"];
fs.writeFileSync(
packageJsonFilePath,
Buffer.from(JSON.stringify(packageJson, null, 2))
);
}
const cmd = [
"yarn",
"link",
@ -91,10 +77,7 @@ const execYarnLink = (params: { targetModuleName?: string; cwd: string }) => {
env: {
...process.env,
...(os.platform() === "win32"
? {
USERPROFILE: yarnGlobalDirPath,
LOCALAPPDATA: yarnGlobalDirPath
}
? { USERPROFILE: yarnGlobalDirPath }
: { HOME: yarnGlobalDirPath })
}
});
@ -125,54 +108,7 @@ if (testAppPaths.length === 0) {
process.exit(-1);
}
testAppPaths.forEach(testAppPath => {
const packageJsonFilePath = pathJoin(testAppPath, "package.json");
const packageJsonContent = fs.readFileSync(packageJsonFilePath);
const parsedPackageJson = JSON.parse(packageJsonContent.toString("utf8")) as {
scripts?: Record<string, string>;
};
let hasPostInstallOrPrepareScript = false;
if (parsedPackageJson.scripts !== undefined) {
for (const scriptName of ["postinstall", "prepare"]) {
if (parsedPackageJson.scripts[scriptName] === undefined) {
continue;
}
hasPostInstallOrPrepareScript = true;
delete parsedPackageJson.scripts[scriptName];
}
}
if (hasPostInstallOrPrepareScript) {
fs.writeFileSync(
packageJsonFilePath,
Buffer.from(JSON.stringify(parsedPackageJson, null, 2), "utf8")
);
}
const restorePackageJson = () => {
if (!hasPostInstallOrPrepareScript) {
return;
}
fs.writeFileSync(packageJsonFilePath, packageJsonContent);
};
try {
execSync("yarn install", { cwd: testAppPath });
} catch (error) {
restorePackageJson();
throw error;
}
restorePackageJson();
});
testAppPaths.forEach(testAppPath => execSync("yarn install", { cwd: testAppPath }));
console.log("=== Linking common dependencies ===");
@ -219,20 +155,4 @@ testAppPaths.forEach(testAppPath =>
})
);
testAppPaths.forEach(testAppPath => {
const { scripts = {} } = JSON.parse(
fs.readFileSync(pathJoin(testAppPath, "package.json")).toString("utf8")
) as {
scripts?: Record<string, string>;
};
for (const scriptName of ["postinstall", "prepare"]) {
if (scripts[scriptName] === undefined) {
continue;
}
execSync(`yarn run ${scriptName}`, { cwd: testAppPath });
}
});
export {};

View File

@ -1,88 +1,49 @@
import * as fs from "fs";
import { join as pathJoin, sep as pathSep } from "path";
import { run } from "./shared/run";
import cliSelect from "cli-select";
import { getThisCodebaseRootDirPath } from "../src/bin/tools/getThisCodebaseRootDirPath";
import chalk from "chalk";
import { removeNodeModules } from "./tools/removeNodeModules";
import { join } from "path";
import { startRebuildOnSrcChange } from "./shared/startRebuildOnSrcChange";
import { crawl } from "../src/bin/tools/crawl";
import { run } from "./shared/run";
(async () => {
const parentDirPath = pathJoin(getThisCodebaseRootDirPath(), "..");
{
const dirPath = "node_modules";
const { starterName } = await (async () => {
const starterNames = fs
.readdirSync(parentDirPath)
.filter(
basename =>
basename.includes("starter") &&
basename.includes("keycloakify") &&
fs.statSync(pathJoin(parentDirPath, basename)).isDirectory()
);
try {
fs.rmSync(dirPath, { recursive: true, force: true });
} catch {
// NOTE: This is a workaround for windows
// we can't remove locked executables.
if (starterNames.length === 0) {
console.log(
chalk.red(
`No starter found. Keycloakify Angular starter found in ${parentDirPath}`
)
);
process.exit(-1);
}
const starterName = await (async () => {
if (starterNames.length === 1) {
return starterNames[0];
crawl({
dirPath,
returnedPathsType: "absolute"
}).forEach(filePath => {
try {
fs.rmSync(filePath, { force: true });
} catch (error) {
if (filePath.endsWith(".exe")) {
return;
}
throw error;
}
});
}
}
console.log(chalk.cyan(`\nSelect a starter to link in:`));
fs.rmSync("dist", { recursive: true, force: true });
fs.rmSync(".yarn_home", { recursive: true, force: true });
const { value } = await cliSelect<string>({
values: starterNames.map(starterName => `..${pathSep}${starterName}`)
}).catch(() => {
process.exit(-1);
});
run("yarn install");
run("yarn build");
return value.split(pathSep)[1];
})();
const starterName = "keycloakify-starter";
return { starterName };
})();
fs.rmSync(join("..", starterName, "node_modules"), {
recursive: true,
force: true
});
const startTime = Date.now();
run("yarn install", { cwd: join("..", starterName) });
console.log(chalk.cyan(`\n\nLinking in ..${pathSep}${starterName}...`));
run(`npx tsx ${join("scripts", "link-in-app.ts")} ${starterName}`);
removeNodeModules({
nodeModulesDirPath: pathJoin(getThisCodebaseRootDirPath(), "node_modules")
});
fs.rmSync(pathJoin(getThisCodebaseRootDirPath(), "dist"), {
recursive: true,
force: true
});
fs.rmSync(pathJoin(getThisCodebaseRootDirPath(), ".yarn_home"), {
recursive: true,
force: true
});
run("yarn install");
run("yarn build");
const starterDirPath = pathJoin(parentDirPath, starterName);
removeNodeModules({
nodeModulesDirPath: pathJoin(starterDirPath, "node_modules")
});
run("yarn install", { cwd: starterDirPath });
run(`npx tsx ${pathJoin("scripts", "link-in-app.ts")} ${starterName}`);
const durationSeconds = Math.round((Date.now() - startTime) / 1000);
await new Promise(resolve => setTimeout(resolve, 1000));
startRebuildOnSrcChange();
console.log(chalk.green(`\n\nLinked in ${starterName} in ${durationSeconds}s`));
})();
startRebuildOnSrcChange();

View File

@ -1,27 +0,0 @@
import * as fs from "fs";
import { crawl } from "../../src/bin/tools/crawl";
export function removeNodeModules(params: { nodeModulesDirPath: string }) {
const { nodeModulesDirPath } = params;
try {
fs.rmSync(nodeModulesDirPath, { recursive: true, force: true });
} catch {
// NOTE: This is a workaround for windows
// we can't remove locked executables.
crawl({
dirPath: nodeModulesDirPath,
returnedPathsType: "absolute"
}).forEach(filePath => {
try {
fs.rmSync(filePath, { force: true });
} catch (error) {
if (filePath.endsWith(".exe")) {
return;
}
throw error;
}
});
}
}

View File

@ -5,30 +5,25 @@ import {
ACCOUNT_THEME_PAGE_IDS,
type LoginThemePageId,
type AccountThemePageId,
THEME_TYPES
THEME_TYPES,
type ThemeType
} from "./shared/constants";
import { capitalize } from "tsafe/capitalize";
import * as fs from "fs";
import { join as pathJoin, relative as pathRelative, dirname as pathDirname } from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import type { BuildContext } from "./shared/buildContext";
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import chalk from "chalk";
import { runPrettier, getIsPrettierAvailable } from "./tools/runPrettier";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "add-story",
buildContext
const buildContext = getBuildContext({
cliCommandOptions
});
if (hasBeenHandled) {
return;
}
console.log(chalk.cyan("Theme type:"));
const themeType = await (async () => {
@ -38,8 +33,6 @@ 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);
});
@ -50,7 +43,7 @@ export async function command(params: { buildContext: BuildContext }) {
return values[0];
}
const { value } = await cliSelect({
const { value } = await cliSelect<ThemeType>({
values
}).catch(() => {
process.exit(-1);
@ -69,16 +62,6 @@ 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}`);
@ -119,7 +102,7 @@ export async function command(params: { buildContext: BuildContext }) {
process.exit(-1);
}
let sourceCode = fs
const componentCode = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
@ -133,17 +116,6 @@ export async function command(params: { buildContext: BuildContext }) {
.replace('import React from "react";\n', "")
.replace(/from "[./]+dist\//, 'from "keycloakify/');
run_prettier: {
if (!(await getIsPrettierAvailable())) {
break run_prettier;
}
sourceCode = await runPrettier({
filePath: targetFilePath,
sourceCode: sourceCode
});
}
{
const targetDirPath = pathDirname(targetFilePath);
@ -152,7 +124,7 @@ export async function command(params: { buildContext: BuildContext }) {
}
}
fs.writeFileSync(targetFilePath, Buffer.from(sourceCode, "utf8"));
fs.writeFileSync(targetFilePath, Buffer.from(componentCode, "utf8"));
console.log(
[

View File

@ -1,96 +1,13 @@
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import { join as pathJoin, dirname as pathDirname } from "path";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "./shared/constants";
import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
import * as fs from "fs";
import { rmSync } from "./tools/fs.rmSync";
import type { BuildContext } from "./shared/buildContext";
import { transformCodebase } from "./tools/transformCodebase";
import { getThisCodebaseRootDirPath } from "./tools/getThisCodebaseRootDirPath";
import { copyKeycloakResourcesToPublic } from "./shared/copyKeycloakResourcesToPublic";
import { getBuildContext } from "./shared/buildContext";
import type { CliCommandOptions } from "./main";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "copy-keycloak-resources-to-public",
const buildContext = getBuildContext({ cliCommandOptions });
copyKeycloakResourcesToPublic({
buildContext
});
if (hasBeenHandled) {
return;
}
const destDirPath = pathJoin(
buildContext.publicDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
const keycloakifyBuildinfoRaw = JSON.stringify(
{
keycloakifyVersion: readThisNpmPackageVersion()
},
null,
2
);
skip_if_already_done: {
if (!fs.existsSync(keycloakifyBuildinfoFilePath)) {
break skip_if_already_done;
}
const keycloakifyBuildinfoRaw_previousRun = fs
.readFileSync(keycloakifyBuildinfoFilePath)
.toString("utf8");
if (keycloakifyBuildinfoRaw_previousRun !== keycloakifyBuildinfoRaw) {
break skip_if_already_done;
}
return;
}
rmSync(destDirPath, { force: true, recursive: true });
// NOTE: To remove in a while, remove the legacy keycloak-resources directory
rmSync(pathJoin(pathDirname(destDirPath), "keycloak-resources"), {
force: true,
recursive: true
});
rmSync(pathJoin(pathDirname(destDirPath), ".keycloakify"), {
force: true,
recursive: true
});
fs.mkdirSync(destDirPath, { recursive: true });
fs.writeFileSync(pathJoin(destDirPath, ".gitignore"), Buffer.from("*", "utf8"));
transformCodebase({
srcDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
),
destDirPath
});
fs.writeFileSync(
pathJoin(destDirPath, "README.txt"),
Buffer.from(
// prettier-ignore
[
"This directory is only used in dev mode by Keycloakify",
"It won't be included in your final build.",
"Do not modify anything in this directory.",
].join("\n")
)
);
fs.writeFileSync(
keycloakifyBuildinfoFilePath,
Buffer.from(keycloakifyBuildinfoRaw, "utf8")
);
}

View File

@ -1,68 +0,0 @@
import type { BuildContext } from "./shared/buildContext";
import { getUiModuleFileSourceCodeReadyToBeCopied } from "./postinstall/getUiModuleFileSourceCodeReadyToBeCopied";
import { getAbsoluteAndInOsFormatPath } from "./tools/getAbsoluteAndInOsFormatPath";
import { relative as pathRelative, dirname as pathDirname, join as pathJoin } from "path";
import { getUiModuleMetas } from "./postinstall/uiModuleMeta";
import { getInstalledModuleDirPath } from "./tools/getInstalledModuleDirPath";
import * as fsPr from "fs/promises";
import {
readManagedGitignoreFile,
writeManagedGitignoreFile
} from "./postinstall/managedGitignoreFile";
export async function command(params: {
buildContext: BuildContext;
cliCommandOptions: {
file: string;
};
}) {
const { buildContext, cliCommandOptions } = params;
const fileRelativePath = pathRelative(
buildContext.themeSrcDirPath,
getAbsoluteAndInOsFormatPath({
cwd: buildContext.themeSrcDirPath,
pathIsh: cliCommandOptions.file
})
);
const uiModuleMetas = await getUiModuleMetas({ buildContext });
const uiModuleMeta = uiModuleMetas.find(({ files }) =>
files.map(({ fileRelativePath }) => fileRelativePath).includes(fileRelativePath)
);
if (!uiModuleMeta) {
throw new Error(`No UI module found for the file ${fileRelativePath}`);
}
const uiModuleDirPath = await getInstalledModuleDirPath({
moduleName: uiModuleMeta.moduleName,
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath),
projectDirPath: buildContext.projectDirPath
});
const sourceCode = await getUiModuleFileSourceCodeReadyToBeCopied({
buildContext,
fileRelativePath,
isForEjection: true,
uiModuleName: uiModuleMeta.moduleName,
uiModuleDirPath,
uiModuleVersion: uiModuleMeta.version
});
await fsPr.writeFile(
pathJoin(buildContext.themeSrcDirPath, fileRelativePath),
sourceCode
);
const { ejectedFilesRelativePaths } = await readManagedGitignoreFile({
buildContext
});
await writeManagedGitignoreFile({
buildContext,
uiModuleMetas,
ejectedFilesRelativePaths: [...ejectedFilesRelativePaths, fileRelativePath]
});
}

View File

@ -7,7 +7,8 @@ import {
ACCOUNT_THEME_PAGE_IDS,
type LoginThemePageId,
type AccountThemePageId,
THEME_TYPES
THEME_TYPES,
type ThemeType
} from "./shared/constants";
import { capitalize } from "tsafe/capitalize";
import * as fs from "fs";
@ -19,23 +20,17 @@ import {
} from "path";
import { kebabCaseToCamelCase } from "./tools/kebabCaseToSnakeCase";
import { assert, Equals } from "tsafe/assert";
import type { BuildContext } from "./shared/buildContext";
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import chalk from "chalk";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import { runPrettier, getIsPrettierAvailable } from "./tools/runPrettier";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "eject-page",
buildContext
const buildContext = getBuildContext({
cliCommandOptions
});
if (hasBeenHandled) {
return;
}
console.log(chalk.cyan("Theme type:"));
const themeType = await (async () => {
@ -45,8 +40,6 @@ 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);
});
@ -57,7 +50,7 @@ export async function command(params: { buildContext: BuildContext }) {
return values[0];
}
const { value } = await cliSelect({
const { value } = await cliSelect<ThemeType>({
values
}).catch(() => {
process.exit(-1);
@ -66,14 +59,6 @@ export async function command(params: { buildContext: BuildContext }) {
return value;
})();
if (themeType === "admin") {
console.log(
"Use `npx keycloakify eject-file` command instead, see documentation"
);
process.exit(-1);
}
if (
themeType === "account" &&
(assert(buildContext.implementedThemeTypes.account.isImplemented),
@ -83,13 +68,13 @@ export async function command(params: { buildContext: BuildContext }) {
pathDirname(buildContext.packageJsonFilePath),
"node_modules",
"@keycloakify",
`keycloak-account-ui`,
"keycloak-account-ui",
"src"
);
console.log(
[
`There isn't an interactive CLI to eject components of the Account SPA UI.`,
`There isn't an interactive CLI to eject components of the Single-Page Account theme.`,
`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)))}`,
@ -98,44 +83,41 @@ export async function command(params: { buildContext: BuildContext }) {
);
eject_entrypoint: {
const kcUiTsxFileRelativePath = `KcAccountUi.tsx` as const;
const kcAccountUiTsxFileRelativePath = "KcAccountUi.tsx";
const themeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
const accountThemeSrcDirPath = pathJoin(
buildContext.themeSrcDirPath,
"account"
);
const targetFilePath = pathJoin(themeSrcDirPath, kcUiTsxFileRelativePath);
const targetFilePath = pathJoin(
accountThemeSrcDirPath,
kcAccountUiTsxFileRelativePath
);
if (fs.existsSync(targetFilePath)) {
break eject_entrypoint;
}
fs.cpSync(pathJoin(srcDirPath, kcUiTsxFileRelativePath), targetFilePath);
fs.cpSync(
pathJoin(srcDirPath, kcAccountUiTsxFileRelativePath),
targetFilePath
);
{
const kcPageTsxFilePath = pathJoin(themeSrcDirPath, "KcPage.tsx");
const kcPageTsxFilePath = pathJoin(accountThemeSrcDirPath, "KcPage.tsx");
const kcPageTsxCode = fs.readFileSync(kcPageTsxFilePath).toString("utf8");
const componentName = pathBasename(kcUiTsxFileRelativePath).replace(
/.tsx$/,
""
);
const componentName = pathBasename(
kcAccountUiTsxFileRelativePath
).replace(/.tsx$/, "");
let modifiedKcPageTsxCode = kcPageTsxCode.replace(
const modifiedKcPageTsxCode = kcPageTsxCode.replace(
`@keycloakify/keycloak-account-ui/${componentName}`,
`./${componentName}`
);
run_prettier: {
if (!(await getIsPrettierAvailable())) {
break run_prettier;
}
modifiedKcPageTsxCode = await runPrettier({
filePath: kcPageTsxFilePath,
sourceCode: modifiedKcPageTsxCode
});
}
fs.writeFileSync(
kcPageTsxFilePath,
Buffer.from(modifiedKcPageTsxCode, "utf8")
@ -151,14 +133,13 @@ export async function command(params: { buildContext: BuildContext }) {
[
`To help you get started ${chalk.bold(pathRelative(process.cwd(), targetFilePath))} has been copied into your project.`,
`The next step is usually to eject ${chalk.bold(routesTsxFilePath)}`,
`with \`cp ${routesTsxFilePath} ${pathRelative(process.cwd(), themeSrcDirPath)}\``,
`then update the import of routes in ${kcUiTsxFileRelativePath}.`
`with \`cp ${routesTsxFilePath} ${pathRelative(process.cwd(), accountThemeSrcDirPath)}\``,
`then update the import of routes in ${kcAccountUiTsxFileRelativePath}.`
].join("\n")
);
}
process.exit(0);
return;
}
console.log(`${themeType}`);
@ -235,7 +216,7 @@ export async function command(params: { buildContext: BuildContext }) {
process.exit(-1);
}
let componentCode = fs
const componentCode = fs
.readFileSync(
pathJoin(
getThisCodebaseRootDirPath(),
@ -247,17 +228,6 @@ export async function command(params: { buildContext: BuildContext }) {
)
.toString("utf8");
run_prettier: {
if (!(await getIsPrettierAvailable())) {
break run_prettier;
}
componentCode = await runPrettier({
filePath: targetFilePath,
sourceCode: componentCode
});
}
{
const targetDirPath = pathDirname(targetFilePath);
@ -274,12 +244,12 @@ export async function command(params: { buildContext: BuildContext }) {
)} copy pasted from the Keycloakify source code into your project`
);
edit_KcPage: {
edit_KcApp: {
if (
pageIdOrComponent !== templateValue &&
pageIdOrComponent !== userProfileFormFieldsValue
) {
break edit_KcPage;
break edit_KcApp;
}
const kcAppTsxPath = pathJoin(

View File

View File

View File

@ -1,24 +1,17 @@
import type { BuildContext } from "../shared/buildContext";
import { getBuildContext } from "../shared/buildContext";
import type { CliCommandOptions } from "../main";
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";
import { generateKcGenTs } from "../shared/generateKcGenTs";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "initialize-account-theme",
buildContext
});
if (hasBeenHandled) {
return;
}
const buildContext = getBuildContext({ cliCommandOptions });
const accountThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "account");
@ -38,9 +31,37 @@ export async function command(params: { buildContext: BuildContext }) {
process.exit(-1);
}
exitIfUncommittedChanges({
projectDirPath: buildContext.projectDirPath
});
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);
}
const { value: accountThemeType } = await cliSelect({
values: ["Single-Page" as const, "Multi-Page" as const]
@ -76,7 +97,7 @@ export async function command(params: { buildContext: BuildContext }) {
updateAccountThemeImplementationInConfig({ buildContext, accountThemeType });
await updateKcGenCommand({
await generateKcGenTs({
buildContext: {
...buildContext,
implementedThemeTypes: {

View File

@ -1,4 +1,4 @@
import { relative as pathRelative, dirname as pathDirname } from "path";
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";
@ -9,10 +9,12 @@ import {
import { SemVer } from "../tools/SemVer";
import fetch from "make-fetch-happen";
import { z } from "zod";
import { assert, type Equals, is } from "tsafe/assert";
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"];
@ -119,7 +121,20 @@ export async function initializeAccountTheme_singlePage(params: {
JSON.stringify(parsedPackageJson, undefined, 4)
);
npmInstall({ packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath) });
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({
accountThemeType: "Single-Page",

View File

@ -1,5 +1,5 @@
import { join as pathJoin } from "path";
import { assert, type Equals, is } from "tsafe/assert";
import { assert, type Equals } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext";
import * as fs from "fs";
import chalk from "chalk";
@ -8,14 +8,12 @@ import { id } from "tsafe/id";
export type BuildContextLike = {
bundler: BuildContext["bundler"];
projectDirPath: string;
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function updateAccountThemeImplementationInConfig(params: {
buildContext: BuildContextLike;
buildContext: BuildContext;
accountThemeType: "Single-Page" | "Multi-Page";
}) {
const { buildContext, accountThemeType } = params;
@ -83,8 +81,6 @@ export function updateAccountThemeImplementationInConfig(params: {
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
})();

View File

@ -1,25 +1,15 @@
import { join as pathJoin, relative as pathRelative } from "path";
import { transformCodebase } from "./tools/transformCodebase";
import { promptKeycloakVersion } from "./shared/promptKeycloakVersion";
import type { BuildContext } from "./shared/buildContext";
import { getBuildContext } from "./shared/buildContext";
import * as fs from "fs";
import type { CliCommandOptions } from "./main";
import { downloadAndExtractArchive } from "./tools/downloadAndExtractArchive";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import fetch from "make-fetch-happen";
import { SemVer } from "./tools/SemVer";
import { assert } from "tsafe/assert";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "initialize-email-theme",
buildContext
});
if (hasBeenHandled) {
return;
}
const buildContext = getBuildContext({ cliCommandOptions });
const emailThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "email");
@ -39,7 +29,7 @@ export async function command(params: { buildContext: BuildContext }) {
console.log("Initialize with the base email theme from which version of Keycloak?");
let { keycloakVersion } = await promptKeycloakVersion({
const { keycloakVersion } = await promptKeycloakVersion({
// NOTE: This is arbitrary
startingFromMajor: 17,
excludeMajorVersions: [],
@ -47,32 +37,8 @@ export async function command(params: { buildContext: BuildContext }) {
buildContext
});
const getUrl = (keycloakVersion: string) => {
return `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`;
};
keycloakVersion = await (async () => {
const keycloakVersionParsed = SemVer.parse(keycloakVersion);
while (true) {
const url = getUrl(SemVer.stringify(keycloakVersionParsed));
const response = await fetch(url, buildContext.fetchOptions);
if (response.ok) {
break;
}
assert(keycloakVersionParsed.patch !== 0);
keycloakVersionParsed.patch--;
}
return SemVer.stringify(keycloakVersionParsed);
})();
const { extractedDirPath } = await downloadAndExtractArchive({
url: getUrl(keycloakVersion),
url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`,
cacheDirPath: buildContext.cacheDirPath,
fetchOptions: buildContext.fetchOptions,
uniqueIdOfOnArchiveFile: "extractOnlyEmailTheme",

View File

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

View File

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

View File

@ -7,7 +7,7 @@ import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPa
/** Assumes the theme type exists */
export function readFieldNameUsage(params: {
themeSrcDirPath: string;
themeType: Exclude<ThemeType, "admin">;
themeType: ThemeType;
}): string[] {
const { themeSrcDirPath, themeType } = params;

View File

@ -2,16 +2,19 @@ import { generateResources } from "./generateResources";
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
import * as child_process from "child_process";
import * as fs from "fs";
import type { BuildContext } from "../shared/buildContext";
import { getBuildContext } from "../shared/buildContext";
import { VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES } from "../shared/constants";
import { buildJars } from "./buildJars";
import type { CliCommandOptions } from "../main";
import chalk from "chalk";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import * as os from "os";
import { rmSync } from "../tools/fs.rmSync";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const buildContext = getBuildContext({ cliCommandOptions });
exit_if_maven_not_installed: {
let commandOutput: Buffer | undefined = undefined;

View File

@ -4,12 +4,8 @@ import { termost } from "termost";
import { readThisNpmPackageVersion } from "./tools/readThisNpmPackageVersion";
import * as child_process from "child_process";
import { assertNoPnpmDlx } from "./tools/assertNoPnpmDlx";
import { getBuildContext } from "./shared/buildContext";
import { SemVer } from "./tools/SemVer";
import { assert, is } from "tsafe/assert";
import chalk from "chalk";
type CliCommandOptions = {
export type CliCommandOptions = {
projectDirPath: string | undefined;
};
@ -73,17 +69,17 @@ program
})
.task({
skip,
handler: async ({ projectDirPath }) => {
handler: async cliCommandOptions => {
const { command } = await import("./keycloakify");
await command({ buildContext: getBuildContext({ projectDirPath }) });
await command({ cliCommandOptions });
}
});
program
.command<{
port: number | undefined;
keycloakVersion: string | number | undefined;
keycloakVersion: string | undefined;
realmJsonFilePath: string | undefined;
}>({
name: "start-keycloak",
@ -134,54 +130,10 @@ program
})
.task({
skip,
handler: async ({ projectDirPath, keycloakVersion, port, realmJsonFilePath }) => {
handler: async cliCommandOptions => {
const { command } = await import("./start-keycloak");
validate_keycloak_version: {
if (keycloakVersion === undefined) {
break validate_keycloak_version;
}
const isValidVersion = (() => {
if (typeof keycloakVersion === "number") {
return false;
}
try {
SemVer.parse(keycloakVersion);
} catch {
return false;
}
return;
})();
if (isValidVersion) {
break validate_keycloak_version;
}
console.log(
chalk.red(
[
`Invalid Keycloak version: ${keycloakVersion}`,
"It should be a valid semver version example: 26.0.4"
].join(" ")
)
);
process.exit(1);
}
assert(is<string | undefined>(keycloakVersion));
await command({
buildContext: getBuildContext({ projectDirPath }),
cliCommandOptions: {
keycloakVersion,
port,
realmJsonFilePath
}
});
await command({ cliCommandOptions });
}
});
@ -192,10 +144,10 @@ program
})
.task({
skip,
handler: async ({ projectDirPath }) => {
handler: async cliCommandOptions => {
const { command } = await import("./eject-page");
await command({ buildContext: getBuildContext({ projectDirPath }) });
await command({ cliCommandOptions });
}
});
@ -206,10 +158,10 @@ program
})
.task({
skip,
handler: async ({ projectDirPath }) => {
handler: async cliCommandOptions => {
const { command } = await import("./add-story");
await command({ buildContext: getBuildContext({ projectDirPath }) });
await command({ cliCommandOptions });
}
});
@ -220,10 +172,10 @@ program
})
.task({
skip,
handler: async ({ projectDirPath }) => {
handler: async cliCommandOptions => {
const { command } = await import("./initialize-email-theme");
await command({ buildContext: getBuildContext({ projectDirPath }) });
await command({ cliCommandOptions });
}
});
@ -234,10 +186,10 @@ program
})
.task({
skip,
handler: async ({ projectDirPath }) => {
handler: async cliCommandOptions => {
const { command } = await import("./initialize-account-theme");
await command({ buildContext: getBuildContext({ projectDirPath }) });
await command({ cliCommandOptions });
}
});
@ -249,10 +201,10 @@ program
})
.task({
skip,
handler: async ({ projectDirPath }) => {
handler: async cliCommandOptions => {
const { command } = await import("./copy-keycloak-resources-to-public");
await command({ buildContext: getBuildContext({ projectDirPath }) });
await command({ cliCommandOptions });
}
});
@ -264,60 +216,10 @@ program
})
.task({
skip,
handler: async ({ projectDirPath }) => {
handler: async cliCommandOptions => {
const { command } = await import("./update-kc-gen");
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
program
.command({
name: "postinstall",
description: "Initialize all the Keycloakify UI modules installed in the project."
})
.task({
skip,
handler: async ({ projectDirPath }) => {
const { command } = await import("./postinstall");
await command({ buildContext: getBuildContext({ projectDirPath }) });
}
});
program
.command<{
file: string;
}>({
name: "eject-file",
description: [
"WARNING: Not usable yet, will be used for future features",
"Take ownership over a given file"
].join(" ")
})
.option({
key: "file",
name: (() => {
const name = "file";
optionsKeys.push(name);
return name;
})(),
description: [
"Relative path of the file relative to the directory of your keycloak theme source",
"Example `--file src/login/page/Login.tsx`"
].join(" ")
})
.task({
skip,
handler: async ({ projectDirPath, file }) => {
const { command } = await import("./eject-file");
await command({
buildContext: getBuildContext({ projectDirPath }),
cliCommandOptions: { file }
});
await command({ cliCommandOptions });
}
});

View File

@ -1,82 +0,0 @@
import { getIsPrettierAvailable, runPrettier } from "../tools/runPrettier";
import * as fsPr from "fs/promises";
import { join as pathJoin, sep as pathSep } from "path";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext";
import { KEYCLOAK_THEME } from "../shared/constants";
export type BuildContextLike = {
themeSrcDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function getUiModuleFileSourceCodeReadyToBeCopied(params: {
buildContext: BuildContextLike;
fileRelativePath: string;
isForEjection: boolean;
uiModuleDirPath: string;
uiModuleName: string;
uiModuleVersion: string;
}): Promise<Buffer> {
const {
buildContext,
uiModuleDirPath,
fileRelativePath,
isForEjection,
uiModuleName,
uiModuleVersion
} = params;
let sourceCode = (
await fsPr.readFile(pathJoin(uiModuleDirPath, KEYCLOAK_THEME, fileRelativePath))
).toString("utf8");
const toComment = (lines: string[]) => {
for (const ext of [".ts", ".tsx", ".css", ".less", ".sass", ".js", ".jsx"]) {
if (!fileRelativePath.endsWith(ext)) {
continue;
}
return [`/**`, ...lines.map(line => ` * ${line}`), ` */`].join("\n");
}
if (fileRelativePath.endsWith(".html")) {
return [`<!--`, ...lines.map(line => ` ${line}`), `-->`].join("\n");
}
return undefined;
};
const comment = toComment(
isForEjection
? [`This file was ejected from ${uiModuleName} version ${uiModuleVersion}.`]
: [
`WARNING: Before modifying this file run the following command:`,
``,
`$ npx keycloakify eject-file --file ${fileRelativePath.split(pathSep).join("/")}`,
``,
`This file comes from ${uiModuleName} version ${uiModuleVersion}.`,
`This file has been copied over to your repo by your postinstall script: \`npx keycloakify postinstall\``
]
);
if (comment !== undefined) {
sourceCode = [comment, ``, sourceCode].join("\n");
}
const destFilePath = pathJoin(buildContext.themeSrcDirPath, fileRelativePath);
format: {
if (!(await getIsPrettierAvailable())) {
break format;
}
sourceCode = await runPrettier({
filePath: destFilePath,
sourceCode
});
}
return Buffer.from(sourceCode, "utf8");
}

View File

@ -1 +0,0 @@
export * from "./postinstall";

View File

@ -1,157 +0,0 @@
import { assert, type Equals, is } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext";
import type { UiModuleMeta } from "./uiModuleMeta";
import { z } from "zod";
import { id } from "tsafe/id";
import * as fsPr from "fs/promises";
import { SemVer } from "../tools/SemVer";
import { same } from "evt/tools/inDepth/same";
import { runPrettier, getIsPrettierAvailable } from "../tools/runPrettier";
import { npmInstall } from "../tools/npmInstall";
import { dirname as pathDirname } from "path";
export type BuildContextLike = {
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export type UiModuleMetaLike = {
moduleName: string;
peerDependencies: Record<string, string>;
};
assert<UiModuleMeta extends UiModuleMetaLike ? true : false>();
export async function installUiModulesPeerDependencies(params: {
buildContext: BuildContextLike;
uiModuleMetas: UiModuleMetaLike[];
}): Promise<void | never> {
const { buildContext, uiModuleMetas } = params;
const { uiModulesPerDependencies } = (() => {
const uiModulesPerDependencies: Record<string, string> = {};
for (const { peerDependencies } of uiModuleMetas) {
for (const [peerDependencyName, versionRange_candidate] of Object.entries(
peerDependencies
)) {
const versionRange = (() => {
const versionRange_current =
uiModulesPerDependencies[peerDependencyName];
if (versionRange_current === undefined) {
return versionRange_candidate;
}
if (versionRange_current === "*") {
return versionRange_candidate;
}
if (versionRange_candidate === "*") {
return versionRange_current;
}
const { versionRange } = [
versionRange_current,
versionRange_candidate
]
.map(versionRange => ({
versionRange,
semVer: SemVer.parse(
(() => {
if (
versionRange.startsWith("^") ||
versionRange.startsWith("~")
) {
return versionRange.slice(1);
}
return versionRange;
})()
)
}))
.sort((a, b) => SemVer.compare(b.semVer, a.semVer))[0];
return versionRange;
})();
uiModulesPerDependencies[peerDependencyName] = versionRange;
}
}
return { uiModulesPerDependencies };
})();
const parsedPackageJson = await (async () => {
type ParsedPackageJson = {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zParsedPackageJson = z.object({
dependencies: z.record(z.string()).optional(),
devDependencies: z.record(z.string()).optional()
});
type InferredType = z.infer<typeof zParsedPackageJson>;
assert<Equals<InferredType, TargetType>>();
return id<z.ZodType<TargetType>>(zParsedPackageJson);
})();
const parsedPackageJson = JSON.parse(
(await fsPr.readFile(buildContext.packageJsonFilePath)).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
})();
const parsedPackageJson_before = JSON.parse(JSON.stringify(parsedPackageJson));
for (const [moduleName, versionRange] of Object.entries(uiModulesPerDependencies)) {
if (moduleName.startsWith("@types/")) {
(parsedPackageJson.devDependencies ??= {})[moduleName] = versionRange;
continue;
}
if (parsedPackageJson.devDependencies !== undefined) {
delete parsedPackageJson.devDependencies[moduleName];
}
(parsedPackageJson.dependencies ??= {})[moduleName] = versionRange;
}
if (same(parsedPackageJson, parsedPackageJson_before)) {
return;
}
let packageJsonContentStr = JSON.stringify(parsedPackageJson, null, 2);
format: {
if (!(await getIsPrettierAvailable())) {
break format;
}
packageJsonContentStr = await runPrettier({
sourceCode: packageJsonContentStr,
filePath: buildContext.packageJsonFilePath
});
}
await fsPr.writeFile(buildContext.packageJsonFilePath, packageJsonContentStr);
npmInstall({
packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath)
});
process.exit(0);
}

View File

@ -1,136 +0,0 @@
import * as fsPr from "fs/promises";
import {
join as pathJoin,
sep as pathSep,
dirname as pathDirname,
relative as pathRelative
} from "path";
import { assert } from "tsafe/assert";
import type { BuildContext } from "../shared/buildContext";
import type { UiModuleMeta } from "./uiModuleMeta";
import { existsAsync } from "../tools/fs.existsAsync";
import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath";
export type BuildContextLike = {
themeSrcDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
const DELIMITER_START = `# === Ejected files start ===`;
const DELIMITER_END = `# === Ejected files end =====`;
export async function writeManagedGitignoreFile(params: {
buildContext: BuildContextLike;
uiModuleMetas: UiModuleMeta[];
ejectedFilesRelativePaths: string[];
}): Promise<void> {
const { buildContext, uiModuleMetas, ejectedFilesRelativePaths } = params;
if (uiModuleMetas.length === 0) {
return;
}
const filePath = pathJoin(buildContext.themeSrcDirPath, ".gitignore");
const content_new = Buffer.from(
[
`# This file is managed by Keycloakify, do not edit it manually.`,
``,
DELIMITER_START,
...ejectedFilesRelativePaths
.map(fileRelativePath => fileRelativePath.split(pathSep).join("/"))
.map(line => `# ${line}`),
DELIMITER_END,
``,
...uiModuleMetas
.map(uiModuleMeta => [
`# === ${uiModuleMeta.moduleName} v${uiModuleMeta.version} ===`,
...uiModuleMeta.files
.map(({ fileRelativePath }) => fileRelativePath)
.filter(
fileRelativePath =>
!ejectedFilesRelativePaths.includes(fileRelativePath)
)
.map(
fileRelativePath =>
`/${fileRelativePath.split(pathSep).join("/").replace(/^\.\//, "")}`
),
``
])
.flat()
].join("\n"),
"utf8"
);
const content_current = await (async () => {
if (!(await existsAsync(filePath))) {
return undefined;
}
return await fsPr.readFile(filePath);
})();
if (content_current !== undefined && content_current.equals(content_new)) {
return;
}
create_dir: {
const dirPath = pathDirname(filePath);
if (await existsAsync(dirPath)) {
break create_dir;
}
await fsPr.mkdir(dirPath, { recursive: true });
}
await fsPr.writeFile(filePath, content_new);
}
export async function readManagedGitignoreFile(params: {
buildContext: BuildContextLike;
}): Promise<{
ejectedFilesRelativePaths: string[];
}> {
const { buildContext } = params;
const filePath = pathJoin(buildContext.themeSrcDirPath, ".gitignore");
if (!(await existsAsync(filePath))) {
return { ejectedFilesRelativePaths: [] };
}
const contentStr = (await fsPr.readFile(filePath)).toString("utf8");
const payload = (() => {
const index_start = contentStr.indexOf(DELIMITER_START);
const index_end = contentStr.indexOf(DELIMITER_END);
if (index_start === -1 || index_end === -1) {
return undefined;
}
return contentStr.slice(index_start + DELIMITER_START.length, index_end).trim();
})();
if (payload === undefined) {
return { ejectedFilesRelativePaths: [] };
}
const ejectedFilesRelativePaths = payload
.split("\n")
.map(line => line.trim())
.map(line => line.replace(/^# /, ""))
.filter(line => line !== "")
.map(line =>
getAbsoluteAndInOsFormatPath({
cwd: buildContext.themeSrcDirPath,
pathIsh: line
})
)
.map(filePath => pathRelative(buildContext.themeSrcDirPath, filePath));
return { ejectedFilesRelativePaths };
}

View File

@ -1,101 +0,0 @@
import type { BuildContext } from "../shared/buildContext";
import { getUiModuleMetas, computeHash } from "./uiModuleMeta";
import { installUiModulesPeerDependencies } from "./installUiModulesPeerDependencies";
import {
readManagedGitignoreFile,
writeManagedGitignoreFile
} from "./managedGitignoreFile";
import { dirname as pathDirname } from "path";
import { join as pathJoin } from "path";
import { existsAsync } from "../tools/fs.existsAsync";
import * as fsPr from "fs/promises";
import { getIsTrackedByGit } from "../tools/isTrackedByGit";
import { untrackFromGit } from "../tools/untrackFromGit";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
const uiModuleMetas = await getUiModuleMetas({ buildContext });
await installUiModulesPeerDependencies({
buildContext,
uiModuleMetas
});
const { ejectedFilesRelativePaths } = await readManagedGitignoreFile({
buildContext
});
await writeManagedGitignoreFile({
buildContext,
ejectedFilesRelativePaths,
uiModuleMetas
});
await Promise.all(
uiModuleMetas
.map(uiModuleMeta =>
Promise.all(
uiModuleMeta.files.map(
async ({ fileRelativePath, copyableFilePath, hash }) => {
if (ejectedFilesRelativePaths.includes(fileRelativePath)) {
return;
}
const destFilePath = pathJoin(
buildContext.themeSrcDirPath,
fileRelativePath
);
const doesFileExist = await existsAsync(destFilePath);
skip_condition: {
if (!doesFileExist) {
break skip_condition;
}
const destFileHash = computeHash(
await fsPr.readFile(destFilePath)
);
if (destFileHash !== hash) {
break skip_condition;
}
return;
}
git_untrack: {
if (!doesFileExist) {
break git_untrack;
}
const isTracked = await getIsTrackedByGit({
filePath: destFilePath
});
if (!isTracked) {
break git_untrack;
}
await untrackFromGit({
filePath: destFilePath
});
}
{
const dirName = pathDirname(destFilePath);
if (!(await existsAsync(dirName))) {
await fsPr.mkdir(dirName, { recursive: true });
}
}
await fsPr.copyFile(copyableFilePath, destFilePath);
}
)
)
)
.flat()
);
}

View File

@ -1,308 +0,0 @@
import { assert, type Equals, is } from "tsafe/assert";
import { id } from "tsafe/id";
import { z } from "zod";
import { join as pathJoin, dirname as pathDirname } from "path";
import * as fsPr from "fs/promises";
import type { BuildContext } from "../shared/buildContext";
import { existsAsync } from "../tools/fs.existsAsync";
import { listInstalledModules } from "../tools/listInstalledModules";
import { crawlAsync } from "../tools/crawlAsync";
import { getIsPrettierAvailable, getPrettier } from "../tools/runPrettier";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import {
getUiModuleFileSourceCodeReadyToBeCopied,
type BuildContextLike as BuildContextLike_getUiModuleFileSourceCodeReadyToBeCopied
} from "./getUiModuleFileSourceCodeReadyToBeCopied";
import * as crypto from "crypto";
import { KEYCLOAK_THEME } from "../shared/constants";
import { exclude } from "tsafe/exclude";
export type UiModuleMeta = {
moduleName: string;
version: string;
files: {
fileRelativePath: string;
hash: string;
copyableFilePath: string;
}[];
peerDependencies: Record<string, string>;
};
const zUiModuleMeta = (() => {
type ExpectedType = UiModuleMeta;
const zTargetType = z.object({
moduleName: z.string(),
version: z.string(),
files: z.array(
z.object({
fileRelativePath: z.string(),
hash: z.string(),
copyableFilePath: z.string()
})
),
peerDependencies: z.record(z.string())
});
type InferredType = z.infer<typeof zTargetType>;
assert<Equals<InferredType, ExpectedType>>();
return id<z.ZodType<ExpectedType>>(zTargetType);
})();
type ParsedCacheFile = {
keycloakifyVersion: string;
prettierConfigHash: string | null;
thisFilePath: string;
uiModuleMetas: UiModuleMeta[];
};
const zParsedCacheFile = (() => {
type ExpectedType = ParsedCacheFile;
const zTargetType = z.object({
keycloakifyVersion: z.string(),
prettierConfigHash: z.union([z.string(), z.null()]),
thisFilePath: z.string(),
uiModuleMetas: z.array(zUiModuleMeta)
});
type InferredType = z.infer<typeof zTargetType>;
assert<Equals<InferredType, ExpectedType>>();
return id<z.ZodType<ExpectedType>>(zTargetType);
})();
const CACHE_FILE_RELATIVE_PATH = pathJoin("ui-modules", "cache.json");
export type BuildContextLike =
BuildContextLike_getUiModuleFileSourceCodeReadyToBeCopied & {
cacheDirPath: string;
packageJsonFilePath: string;
projectDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function getUiModuleMetas(params: {
buildContext: BuildContextLike;
}): Promise<UiModuleMeta[]> {
const { buildContext } = params;
const cacheFilePath = pathJoin(buildContext.cacheDirPath, CACHE_FILE_RELATIVE_PATH);
const keycloakifyVersion = readThisNpmPackageVersion();
const prettierConfigHash = await (async () => {
if (!(await getIsPrettierAvailable())) {
return null;
}
const { configHash } = await getPrettier();
return configHash;
})();
const installedUiModules = await (async () => {
const installedModulesWithKeycloakifyInTheName = await listInstalledModules({
packageJsonFilePath: buildContext.packageJsonFilePath,
projectDirPath: buildContext.packageJsonFilePath,
filter: ({ moduleName }) =>
moduleName.includes("keycloakify") && moduleName !== "keycloakify"
});
return (
await Promise.all(
installedModulesWithKeycloakifyInTheName.map(async entry => {
if (!(await existsAsync(pathJoin(entry.dirPath, KEYCLOAK_THEME)))) {
return undefined;
}
return entry;
})
)
).filter(exclude(undefined));
})();
const cacheContent = await (async () => {
if (!(await existsAsync(cacheFilePath))) {
return undefined;
}
return await fsPr.readFile(cacheFilePath);
})();
const uiModuleMetas_cacheUpToDate: UiModuleMeta[] = await (async () => {
const parsedCacheFile: ParsedCacheFile | undefined = await (async () => {
if (cacheContent === undefined) {
return undefined;
}
const cacheContentStr = cacheContent.toString("utf8");
let parsedCacheFile: unknown;
try {
parsedCacheFile = JSON.parse(cacheContentStr);
} catch {
return undefined;
}
try {
zParsedCacheFile.parse(parsedCacheFile);
} catch {
return undefined;
}
assert(is<ParsedCacheFile>(parsedCacheFile));
return parsedCacheFile;
})();
if (parsedCacheFile === undefined) {
return [];
}
if (parsedCacheFile.keycloakifyVersion !== keycloakifyVersion) {
return [];
}
if (parsedCacheFile.prettierConfigHash !== prettierConfigHash) {
return [];
}
if (parsedCacheFile.thisFilePath !== cacheFilePath) {
return [];
}
const uiModuleMetas_cacheUpToDate = parsedCacheFile.uiModuleMetas.filter(
uiModuleMeta => {
const correspondingInstalledUiModule = installedUiModules.find(
installedUiModule =>
installedUiModule.moduleName === uiModuleMeta.moduleName
);
if (correspondingInstalledUiModule === undefined) {
return false;
}
return correspondingInstalledUiModule.version === uiModuleMeta.version;
}
);
return uiModuleMetas_cacheUpToDate;
})();
const uiModuleMetas = await Promise.all(
installedUiModules.map(
async ({
moduleName,
version,
peerDependencies,
dirPath
}): Promise<UiModuleMeta> => {
use_cache: {
const uiModuleMeta_cache = uiModuleMetas_cacheUpToDate.find(
uiModuleMeta => uiModuleMeta.moduleName === moduleName
);
if (uiModuleMeta_cache === undefined) {
break use_cache;
}
return uiModuleMeta_cache;
}
const files: UiModuleMeta["files"] = [];
{
const srcDirPath = pathJoin(dirPath, KEYCLOAK_THEME);
await crawlAsync({
dirPath: srcDirPath,
returnedPathsType: "relative to dirPath",
onFileFound: async fileRelativePath => {
const sourceCode =
await getUiModuleFileSourceCodeReadyToBeCopied({
buildContext,
fileRelativePath,
isForEjection: false,
uiModuleDirPath: dirPath,
uiModuleName: moduleName,
uiModuleVersion: version
});
const hash = computeHash(sourceCode);
const copyableFilePath = pathJoin(
pathDirname(cacheFilePath),
KEYCLOAK_THEME,
fileRelativePath
);
{
const dirPath = pathDirname(copyableFilePath);
if (!(await existsAsync(dirPath))) {
await fsPr.mkdir(dirPath, { recursive: true });
}
}
fsPr.writeFile(copyableFilePath, sourceCode);
files.push({
fileRelativePath,
hash,
copyableFilePath
});
}
});
}
return id<UiModuleMeta>({
moduleName,
version,
files,
peerDependencies
});
}
)
);
update_cache: {
const parsedCacheFile = id<ParsedCacheFile>({
keycloakifyVersion,
prettierConfigHash,
thisFilePath: cacheFilePath,
uiModuleMetas
});
const cacheContent_new = Buffer.from(
JSON.stringify(parsedCacheFile, null, 2),
"utf8"
);
if (cacheContent !== undefined && cacheContent_new.equals(cacheContent)) {
break update_cache;
}
create_dir: {
const dirPath = pathDirname(cacheFilePath);
if (await existsAsync(dirPath)) {
break create_dir;
}
await fsPr.mkdir(dirPath, { recursive: true });
}
await fsPr.writeFile(cacheFilePath, cacheContent_new);
}
return uiModuleMetas;
}
export function computeHash(data: Buffer) {
return crypto.createHash("sha256").update(data).digest("hex");
}

View File

@ -7,9 +7,10 @@ import {
dirname as pathDirname
} from "path";
import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath";
import type { CliCommandOptions } from "../main";
import { z } from "zod";
import * as fs from "fs";
import { assert, type Equals, is } from "tsafe/assert";
import { assert, type Equals } from "tsafe/assert";
import * as child_process from "child_process";
import {
VITE_PLUGIN_SUB_SCRIPTS_ENV_NAMES,
@ -18,11 +19,13 @@ import {
import type { KeycloakVersionRange } from "./KeycloakVersionRange";
import { exclude } from "tsafe";
import { crawl } from "../tools/crawl";
import { THEME_TYPES, KEYCLOAK_THEME, type ThemeType } from "./constants";
import { THEME_TYPES } from "./constants";
import { objectEntries } from "tsafe/objectEntries";
import { type ThemeType } from "./constants";
import { id } from "tsafe/id";
import chalk from "chalk";
import { getProxyFetchOptions, type FetchOptionsLike } from "../tools/fetchProxyOptions";
import { getProxyFetchOptions, type ProxyFetchOptions } from "../tools/fetchProxyOptions";
import { is } from "tsafe/is";
export type BuildContext = {
themeVersion: string;
@ -40,7 +43,7 @@ export type BuildContext = {
* In this case the urlPathname will be "/my-app/" */
urlPathname: string | undefined;
assetsDirPath: string;
fetchOptions: FetchOptionsLike;
fetchOptions: ProxyFetchOptions;
kcContextExclusionsFtlCode: string | undefined;
environmentVariables: { name: string; default: string }[];
themeSrcDirPath: string;
@ -50,7 +53,6 @@ export type BuildContext = {
account:
| { isImplemented: false }
| { isImplemented: true; type: "Single-Page" | "Multi-Page" };
admin: { isImplemented: boolean };
};
packageJsonFilePath: string;
bundler: "vite" | "webpack";
@ -127,12 +129,14 @@ export type ResolvedViteConfig = {
};
export function getBuildContext(params: {
projectDirPath: string | undefined;
cliCommandOptions: CliCommandOptions;
}): BuildContext {
const { cliCommandOptions } = params;
const projectDirPath =
params.projectDirPath !== undefined
cliCommandOptions.projectDirPath !== undefined
? getAbsoluteAndInOsFormatPath({
pathIsh: params.projectDirPath,
pathIsh: cliCommandOptions.projectDirPath,
cwd: process.cwd()
})
: process.cwd();
@ -145,10 +149,7 @@ export function getBuildContext(params: {
returnedPathsType: "relative to dirPath"
})
.map(fileRelativePath => {
for (const themeSrcDirBasename of [
KEYCLOAK_THEME,
KEYCLOAK_THEME.replace(/-/g, "_")
]) {
for (const themeSrcDirBasename of ["keycloak-theme", "keycloak_theme"]) {
const split = fileRelativePath.split(themeSrcDirBasename);
if (split.length === 2) {
return pathJoin(srcDirPath, split[0] + themeSrcDirBasename);
@ -174,7 +175,7 @@ export function getBuildContext(params: {
[
`Can't locate your Keycloak theme source directory in .${pathSep}${pathRelative(process.cwd(), srcDirPath)}`,
`Make sure to either use the Keycloakify CLI in the root of your Keycloakify project or use the --project CLI option`,
`If you are collocating your Keycloak theme with your app you must have a directory named '${KEYCLOAK_THEME}' or '${KEYCLOAK_THEME.replace(/-/g, "_")}' in your 'src' directory`
`If you are collocating your Keycloak theme with your app you must have a directory named 'keycloak-theme' or 'keycloak_theme' in your 'src' directory`
].join("\n")
)
);
@ -450,10 +451,7 @@ export function getBuildContext(params: {
isImplemented: true,
type: buildOptions.accountThemeImplementation
};
})(),
admin: {
isImplemented: fs.existsSync(pathJoin(themeSrcDirPath, "admin"))
}
})()
};
if (
@ -513,15 +511,6 @@ export function getBuildContext(params: {
return themeNames;
})();
const relativePathsCwd = (() => {
switch (bundler) {
case "vite":
return projectDirPath;
case "webpack":
return pathDirname(packageJsonFilePath);
}
})();
const projectBuildDirPath = (() => {
webpack: {
if (bundler !== "webpack") {
@ -533,7 +522,7 @@ export function getBuildContext(params: {
if (parsedPackageJson.keycloakify.projectBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: parsedPackageJson.keycloakify.projectBuildDirPath,
cwd: relativePathsCwd
cwd: projectDirPath
});
}
@ -577,7 +566,7 @@ export function getBuildContext(params: {
if (buildOptions.keycloakifyBuildDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.keycloakifyBuildDirPath,
cwd: relativePathsCwd
cwd: projectDirPath
});
}
@ -606,7 +595,7 @@ export function getBuildContext(params: {
if (parsedPackageJson.keycloakify.publicDirPath !== undefined) {
return getAbsoluteAndInOsFormatPath({
pathIsh: parsedPackageJson.keycloakify.publicDirPath,
cwd: relativePathsCwd
cwd: projectDirPath
});
}
@ -678,7 +667,7 @@ export function getBuildContext(params: {
pathIsh:
parsedPackageJson.keycloakify
.staticDirPathInProjectBuildDirPath,
cwd: relativePathsCwd
cwd: projectBuildDirPath
});
}
@ -1006,7 +995,7 @@ export function getBuildContext(params: {
type: "path",
path: getAbsoluteAndInOsFormatPath({
pathIsh: urlOrPath,
cwd: relativePathsCwd
cwd: projectDirPath
})
};
}
@ -1016,7 +1005,7 @@ export function getBuildContext(params: {
? undefined
: getAbsoluteAndInOsFormatPath({
pathIsh: buildOptions.startKeycloakOptions.realmJsonFilePath,
cwd: relativePathsCwd
cwd: projectDirPath
}),
port: buildOptions.startKeycloakOptions?.port
}

View File

@ -4,7 +4,7 @@ export const WELL_KNOWN_DIRECTORY_BASE_NAME = {
DIST: "dist"
} as const;
export const THEME_TYPES = ["login", "account", "admin"] as const;
export const THEME_TYPES = ["login", "account"] as const;
export type ThemeType = (typeof THEME_TYPES)[number];
@ -71,10 +71,3 @@ export type AccountThemePageId = (typeof ACCOUNT_THEME_PAGE_IDS)[number];
export const CONTAINER_NAME = "keycloak-keycloakify";
export const FALLBACK_LANGUAGE_TAG = "en";
export const CUSTOM_HANDLER_ENV_NAMES = {
COMMAND_NAME: "KEYCLOAKIFY_COMMAND_NAME",
BUILD_CONTEXT: "KEYCLOAKIFY_BUILD_CONTEXT"
};
export const KEYCLOAK_THEME = "keycloak-theme";

View File

@ -0,0 +1,95 @@
import { join as pathJoin, dirname as pathDirname } from "path";
import { WELL_KNOWN_DIRECTORY_BASE_NAME } from "../shared/constants";
import { readThisNpmPackageVersion } from "../tools/readThisNpmPackageVersion";
import { assert } from "tsafe/assert";
import * as fs from "fs";
import { rmSync } from "../tools/fs.rmSync";
import type { BuildContext } from "./buildContext";
import { transformCodebase } from "../tools/transformCodebase";
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
export type BuildContextLike = {
publicDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export function copyKeycloakResourcesToPublic(params: {
buildContext: BuildContextLike;
}) {
const { buildContext } = params;
const destDirPath = pathJoin(
buildContext.publicDirPath,
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
);
const keycloakifyBuildinfoFilePath = pathJoin(destDirPath, "keycloakify.buildinfo");
const keycloakifyBuildinfoRaw = JSON.stringify(
{
keycloakifyVersion: readThisNpmPackageVersion()
},
null,
2
);
skip_if_already_done: {
if (!fs.existsSync(keycloakifyBuildinfoFilePath)) {
break skip_if_already_done;
}
const keycloakifyBuildinfoRaw_previousRun = fs
.readFileSync(keycloakifyBuildinfoFilePath)
.toString("utf8");
if (keycloakifyBuildinfoRaw_previousRun !== keycloakifyBuildinfoRaw) {
break skip_if_already_done;
}
return;
}
rmSync(destDirPath, { force: true, recursive: true });
// NOTE: To remove in a while, remove the legacy keycloak-resources directory
rmSync(pathJoin(pathDirname(destDirPath), "keycloak-resources"), {
force: true,
recursive: true
});
rmSync(pathJoin(pathDirname(destDirPath), ".keycloakify"), {
force: true,
recursive: true
});
fs.mkdirSync(destDirPath, { recursive: true });
fs.writeFileSync(pathJoin(destDirPath, ".gitignore"), Buffer.from("*", "utf8"));
transformCodebase({
srcDirPath: pathJoin(
getThisCodebaseRootDirPath(),
"res",
"public",
WELL_KNOWN_DIRECTORY_BASE_NAME.KEYCLOAKIFY_DEV_RESOURCES
),
destDirPath
});
fs.writeFileSync(
pathJoin(destDirPath, "README.txt"),
Buffer.from(
// prettier-ignore
[
"This directory is only used in dev mode by Keycloakify",
"It won't be included in your final build.",
"Do not modify anything in this directory.",
].join("\n")
)
);
fs.writeFileSync(
keycloakifyBuildinfoFilePath,
Buffer.from(keycloakifyBuildinfoRaw, "utf8")
);
}

View File

@ -1,42 +0,0 @@
import { assert } from "tsafe/assert";
import type { BuildContext } from "./buildContext";
import { CUSTOM_HANDLER_ENV_NAMES } from "./constants";
export const BIN_NAME = "_keycloakify-custom-handler";
export const NOT_IMPLEMENTED_EXIT_CODE = 78;
export type CommandName =
| "update-kc-gen"
| "eject-page"
| "add-story"
| "initialize-account-theme"
| "initialize-admin-theme"
| "initialize-email-theme"
| "copy-keycloak-resources-to-public";
export type ApiVersion = "v1";
export function readParams(params: { apiVersion: ApiVersion }) {
const { apiVersion } = params;
assert(apiVersion === "v1");
const commandName = (() => {
const envValue = process.env[CUSTOM_HANDLER_ENV_NAMES.COMMAND_NAME];
assert(envValue !== undefined);
return envValue as CommandName;
})();
const buildContext = (() => {
const envValue = process.env[CUSTOM_HANDLER_ENV_NAMES.BUILD_CONTEXT];
assert(envValue !== undefined);
return JSON.parse(envValue) as BuildContext;
})();
return { commandName, buildContext };
}

View File

@ -1,48 +0,0 @@
import { assert, type Equals } from "tsafe/assert";
import type { BuildContext } from "./buildContext";
import { CUSTOM_HANDLER_ENV_NAMES } from "./constants";
import {
NOT_IMPLEMENTED_EXIT_CODE,
type CommandName,
BIN_NAME,
ApiVersion
} from "./customHandler";
import * as child_process from "child_process";
import { getNodeModulesBinDirPath } from "../tools/nodeModulesBinDirPath";
import * as fs from "fs";
assert<Equals<ApiVersion, "v1">>();
export function maybeDelegateCommandToCustomHandler(params: {
commandName: CommandName;
buildContext: BuildContext;
}): { hasBeenHandled: boolean } {
const { commandName, buildContext } = params;
const nodeModulesBinDirPath = getNodeModulesBinDirPath();
if (!fs.readdirSync(nodeModulesBinDirPath).includes(BIN_NAME)) {
return { hasBeenHandled: false };
}
try {
child_process.execSync(`npx ${BIN_NAME}`, {
stdio: "inherit",
env: {
...process.env,
[CUSTOM_HANDLER_ENV_NAMES.COMMAND_NAME]: commandName,
[CUSTOM_HANDLER_ENV_NAMES.BUILD_CONTEXT]: JSON.stringify(buildContext)
}
});
} catch (error: any) {
const status = error.status;
if (status === NOT_IMPLEMENTED_EXIT_CODE) {
return { hasBeenHandled: false };
}
process.exit(status);
}
return { hasBeenHandled: true };
}

View File

@ -1,36 +0,0 @@
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

@ -0,0 +1,175 @@
import { assert, type Equals } from "tsafe/assert";
import { id } from "tsafe/id";
import type { BuildContext } from "./buildContext";
import * as fs from "fs/promises";
import { join as pathJoin } from "path";
import { existsAsync } from "../tools/fs.existsAsync";
import { z } from "zod";
export type BuildContextLike = {
projectDirPath: string;
themeNames: string[];
environmentVariables: { name: string; default: string }[];
themeSrcDirPath: string;
implementedThemeTypes: Pick<
BuildContext["implementedThemeTypes"],
"login" | "account"
>;
packageJsonFilePath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>();
export async function generateKcGenTs(params: {
buildContext: BuildContextLike;
}): Promise<void> {
const { buildContext } = params;
const isReactProject: boolean = await (async () => {
const parsedPackageJson = await (async () => {
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);
})();
return zParsedPackageJson.parse(
JSON.parse(
(await fs.readFile(buildContext.packageJsonFilePath)).toString("utf8")
)
);
})();
return (
{
...parsedPackageJson.dependencies,
...parsedPackageJson.devDependencies
}.react !== undefined
);
})();
const filePath = pathJoin(
buildContext.themeSrcDirPath,
`kc.gen.ts${isReactProject ? "x" : ""}`
);
const currentContent = (await existsAsync(filePath))
? await fs.readFile(filePath)
: undefined;
const hasLoginTheme = buildContext.implementedThemeTypes.login.isImplemented;
const hasAccountTheme = buildContext.implementedThemeTypes.account.isImplemented;
const newContent = Buffer.from(
[
`/* prettier-ignore-start */`,
``,
`/* eslint-disable */`,
``,
`// @ts-nocheck`,
``,
`// noinspection JSUnusedGlobalSymbols`,
``,
`// This file is auto-generated by Keycloakify`,
``,
isReactProject && `import { lazy, Suspense, type ReactNode } from "react";`,
``,
`export type ThemeName = ${buildContext.themeNames.map(themeName => `"${themeName}"`).join(" | ")};`,
``,
`export const themeNames: ThemeName[] = [${buildContext.themeNames.map(themeName => `"${themeName}"`).join(", ")}];`,
``,
`export type KcEnvName = ${buildContext.environmentVariables.length === 0 ? "never" : buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(" | ")};`,
``,
`export const kcEnvNames: KcEnvName[] = [${buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(", ")}];`,
``,
`export const kcEnvDefaults: Record<KcEnvName, string> = ${JSON.stringify(
Object.fromEntries(
buildContext.environmentVariables.map(
({ name, default: defaultValue }) => [name, defaultValue]
)
),
null,
2
)};`,
``,
`export type KcContext =`,
hasLoginTheme && ` | import("./login/KcContext").KcContext`,
hasAccountTheme && ` | import("./account/KcContext").KcContext`,
` ;`,
``,
`declare global {`,
` interface Window {`,
` kcContext?: KcContext;`,
` }`,
`}`,
``,
...(!isReactProject
? []
: [
hasLoginTheme &&
`export const KcLoginPage = lazy(() => import("./login/KcPage"));`,
hasAccountTheme &&
`export const KcAccountPage = lazy(() => import("./account/KcPage"));`,
``,
`export function KcPage(`,
` props: {`,
` kcContext: KcContext;`,
` fallback?: ReactNode;`,
` }`,
`) {`,
` const { kcContext, fallback } = props;`,
` return (`,
` <Suspense fallback={fallback}>`,
` {(() => {`,
` switch (kcContext.themeType) {`,
hasLoginTheme &&
` case "login": return <KcLoginPage kcContext={kcContext} />;`,
hasAccountTheme &&
` case "account": return <KcAccountPage kcContext={kcContext} />;`,
` }`,
` })()}`,
` </Suspense>`,
` );`,
`}`
]),
``,
`/* prettier-ignore-end */`,
``
]
.filter(item => typeof item === "string")
.join("\n"),
"utf8"
);
if (currentContent !== undefined && currentContent.equals(newContent)) {
return;
}
await fs.writeFile(filePath, newContent);
delete_legacy_file: {
if (!isReactProject) {
break delete_legacy_file;
}
const legacyFilePath = filePath.replace(/tsx$/, "ts");
if (!(await existsAsync(legacyFilePath))) {
break delete_legacy_file;
}
await fs.unlink(legacyFilePath);
}
}

View File

@ -1,5 +1,6 @@
import type { BuildContext } from "../shared/buildContext";
import { getBuildContext } from "../shared/buildContext";
import { exclude } from "tsafe/exclude";
import type { CliCommandOptions as CliCommandOptions_common } from "../main";
import { promptKeycloakVersion } from "../shared/promptKeycloakVersion";
import { CONTAINER_NAME } from "../shared/constants";
import { SemVer } from "../tools/SemVer";
@ -28,14 +29,13 @@ import { existsAsync } from "../tools/fs.existsAsync";
import { rm } from "../tools/fs.rm";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
export async function command(params: {
buildContext: BuildContext;
cliCommandOptions: {
port: number | undefined;
keycloakVersion: string | undefined;
realmJsonFilePath: string | undefined;
};
}) {
export type CliCommandOptions = CliCommandOptions_common & {
port: number | undefined;
keycloakVersion: string | undefined;
realmJsonFilePath: string | undefined;
};
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
exit_if_docker_not_installed: {
let commandOutput: string | undefined = undefined;
@ -88,7 +88,9 @@ export async function command(params: {
process.exit(1);
}
const { cliCommandOptions, buildContext } = params;
const { cliCommandOptions } = params;
const buildContext = getBuildContext({ cliCommandOptions });
const { dockerImageTag } = await (async () => {
if (cliCommandOptions.keycloakVersion !== undefined) {
@ -396,12 +398,12 @@ export async function command(params: {
...(realmJsonFilePath === undefined
? []
: [
`-v${SPACE_PLACEHOLDER}"${realmJsonFilePath}":/opt/keycloak/data/import/myrealm-realm.json`
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), realmJsonFilePath)}":/opt/keycloak/data/import/myrealm-realm.json`
]),
`-v${SPACE_PLACEHOLDER}"${jarFilePath_cacheDir}":/opt/keycloak/providers/keycloak-theme.jar`,
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), jarFilePath_cacheDir)}":/opt/keycloak/providers/keycloak-theme.jar`,
...extensionJarFilePaths.map(
jarFilePath =>
`-v${SPACE_PLACEHOLDER}"${jarFilePath}":/opt/keycloak/providers/${pathBasename(jarFilePath)}`
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), jarFilePath)}":/opt/keycloak/providers/${pathBasename(jarFilePath)}`
),
...(keycloakMajorVersionNumber <= 20
? [`-e${SPACE_PLACEHOLDER}JAVA_OPTS=-Dkeycloak.profile=preview`]
@ -424,7 +426,7 @@ export async function command(params: {
}))
.map(
({ localDirPath, containerDirPath }) =>
`-v${SPACE_PLACEHOLDER}"${localDirPath}":${containerDirPath}:rw`
`-v${SPACE_PLACEHOLDER}".${pathSep}${pathRelative(process.cwd(), localDirPath)}":${containerDirPath}:rw`
),
...buildContext.environmentVariables
.map(({ name }) => ({ name, envValue: process.env[name] }))

View File

@ -1,51 +0,0 @@
import * as fsPr from "fs/promises";
import { join as pathJoin, relative as pathRelative } from "path";
import { assert, type Equals } from "tsafe/assert";
/** List all files in a given directory return paths relative to the dir_path */
export async function crawlAsync(params: {
dirPath: string;
returnedPathsType: "absolute" | "relative to dirPath";
onFileFound: (filePath: string) => Promise<void>;
}) {
const { dirPath, returnedPathsType, onFileFound } = params;
await crawlAsyncRec({
dirPath,
onFileFound: async ({ filePath }) => {
switch (returnedPathsType) {
case "absolute":
await onFileFound(filePath);
return;
case "relative to dirPath":
await onFileFound(pathRelative(dirPath, filePath));
return;
}
assert<Equals<typeof returnedPathsType, never>>();
}
});
}
async function crawlAsyncRec(params: {
dirPath: string;
onFileFound: (params: { filePath: string }) => Promise<void>;
}) {
const { dirPath, onFileFound } = params;
await Promise.all(
(await fsPr.readdir(dirPath)).map(async basename => {
const fileOrDirPath = pathJoin(dirPath, basename);
const isDirectory = await fsPr
.lstat(fileOrDirPath)
.then(stat => stat.isDirectory());
if (isDirectory) {
await crawlAsyncRec({ dirPath: fileOrDirPath, onFileFound });
return;
}
await onFileFound({ filePath: fileOrDirPath });
})
);
}

View File

@ -1,18 +1,16 @@
import { type FetchOptions } from "make-fetch-happen";
import * as child_process from "child_process";
import * as fs from "fs";
import { exclude } from "tsafe/exclude";
export type FetchOptionsLike = {
proxy: string | undefined;
noProxy: string | string[];
strictSSL: boolean;
cert: string | string[] | undefined;
ca: string[] | undefined;
};
export type ProxyFetchOptions = Pick<
FetchOptions,
"proxy" | "noProxy" | "strictSSL" | "cert" | "ca"
>;
export function getProxyFetchOptions(params: {
npmConfigGetCwd: string;
}): FetchOptionsLike {
}): ProxyFetchOptions {
const { npmConfigGetCwd } = params;
const cfg = (() => {

View File

@ -1,51 +0,0 @@
import { join as pathJoin } from "path";
import { existsAsync } from "./fs.existsAsync";
import * as child_process from "child_process";
import { assert } from "tsafe/assert";
export async function getInstalledModuleDirPath(params: {
moduleName: string;
packageJsonDirPath: string;
projectDirPath: string;
}) {
const { moduleName, packageJsonDirPath, projectDirPath } = params;
common_case: {
const dirPath = pathJoin(
...[packageJsonDirPath, "node_modules", ...moduleName.split("/")]
);
if (!(await existsAsync(dirPath))) {
break common_case;
}
return dirPath;
}
node_modules_at_root_case: {
if (projectDirPath === packageJsonDirPath) {
break node_modules_at_root_case;
}
const dirPath = pathJoin(
...[projectDirPath, "node_modules", ...moduleName.split("/")]
);
if (!(await existsAsync(dirPath))) {
break node_modules_at_root_case;
}
return dirPath;
}
const dirPath = child_process
.execSync(`npm list ${moduleName}`, {
cwd: packageJsonDirPath
})
.toString("utf8")
.trim();
assert(dirPath !== "");
return dirPath;
}

View File

@ -1,29 +0,0 @@
import * as child_process from "child_process";
import { dirname as pathDirname, basename as pathBasename } from "path";
import { Deferred } from "evt/tools/Deferred";
export function getIsTrackedByGit(params: { filePath: string }): Promise<boolean> {
const { filePath } = params;
const dIsTracked = new Deferred<boolean>();
child_process.exec(
`git ls-files --error-unmatch ${pathBasename(filePath)}`,
{ cwd: pathDirname(filePath) },
error => {
if (error === null) {
dIsTracked.resolve(true);
return;
}
if (error.code === 1) {
dIsTracked.resolve(false);
return;
}
dIsTracked.reject(error);
}
);
return dIsTracked.pr;
}

View File

@ -1,130 +0,0 @@
import { assert, type Equals, is } from "tsafe/assert";
import { id } from "tsafe/id";
import { z } from "zod";
import { join as pathJoin, dirname as pathDirname } from "path";
import * as fsPr from "fs/promises";
import { getInstalledModuleDirPath } from "../tools/getInstalledModuleDirPath";
import { exclude } from "tsafe/exclude";
export async function listInstalledModules(params: {
packageJsonFilePath: string;
projectDirPath: string;
filter: (params: { moduleName: string }) => boolean;
}): Promise<
{
moduleName: string;
version: string;
dirPath: string;
peerDependencies: Record<string, string>;
}[]
> {
const { packageJsonFilePath, projectDirPath, filter } = params;
const parsedPackageJson = await readPackageJsonDependencies({
packageJsonFilePath
});
const uiModuleNames = (
[parsedPackageJson.dependencies, parsedPackageJson.devDependencies] as const
)
.filter(exclude(undefined))
.map(obj => Object.keys(obj))
.flat()
.filter(moduleName => filter({ moduleName }));
const result = await Promise.all(
uiModuleNames.map(async moduleName => {
const dirPath = await getInstalledModuleDirPath({
moduleName,
packageJsonDirPath: pathDirname(packageJsonFilePath),
projectDirPath
});
const { version, peerDependencies } =
await readPackageJsonVersionAndPeerDependencies({
packageJsonFilePath: pathJoin(dirPath, "package.json")
});
return { moduleName, version, peerDependencies, dirPath } as const;
})
);
return result;
}
const { readPackageJsonDependencies } = (() => {
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);
})();
async function readPackageJsonDependencies(params: { packageJsonFilePath: string }) {
const { packageJsonFilePath } = params;
const parsedPackageJson = JSON.parse(
(await fsPr.readFile(packageJsonFilePath)).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
}
return { readPackageJsonDependencies };
})();
const { readPackageJsonVersionAndPeerDependencies } = (() => {
type ParsedPackageJson = {
version: string;
peerDependencies?: Record<string, string>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
version: z.string(),
peerDependencies: z.record(z.string()).optional()
});
assert<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(zTargetType);
})();
async function readPackageJsonVersionAndPeerDependencies(params: {
packageJsonFilePath: string;
}): Promise<{ version: string; peerDependencies: Record<string, string> }> {
const { packageJsonFilePath } = params;
const parsedPackageJson = JSON.parse(
(await fsPr.readFile(packageJsonFilePath)).toString("utf8")
);
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return {
version: parsedPackageJson.version,
peerDependencies: parsedPackageJson.peerDependencies ?? {}
};
}
return { readPackageJsonVersionAndPeerDependencies };
})();

View File

@ -1,38 +0,0 @@
import { sep as pathSep } from "path";
let cache: string | undefined = undefined;
export function getNodeModulesBinDirPath() {
if (cache !== undefined) {
return cache;
}
const binPath = process.argv[1];
const segments: string[] = [".bin"];
let foundNodeModules = false;
for (const segment of binPath.split(pathSep).reverse()) {
skip_segment: {
if (foundNodeModules) {
break skip_segment;
}
if (segment === "node_modules") {
foundNodeModules = true;
break skip_segment;
}
continue;
}
segments.unshift(segment);
}
const nodeModulesBinDirPath = segments.join(pathSep);
cache = nodeModulesBinDirPath;
return nodeModulesBinDirPath;
}

View File

@ -1,14 +1,7 @@
import * as fs from "fs";
import { join as pathJoin, dirname as pathDirname } from "path";
import { join as pathJoin } from "path";
import * as child_process from "child_process";
import chalk from "chalk";
import { z } from "zod";
import { assert, type Equals, is } from "tsafe/assert";
import { id } from "tsafe/id";
import { objectKeys } from "tsafe/objectKeys";
import { getAbsoluteAndInOsFormatPath } from "./getAbsoluteAndInOsFormatPath";
import { exclude } from "tsafe/exclude";
import { rmSync } from "./fs.rmSync";
export function npmInstall(params: { packageJsonDirPath: string }) {
const { packageJsonDirPath } = params;
@ -30,10 +23,6 @@ export function npmInstall(params: { packageJsonDirPath: string }) {
{
binName: "bun",
lockFileBasename: "bun.lockdb"
},
{
binName: "deno",
lockFileBasename: "deno.lock"
}
] as const;
@ -48,411 +37,27 @@ export function npmInstall(params: { packageJsonDirPath: string }) {
}
}
throw new Error(
"No lock file found, cannot tell which package manager to use for installing dependencies."
);
return undefined;
})();
console.log(`Installing the new dependencies...`);
install_without_breaking_links: {
if (packageManagerBinName !== "yarn") {
break install_without_breaking_links;
install_dependencies: {
if (packageManagerBinName === undefined) {
break install_dependencies;
}
const garronejLinkInfos = getGarronejLinkInfos({ packageJsonDirPath });
if (garronejLinkInfos === undefined) {
break install_without_breaking_links;
}
console.log(chalk.green("Installing in a way that won't break the links..."));
installWithoutBreakingLinks({
packageJsonDirPath,
garronejLinkInfos
});
return;
}
try {
child_process.execSync(`${packageManagerBinName} install`, {
cwd: packageJsonDirPath,
stdio: "inherit"
});
} catch {
console.log(
chalk.yellow(
`\`${packageManagerBinName} install\` failed, continuing anyway...`
)
);
}
}
function getGarronejLinkInfos(params: {
packageJsonDirPath: string;
}): { linkedModuleNames: string[]; yarnHomeDirPath: string } | undefined {
const { packageJsonDirPath } = params;
const nodeModuleDirPath = pathJoin(packageJsonDirPath, "node_modules");
if (!fs.existsSync(nodeModuleDirPath)) {
return undefined;
}
const linkedModuleNames: string[] = [];
let yarnHomeDirPath: string | undefined = undefined;
const getIsLinkedByGarronejScript = (path: string) => {
let realPath: string;
console.log(`Installing the new dependencies...`);
try {
realPath = fs.readlinkSync(path);
} catch {
return false;
}
const doesIncludeYarnHome = realPath.includes(".yarn_home");
if (!doesIncludeYarnHome) {
return false;
}
set_yarnHomeDirPath: {
if (yarnHomeDirPath !== undefined) {
break set_yarnHomeDirPath;
}
const [firstElement] = getAbsoluteAndInOsFormatPath({
pathIsh: realPath,
cwd: pathDirname(path)
}).split(".yarn_home");
yarnHomeDirPath = pathJoin(firstElement, ".yarn_home");
}
return true;
};
for (const basename of fs.readdirSync(nodeModuleDirPath)) {
const path = pathJoin(nodeModuleDirPath, basename);
if (fs.lstatSync(path).isSymbolicLink()) {
if (basename.startsWith("@")) {
return undefined;
}
if (!getIsLinkedByGarronejScript(path)) {
return undefined;
}
linkedModuleNames.push(basename);
continue;
}
if (!fs.lstatSync(path).isDirectory()) {
continue;
}
if (basename.startsWith("@")) {
for (const subBasename of fs.readdirSync(path)) {
const subPath = pathJoin(path, subBasename);
if (!fs.lstatSync(subPath).isSymbolicLink()) {
continue;
}
if (!getIsLinkedByGarronejScript(subPath)) {
return undefined;
}
linkedModuleNames.push(`${basename}/${subBasename}`);
}
}
}
if (yarnHomeDirPath === undefined) {
return undefined;
}
return { linkedModuleNames, yarnHomeDirPath };
}
function installWithoutBreakingLinks(params: {
packageJsonDirPath: string;
garronejLinkInfos: Exclude<ReturnType<typeof getGarronejLinkInfos>, undefined>;
}) {
const {
packageJsonDirPath,
garronejLinkInfos: { linkedModuleNames, yarnHomeDirPath }
} = params;
const parsedPackageJson = (() => {
const packageJsonFilePath = pathJoin(packageJsonDirPath, "package.json");
type ParsedPackageJson = {
scripts?: Record<string, string>;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
scripts: z.record(z.string()).optional()
child_process.execSync(`${packageManagerBinName} install`, {
cwd: packageJsonDirPath,
stdio: "inherit"
});
type InferredType = z.infer<typeof zTargetType>;
assert<Equals<TargetType, InferredType>>;
return id<z.ZodType<TargetType>>(zTargetType);
})();
const parsedPackageJson = JSON.parse(
fs.readFileSync(packageJsonFilePath).toString("utf8")
) as unknown;
zParsedPackageJson.parse(parsedPackageJson);
assert(is<ParsedPackageJson>(parsedPackageJson));
return parsedPackageJson;
})();
const isImplementedScriptByName = {
postinstall: false,
prepare: false
};
delete_postinstall_script: {
if (parsedPackageJson.scripts === undefined) {
break delete_postinstall_script;
} catch {
console.log(
chalk.yellow(
`\`${packageManagerBinName} install\` failed, continuing anyway...`
)
);
}
for (const scriptName of objectKeys(isImplementedScriptByName)) {
if (parsedPackageJson.scripts[scriptName] === undefined) {
continue;
}
isImplementedScriptByName[scriptName] = true;
delete parsedPackageJson.scripts[scriptName];
}
}
const tmpProjectDirPath = pathJoin(yarnHomeDirPath, "tmpProject");
if (fs.existsSync(tmpProjectDirPath)) {
rmSync(tmpProjectDirPath, { recursive: true });
}
fs.mkdirSync(tmpProjectDirPath, { recursive: true });
fs.writeFileSync(
pathJoin(tmpProjectDirPath, "package.json"),
JSON.stringify(parsedPackageJson, undefined, 4)
);
const YARN_LOCK = "yarn.lock";
fs.copyFileSync(
pathJoin(packageJsonDirPath, YARN_LOCK),
pathJoin(tmpProjectDirPath, YARN_LOCK)
);
child_process.execSync(`yarn install`, {
cwd: tmpProjectDirPath,
stdio: "inherit"
});
// NOTE: Moving the modules from the tmp project to the actual project
// without messing up the links.
{
const { getAreSameVersions } = (() => {
type ParsedPackageJson = {
version: string;
};
const zParsedPackageJson = (() => {
type TargetType = ParsedPackageJson;
const zTargetType = z.object({
version: z.string()
});
type InferredType = z.infer<typeof zTargetType>;
assert<Equals<TargetType, InferredType>>;
return id<z.ZodType<TargetType>>(zTargetType);
})();
function readVersion(params: { moduleDirPath: string }): string {
const { moduleDirPath } = params;
const packageJsonFilePath = pathJoin(moduleDirPath, "package.json");
const packageJson = JSON.parse(
fs.readFileSync(packageJsonFilePath).toString("utf8")
);
zParsedPackageJson.parse(packageJson);
assert(is<ParsedPackageJson>(packageJson));
return packageJson.version;
}
function getAreSameVersions(params: {
moduleDirPath_a: string;
moduleDirPath_b: string;
}): boolean {
const { moduleDirPath_a, moduleDirPath_b } = params;
return (
readVersion({ moduleDirPath: moduleDirPath_a }) ===
readVersion({ moduleDirPath: moduleDirPath_b })
);
}
return { getAreSameVersions };
})();
const nodeModulesDirPath_tmpProject = pathJoin(tmpProjectDirPath, "node_modules");
const nodeModulesDirPath = pathJoin(packageJsonDirPath, "node_modules");
const modulePaths = fs
.readdirSync(nodeModulesDirPath_tmpProject)
.map(basename => {
if (basename.startsWith(".")) {
return undefined;
}
const path = pathJoin(nodeModulesDirPath_tmpProject, basename);
if (basename.startsWith("@")) {
return fs
.readdirSync(path)
.map(subBasename => {
if (subBasename.startsWith(".")) {
return undefined;
}
const subPath = pathJoin(path, subBasename);
if (!fs.lstatSync(subPath).isDirectory()) {
return undefined;
}
return {
moduleName: `${basename}/${subBasename}`,
moduleDirPath_tmpProject: subPath,
moduleDirPath: pathJoin(
nodeModulesDirPath,
basename,
subBasename
)
};
})
.filter(exclude(undefined));
}
if (!fs.lstatSync(path).isDirectory()) {
return undefined;
}
return [
{
moduleName: basename,
moduleDirPath_tmpProject: path,
moduleDirPath: pathJoin(nodeModulesDirPath, basename)
}
];
})
.filter(exclude(undefined))
.flat();
for (const {
moduleName,
moduleDirPath,
moduleDirPath_tmpProject
} of modulePaths) {
if (linkedModuleNames.includes(moduleName)) {
continue;
}
let doesTargetModuleExist = false;
skip_condition: {
if (!fs.existsSync(moduleDirPath)) {
break skip_condition;
}
doesTargetModuleExist = true;
const areSameVersions = getAreSameVersions({
moduleDirPath_a: moduleDirPath,
moduleDirPath_b: moduleDirPath_tmpProject
});
if (!areSameVersions) {
break skip_condition;
}
continue;
}
if (doesTargetModuleExist) {
rmSync(moduleDirPath, { recursive: true });
}
{
const dirPath = pathDirname(moduleDirPath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
fs.renameSync(moduleDirPath_tmpProject, moduleDirPath);
}
move_bin: {
const binDirPath_tmpProject = pathJoin(nodeModulesDirPath_tmpProject, ".bin");
const binDirPath = pathJoin(nodeModulesDirPath, ".bin");
if (!fs.existsSync(binDirPath_tmpProject)) {
break move_bin;
}
for (const basename of fs.readdirSync(binDirPath_tmpProject)) {
const path_tmpProject = pathJoin(binDirPath_tmpProject, basename);
const path = pathJoin(binDirPath, basename);
if (fs.existsSync(path)) {
continue;
}
fs.renameSync(path_tmpProject, path);
}
}
}
fs.cpSync(
pathJoin(tmpProjectDirPath, YARN_LOCK),
pathJoin(packageJsonDirPath, YARN_LOCK)
);
rmSync(tmpProjectDirPath, { recursive: true });
for (const scriptName of objectKeys(isImplementedScriptByName)) {
if (!isImplementedScriptByName[scriptName]) {
continue;
}
child_process.execSync(`yarn run ${scriptName}`, {
cwd: packageJsonDirPath,
stdio: "inherit"
});
}
}

View File

@ -3,13 +3,7 @@ import { assert } from "tsafe/assert";
import * as fs from "fs";
import { join as pathJoin } from "path";
let cache: string | undefined = undefined;
export function readThisNpmPackageVersion(): string {
if (cache !== undefined) {
return cache;
}
const version = JSON.parse(
fs
.readFileSync(pathJoin(getThisCodebaseRootDirPath(), "package.json"))
@ -18,7 +12,5 @@ export function readThisNpmPackageVersion(): string {
assert(typeof version === "string");
cache = version;
return version;
}

View File

@ -1,126 +0,0 @@
import { getNodeModulesBinDirPath } from "./nodeModulesBinDirPath";
import { join as pathJoin, resolve as pathResolve } from "path";
import * as fsPr from "fs/promises";
import { id } from "tsafe/id";
import { assert, is } from "tsafe/assert";
import chalk from "chalk";
import * as crypto from "crypto";
import { symToStr } from "tsafe/symToStr";
import { readThisNpmPackageVersion } from "./readThisNpmPackageVersion";
getIsPrettierAvailable.cache = id<boolean | undefined>(undefined);
export async function getIsPrettierAvailable(): Promise<boolean> {
if (getIsPrettierAvailable.cache !== undefined) {
return getIsPrettierAvailable.cache;
}
const nodeModulesBinDirPath = getNodeModulesBinDirPath();
const prettierBinPath = pathJoin(nodeModulesBinDirPath, "prettier");
const stats = await fsPr.stat(prettierBinPath).catch(() => undefined);
const isPrettierAvailable = stats?.isFile() ?? false;
getIsPrettierAvailable.cache = isPrettierAvailable;
return isPrettierAvailable;
}
type PrettierAndConfigHash = {
prettier: typeof import("prettier");
configHash: string;
};
getPrettier.cache = id<PrettierAndConfigHash | undefined>(undefined);
export async function getPrettier(): Promise<PrettierAndConfigHash> {
assert(getIsPrettierAvailable());
if (getPrettier.cache !== undefined) {
return getPrettier.cache;
}
let prettier = id<typeof import("prettier") | undefined>(undefined);
import_prettier: {
// NOTE: When module is linked we want to make sure we import the correct version
// of prettier, that is the one of the project, not the one of this repo.
// So we do a sketchy eval to bypass ncc.
// We make sure to only do that when linking, otherwise we import properly.
if (readThisNpmPackageVersion().startsWith("0.0.0")) {
eval(
`${symToStr({ prettier })} = require("${pathResolve(pathJoin(getNodeModulesBinDirPath(), "..", "prettier"))}")`
);
assert(!is<undefined>(prettier));
break import_prettier;
}
prettier = await import("prettier");
}
const configHash = await (async () => {
const configFilePath = await prettier.resolveConfigFile(
pathJoin(getNodeModulesBinDirPath(), "..")
);
if (configFilePath === null) {
return "";
}
const data = await fsPr.readFile(configFilePath);
return crypto.createHash("sha256").update(data).digest("hex");
})();
const prettierAndConfig: PrettierAndConfigHash = {
prettier,
configHash
};
getPrettier.cache = prettierAndConfig;
return prettierAndConfig;
}
export async function runPrettier(params: {
sourceCode: string;
filePath: string;
}): Promise<string> {
const { sourceCode, filePath } = params;
let formattedSourceCode: string;
try {
const { prettier } = await getPrettier();
const { ignored, inferredParser } = await prettier.getFileInfo(filePath, {
resolveConfig: true
});
if (ignored) {
return sourceCode;
}
const config = await prettier.resolveConfig(filePath);
formattedSourceCode = await prettier.format(sourceCode, {
...config,
filePath,
parser: inferredParser ?? undefined
});
} catch (error) {
console.log(
chalk.red(
`You probably need to upgrade the version of prettier in your project`
)
);
throw error;
}
return formattedSourceCode;
}

View File

@ -1,24 +0,0 @@
import * as child_process from "child_process";
import { dirname as pathDirname, basename as pathBasename } from "path";
import { Deferred } from "evt/tools/Deferred";
export async function untrackFromGit(params: { filePath: string }): Promise<void> {
const { filePath } = params;
const dDone = new Deferred<void>();
child_process.exec(
`git rm --cached ${pathBasename(filePath)}`,
{ cwd: pathDirname(filePath) },
error => {
if (error !== null) {
dDone.reject(error);
return;
}
dDone.resolve();
}
);
await dDone.pr;
}

View File

@ -1,146 +1,13 @@
import type { BuildContext } from "./shared/buildContext";
import * as fs from "fs/promises";
import { join as pathJoin } from "path";
import { existsAsync } from "./tools/fs.existsAsync";
import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate";
import * as crypto from "crypto";
import { getIsPrettierAvailable, runPrettier } from "./tools/runPrettier";
import type { CliCommandOptions } from "./main";
import { getBuildContext } from "./shared/buildContext";
import { generateKcGenTs } from "./shared/generateKcGenTs";
export async function command(params: { buildContext: BuildContext }) {
const { buildContext } = params;
export async function command(params: { cliCommandOptions: CliCommandOptions }) {
const { cliCommandOptions } = params;
const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({
commandName: "update-kc-gen",
buildContext
const buildContext = getBuildContext({
cliCommandOptions
});
if (hasBeenHandled) {
return;
}
const filePath = pathJoin(buildContext.themeSrcDirPath, "kc.gen.tsx");
const hasLoginTheme = buildContext.implementedThemeTypes.login.isImplemented;
const hasAccountTheme = buildContext.implementedThemeTypes.account.isImplemented;
const hasAdminTheme = buildContext.implementedThemeTypes.admin.isImplemented;
let newContent = [
``,
`/* eslint-disable */`,
``,
`// @ts-nocheck`,
``,
`// noinspection JSUnusedGlobalSymbols`,
``,
`import { lazy, Suspense, type ReactNode } from "react";`,
``,
`export type ThemeName = ${buildContext.themeNames.map(themeName => `"${themeName}"`).join(" | ")};`,
``,
`export const themeNames: ThemeName[] = [${buildContext.themeNames.map(themeName => `"${themeName}"`).join(", ")}];`,
``,
`export type KcEnvName = ${buildContext.environmentVariables.length === 0 ? "never" : buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(" | ")};`,
``,
`export const kcEnvNames: KcEnvName[] = [${buildContext.environmentVariables.map(({ name }) => `"${name}"`).join(", ")}];`,
``,
`export const kcEnvDefaults: Record<KcEnvName, string> = ${JSON.stringify(
Object.fromEntries(
buildContext.environmentVariables.map(
({ name, default: defaultValue }) => [name, defaultValue]
)
),
null,
2
)};`,
``,
`type KcContext =`,
hasLoginTheme && ` | import("./login/KcContext").KcContext`,
hasAccountTheme && ` | import("./account/KcContext").KcContext`,
hasAdminTheme && ` | import("./admin/KcContext").KcContext`,
` ;`,
``,
`declare global {`,
` interface Window {`,
` kcContext?: KcContext;`,
` }`,
`}`,
``,
hasLoginTheme &&
`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: {`,
` kcContext: KcContext;`,
` fallback?: ReactNode;`,
` }`,
`) {`,
` const { kcContext, fallback } = props;`,
` return (`,
` <Suspense fallback={fallback}>`,
` {(() => {`,
` switch (kcContext.themeType) {`,
hasLoginTheme &&
` case "login": return <KcLoginPage kcContext={kcContext} />;`,
hasAccountTheme &&
` case "account": return <KcAccountPage kcContext={kcContext} />;`,
hasAdminTheme &&
` case "admin": return <KcAdminPage kcContext={kcContext} />;`,
` }`,
` })()}`,
` </Suspense>`,
` );`,
`}`,
``
]
.filter(item => typeof item === "string")
.join("\n");
const hash = crypto.createHash("sha256").update(newContent).digest("hex");
skip_if_no_changes: {
if (!(await existsAsync(filePath))) {
break skip_if_no_changes;
}
const currentContent = (await fs.readFile(filePath)).toString("utf8");
if (!currentContent.includes(hash)) {
break skip_if_no_changes;
}
return;
}
newContent = [
`// This file is auto-generated by the \`update-kc-gen\` command. Do not edit it manually.`,
`// Hash: ${hash}`,
``,
newContent
].join("\n");
format: {
if (!(await getIsPrettierAvailable())) {
break format;
}
newContent = await runPrettier({
filePath,
sourceCode: newContent
});
}
await fs.writeFile(filePath, Buffer.from(newContent, "utf8"));
delete_legacy_file: {
const legacyFilePath = filePath.replace(/tsx$/, "ts");
if (!(await existsAsync(legacyFilePath))) {
break delete_legacy_file;
}
await fs.unlink(legacyFilePath);
}
await generateKcGenTs({ buildContext });
}

View File

@ -1,7 +1,8 @@
import type { Param0 } from "tsafe";
import { type CxArg, clsx_withTransform } from "../tools/clsx_withTransform";
import { clsx } from "../tools/clsx";
import { assert, is } from "tsafe/assert";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
export function createGetKcClsx<ClassKey extends string>(params: {
defaultClasses: Record<ClassKey, string | undefined>;

View File

@ -33,7 +33,7 @@ export type KcContext =
| KcContext.LoginResetPassword
| KcContext.LoginVerifyEmail
| KcContext.Terms
| KcContext.LoginOauth2DeviceVerifyUserCode
| KcContext.LoginDeviceVerifyUserCode
| KcContext.LoginOauthGrant
| KcContext.LoginOtp
| KcContext.LoginUsername
@ -94,7 +94,6 @@ export declare namespace KcContext {
languageTag: string;
}[];
currentLanguageTag: string;
rtl?: boolean;
};
auth?: {
showUsername?: boolean;
@ -102,7 +101,7 @@ export declare namespace KcContext {
showTryAnotherWayLink?: boolean;
attemptedUsername?: string;
};
scripts?: string[];
scripts: string[];
message?: {
type: "success" | "warning" | "error" | "info";
summary: string;
@ -277,7 +276,7 @@ export declare namespace KcContext {
__localizationRealmOverridesTermsText?: string;
};
export type LoginOauth2DeviceVerifyUserCode = Common & {
export type LoginDeviceVerifyUserCode = Common & {
pageId: "login-oauth2-device-verify-user-code.ftl";
url: {
oauth2DeviceVerificationAction: string;

View File

@ -290,7 +290,7 @@ export const kcContextMocks = [
...kcContextCommonMock,
pageId: "terms.ftl"
}),
id<KcContext.LoginOauth2DeviceVerifyUserCode>({
id<KcContext.LoginDeviceVerifyUserCode>({
...kcContextCommonMock,
pageId: "login-oauth2-device-verify-user-code.ftl",
url: loginUrl

View File

@ -10,7 +10,7 @@ export type KcContextLike = {
resourcesPath: string;
ssoLoginInOtherTabsUrl: string;
};
scripts?: string[];
scripts: string[];
};
assert<keyof KcContextLike extends keyof KcContext ? true : false>();
@ -45,12 +45,10 @@ export function useInitialize(params: {
type: "module",
src: `${url.resourcesPath}/js/menu-button-links.js`
},
...(scripts === undefined
? []
: scripts.map(src => ({
type: "text/javascript" as const,
src
}))),
...scripts.map(src => ({
type: "text/javascript" as const,
src
})),
{
type: "module",
textContent: `

View File

@ -11,6 +11,7 @@ export type TemplateProps<KcContext, I18n> = {
displayInfo?: boolean;
displayMessage?: boolean;
displayRequiredFields?: boolean;
showAnotherWayIfPresent?: boolean;
headerNode: ReactNode;
socialProvidersNode?: ReactNode;
infoNode?: ReactNode;

View File

@ -1,11 +1,11 @@
import "keycloakify/tools/Object.fromEntries";
import { assert, is } from "tsafe/assert";
import { extractLastParenthesisContent } from "keycloakify/tools/extractLastParenthesisContent";
import { assert } from "tsafe/assert";
import messages_defaultSet_fallbackLanguage from "../messages_defaultSet/en";
import { fetchMessages_defaultSet } from "../messages_defaultSet";
import type { KcContext } from "../../KcContext";
import { FALLBACK_LANGUAGE_TAG } from "keycloakify/bin/shared/constants";
import { id } from "tsafe/id";
import { is } from "tsafe/is";
import { Reflect } from "tsafe/Reflect";
import {
type LanguageTag as LanguageTag_defaultSet,
@ -16,13 +16,9 @@ import type { GenericI18n_noJsx } from "./GenericI18n_noJsx";
export type KcContextLike = {
themeName: string;
realm: {
internationalizationEnabled: boolean;
};
locale?: {
currentLanguageTag: string;
supported: { languageTag: string; url: string; label: string }[];
rtl?: boolean;
};
"x-keycloakify": {
messages: Record<string, string>;
@ -94,56 +90,11 @@ export function createGetI18n<
return cachedResult;
}
const kcContextLocale = params.kcContext.realm.internationalizationEnabled ? params.kcContext.locale : undefined;
{
const currentLanguageTag = kcContextLocale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG;
const currentLanguageTag = kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG;
const html = document.querySelector("html");
assert(html !== null);
html.lang = currentLanguageTag;
const isRtl = (() => {
const { rtl } = kcContextLocale ?? {};
if (rtl !== undefined) {
return rtl;
}
return [
/* spell-checker: disable */
// Common RTL languages
"ar", // Arabic
"fa", // Persian (Farsi)
"he", // Hebrew
"ur", // Urdu
"ps", // Pashto
"syr", // Syriac
"dv", // Divehi (Maldivian)
"ku", // Kurdish (Sorani)
"ug", // Uighur
"az", // Azerbaijani (Arabic script)
"sd", // Sindhi
// Less common RTL languages
"yi", // Yiddish
"ha", // Hausa (when written in Arabic script)
"ks", // Kashmiri (written in the Perso-Arabic script)
"bal", // Balochi (when written in Arabic script)
"khw", // Khowar (Chitrali)
"brh", // Brahui (when written in Arabic script)
"tmh", // Tamashek (some dialects use Arabic script)
"bgn", // Western Balochi
"arc", // Aramaic
"sam", // Samaritan Aramaic
"prd", // Parsi-Dari (a dialect of Persian)
"huz", // Hazaragi (a dialect of Persian)
"gbz", // Zaza (written in Arabic script in some areas)
"urj" // Urdu in Romanized script (not always RTL, but to account for edge cases)
/* spell-checker: enable */
].includes(currentLanguageTag);
})();
html.dir = isRtl ? "rtl" : "ltr";
}
const getLanguageLabel = (languageTag: LanguageTag) => {
@ -159,20 +110,22 @@ export function createGetI18n<
}
from_server: {
if (kcContextLocale === undefined) {
if (kcContext.locale === undefined) {
break from_server;
}
const supportedEntry = kcContextLocale.supported.find(entry => entry.languageTag === languageTag);
const supportedEntry = kcContext.locale.supported.find(entry => entry.languageTag === languageTag);
if (supportedEntry === undefined) {
break from_server;
}
const lastParenthesisContent = extractLastParenthesisContent(supportedEntry.label);
// cspell: disable-next-line
// from "Espagnol (Español)" we want to extract "Español"
const match = supportedEntry.label.match(/[^(]+\(([^)]+)\)/);
if (lastParenthesisContent !== undefined) {
return lastParenthesisContent;
if (match !== null) {
return match[1];
}
return supportedEntry.label;
@ -183,7 +136,7 @@ export function createGetI18n<
};
const currentLanguage: I18n["currentLanguage"] = (() => {
const languageTag = id<string>(kcContextLocale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG) as LanguageTag;
const languageTag = id<string>(kcContext.locale?.currentLanguageTag ?? FALLBACK_LANGUAGE_TAG) as LanguageTag;
return {
languageTag,
@ -194,8 +147,8 @@ export function createGetI18n<
const enabledLanguages: I18n["enabledLanguages"] = (() => {
const enabledLanguages: I18n["enabledLanguages"] = [];
if (kcContextLocale !== undefined) {
for (const entry of kcContextLocale.supported ?? []) {
if (kcContext.locale !== undefined) {
for (const entry of kcContext.locale.supported ?? []) {
const languageTag = id<string>(entry.languageTag) as LanguageTag;
enabledLanguages.push({

View File

@ -47,25 +47,11 @@ export function createUseI18n<
function renderHtmlString(params: { htmlString: string; msgKey: string }): JSX.Element {
const { htmlString, msgKey } = params;
const htmlString_sanitized = kcSanitize(htmlString);
const Element = (() => {
if (htmlString_sanitized.includes("<") && htmlString_sanitized.includes(">")) {
for (const tagName of ["div", "section", "article", "ul", "ol"]) {
if (htmlString_sanitized.includes(`<${tagName}`)) {
return "div";
}
}
}
return "span";
})();
return (
<Element
<div
data-kc-msg={msgKey}
dangerouslySetInnerHTML={{
__html: htmlString_sanitized
__html: kcSanitize(htmlString)
}}
/>
);
@ -97,7 +83,7 @@ export function createUseI18n<
})();
add_style: {
const attributeName = "data-kc-msg";
const attributeName = "data-kc-i18n";
// Check if already exists in head
if (document.querySelector(`style[${attributeName}]`) !== null) {
@ -106,8 +92,7 @@ export function createUseI18n<
const styleElement = document.createElement("style");
styleElement.attributes.setNamedItem(document.createAttribute(attributeName));
styleElement.textContent = `div[${attributeName}] { display: inline-block; }`;
document.head.prepend(styleElement);
(styleElement.textContent = `[data-kc-msg] { display: inline-block; }`), document.head.prepend(styleElement);
}
const { getI18n } = createGetI18n({ extraLanguageTranslations, messagesByLanguageTag_themeDefined });

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
export * from "./getUserProfileApi";

View File

@ -1,109 +0,0 @@
import { assert } from "keycloakify/tools/assert";
let cleanup: (() => void) | undefined;
const handledElements = new WeakSet<HTMLElement>();
const KC_NUMBER_UNFORMAT = "kcNumberUnFormat";
const SELECTOR = `input[data-${KC_NUMBER_UNFORMAT}]`;
export function unFormatNumberOnSubmit() {
cleanup?.();
const handleElement = (element: HTMLInputElement) => {
if (handledElements.has(element)) {
return;
}
const form = element.closest("form");
if (form === null) {
return;
}
form.addEventListener("submit", () => {
const rawFormat = element.getAttribute(`data-${KC_NUMBER_UNFORMAT}`);
if (rawFormat) {
element.value = formatNumber(element.value, rawFormat);
}
});
handledElements.add(element);
};
document.querySelectorAll(SELECTOR).forEach(element => {
assert(element instanceof HTMLInputElement);
handleElement(element);
});
const observer = new MutationObserver(mutationsList => {
for (const mutation of mutationsList) {
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = (node as HTMLElement).querySelector(SELECTOR);
if (element !== null) {
assert(element instanceof HTMLInputElement);
handleElement(element);
}
}
});
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
cleanup = () => observer.disconnect();
}
// NOTE: Keycloak code
const formatNumber = (input: string, format: string) => {
if (!input) {
return "";
}
// array holding the patterns for the number of expected digits in each part
const digitPattern = format.match(/{\d+}/g);
if (!digitPattern) {
return "";
}
// calculate the maximum size of the given pattern based on the sum of the expected digits
const maxSize = digitPattern.reduce(
(total, p) => total + parseInt(p.replace("{", "").replace("}", "")),
0
);
// keep only digits
let rawValue = input.replace(/\D+/g, "");
// make sure the value is a number
//@ts-expect-error
if (parseInt(rawValue) != rawValue) {
return "";
}
// make sure the number of digits does not exceed the maximum size
if (rawValue.length > maxSize) {
rawValue = rawValue.substring(0, maxSize);
}
// build the regex based based on the expected digits in each part
const formatter = digitPattern.reduce((result, p) => result + `(\\d${p})`, "^");
// if the current digits match the pattern we have each group of digits in an array
let digits = new RegExp(formatter).exec(rawValue);
// no match, return the raw value without any format
if (!digits) {
return input;
}
let result = format;
// finally format the current digits accordingly to the given format
for (let i = 0; i < digitPattern.length; i++) {
result = result.replace(digitPattern[i], digits[i + 1]);
}
return result;
};

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,6 @@ import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
export default function Code(props: PageProps<Extract<KcContext, { pageId: "code.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -31,14 +30,7 @@ export default function Code(props: PageProps<Extract<KcContext, { pageId: "code
<input id="code" className={kcClsx("kcTextareaClass")} defaultValue={code.code} />
</>
) : (
code.error && (
<p
id="error"
dangerouslySetInnerHTML={{
__html: kcSanitize(code.error)
}}
/>
)
<p id="error">{code.error}</p>
)}
</div>
</Template>

View File

@ -52,26 +52,28 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
</li>
<li>
<p>{msg("loginTotpManualStep3")}</p>
<ul>
<li id="kc-totp-type">
{msg("loginTotpType")}: {msg(`loginTotp.${totp.policy.type}`)}
</li>
<li id="kc-totp-algorithm">
{msg("loginTotpAlgorithm")}: {totp.policy.getAlgorithmKey()}
</li>
<li id="kc-totp-digits">
{msg("loginTotpDigits")}: {totp.policy.digits}
</li>
{totp.policy.type === "totp" ? (
<li id="kc-totp-period">
{msg("loginTotpInterval")}: {totp.policy.period}
<p>
<ul>
<li id="kc-totp-type">
{msg("loginTotpType")}: {msg(`loginTotp.${totp.policy.type}`)}
</li>
) : (
<li id="kc-totp-counter">
{msg("loginTotpCounter")}: {totp.policy.initialCounter}
<li id="kc-totp-algorithm">
{msg("loginTotpAlgorithm")}: {totp.policy.getAlgorithmKey()}
</li>
)}
</ul>
<li id="kc-totp-digits">
{msg("loginTotpDigits")}: {totp.policy.digits}
</li>
{totp.policy.type === "totp" ? (
<li id="kc-totp-period">
{msg("loginTotpInterval")}: {totp.policy.period}
</li>
) : (
<li id="kc-totp-counter">
{msg("loginTotpCounter")}: {totp.policy.initialCounter}
</li>
)}
</ul>
</p>
</li>
</>
) : (

View File

@ -115,61 +115,62 @@ export default function LoginPasskeysConditionalAuthenticate(
</div>
</>
)}
<div id="kc-form">
<div id="kc-form-wrapper">
{realm.password && (
<form
id="kc-form-passkey"
action={url.loginAction}
method="post"
style={{ display: "none" }}
onSubmit={event => {
try {
// @ts-expect-error
event.target.login.disabled = true;
} catch {}
return true;
}}
>
{!usernameHidden && (
<div className={kcClsx("kcFormGroupClass")}>
<label htmlFor="username" className={kcClsx("kcLabelClass")}>
{msg("passkey-autofill-select")}
</label>
<input
tabIndex={1}
id="username"
aria-invalid={messagesPerField.existsError("username")}
className={kcClsx("kcInputClass")}
name="username"
defaultValue={login.username ?? ""}
//autoComplete="username webauthn"
type="text"
autoFocus
autoComplete="off"
/>
{messagesPerField.existsError("username") && (
<span id="input-error-username" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("username")}
</span>
)}
</div>
)}
</form>
)}
<div id="kc-form-passkey-button" className={kcClsx("kcFormButtonsClass")} style={{ display: "none" }}>
<input
id={authButtonId}
type="button"
autoFocus
value={msgStr("passkey-doAuthenticate")}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonBlockClass", "kcButtonLargeClass")}
/>
</div>
</div>
</div>
</>
)}
<div id="kc-form">
<div id="kc-form-wrapper">
{realm.password && (
<form
id="kc-form-login"
action={url.loginAction}
method="post"
style={{ display: "none" }}
onSubmit={event => {
try {
// @ts-expect-error
event.target.login.disabled = true;
} catch {}
return true;
}}
>
{!usernameHidden && (
<div className={kcClsx("kcFormGroupClass")}>
<label htmlFor="username" className={kcClsx("kcLabelClass")}>
{msg("passkey-autofill-select")}
</label>
<input
tabIndex={1}
id="username"
aria-invalid={messagesPerField.existsError("username")}
className={kcClsx("kcInputClass")}
name="username"
defaultValue={login.username ?? ""}
autoComplete="username webauthn"
type="text"
autoFocus
/>
{messagesPerField.existsError("username") && (
<span id="input-error-username" className={kcClsx("kcInputErrorMessageClass")} aria-live="polite">
{messagesPerField.get("username")}
</span>
)}
</div>
)}
</form>
)}
<div id="kc-form-passkey-button" className={kcClsx("kcFormButtonsClass")} style={{ display: "none" }}>
<input
id={authButtonId}
type="button"
autoFocus
value={msgStr("passkey-doAuthenticate")}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonBlockClass", "kcButtonLargeClass")}
/>
</div>
</div>
</div>
</div>
</Template>
);

View File

@ -2,7 +2,6 @@ import { useEffect } from "react";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { assert } from "keycloakify/tools/assert";
import { KcContext } from "keycloakify/login/KcContext/KcContext";
import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom";
type KcContextLike = {
url: {
@ -68,12 +67,6 @@ export function useScript(params: { authButtonId: string; kcContext: KcContextLi
return;
}
(async () => {
await waitForElementMountedOnDom({
elementId: authButtonId
});
insertScriptTags();
})();
insertScriptTags();
}, [isFetchingTranslations]);
}

View File

@ -70,9 +70,9 @@ export default function LoginRecoveryAuthnCodeConfig(props: PageProps<Extract<Kc
type="checkbox"
id="kcRecoveryCodesConfirmationCheck"
name="kcRecoveryCodesConfirmationCheck"
onChange={event => {
//@ts-expect-error: This is inherited from the original code
document.getElementById("saveRecoveryAuthnCodesBtn").disabled = !event.target.checked;
onChange={function () {
//@ts-expect-error: This is code from the original theme, we trust it.
document.getElementById("saveRecoveryAuthnCodesBtn").disabled = !this.checked;
}}
/>
<label htmlFor="kcRecoveryCodesConfirmationCheck">{msg("recovery-codes-confirmation-message")}</label>

View File

@ -1,6 +1,5 @@
import { useEffect } from "react";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom";
type I18nLike = {
msgStr: (key: "recovery-codes-download-file-header" | "recovery-codes-download-file-description" | "recovery-codes-download-file-date") => string;
@ -138,12 +137,6 @@ export function useScript(params: { olRecoveryCodesListId: string; i18n: I18nLik
return;
}
(async () => {
await waitForElementMountedOnDom({
elementId: olRecoveryCodesListId
});
insertScriptTags();
})();
insertScriptTags();
}, [isFetchingTranslations]);
}

View File

@ -2,7 +2,6 @@ import { useEffect } from "react";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { assert } from "keycloakify/tools/assert";
import { KcContext } from "keycloakify/login/KcContext/KcContext";
import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom";
type KcContextLike = {
url: {
@ -60,12 +59,6 @@ export function useScript(params: { authButtonId: string; kcContext: KcContextLi
return;
}
(async () => {
await waitForElementMountedOnDom({
elementId: authButtonId
});
insertScriptTags();
})();
insertScriptTags();
}, [isFetchingTranslations]);
}

View File

@ -2,7 +2,6 @@ import { useEffect } from "react";
import { useInsertScriptTags } from "keycloakify/tools/useInsertScriptTags";
import { assert } from "keycloakify/tools/assert";
import { KcContext } from "keycloakify/login/KcContext/KcContext";
import { waitForElementMountedOnDom } from "keycloakify/tools/waitForElementMountedOnDom";
type KcContextLike = {
url: {
@ -89,12 +88,6 @@ export function useScript(params: { authButtonId: string; kcContext: KcContextLi
return;
}
(async () => {
await waitForElementMountedOnDom({
elementId: authButtonId
});
insertScriptTags();
})();
insertScriptTags();
}, [isFetchingTranslations]);
}

View File

@ -1,4 +1,5 @@
import { assert, is } from "tsafe/assert";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
export type StatefulObservable<T> = {
current: T;

View File

@ -1,4 +1,5 @@
import { assert, is } from "tsafe/assert";
import { assert } from "tsafe/assert";
import { is } from "tsafe/is";
import { structuredCloneButFunctions } from "./structuredCloneButFunctions";
/** NOTE: Array a copied over, not merged. */

View File

@ -1,43 +0,0 @@
/**
* "Hello (world)" => "world"
* "Hello (world) (foo)" => "foo"
* "Hello (world (foo))" => "world (foo)"
*/
export function extractLastParenthesisContent(str: string): string | undefined {
const chars: string[] = [];
for (const char of str) {
chars.push(char);
}
const extractedChars: string[] = [];
let openingCount = 0;
loop_through_char: for (let i = chars.length - 1; i >= 0; i--) {
const char = chars[i];
if (i === chars.length - 1) {
if (char !== ")") {
return undefined;
}
continue;
}
switch (char) {
case ")":
openingCount++;
break;
case "(":
if (openingCount === 0) {
return extractedChars.join("");
}
openingCount--;
break;
}
extractedChars.unshift(char);
}
return undefined;
}

View File

@ -1,30 +0,0 @@
export async function waitForElementMountedOnDom(params: {
elementId: string;
}): Promise<void> {
const { elementId } = params;
const getElement = () => document.getElementById(elementId);
const element = getElement();
if (element === null) {
let prElementPresentInTheDom_resolve: () => void;
const prElementPresentInTheDom = new Promise<void>(
resolve => (prElementPresentInTheDom_resolve = resolve)
);
// Observe the dom for the element to be added
const observer = new MutationObserver(() => {
const element = getElement();
if (element === null) {
return;
}
observer.disconnect();
prElementPresentInTheDom_resolve();
});
observer.observe(document.body, { childList: true, subtree: true });
await prElementPresentInTheDom;
}
}

View File

@ -6,7 +6,7 @@ import {
} from "../bin/shared/constants";
import { id } from "tsafe/id";
import { rm } from "../bin/tools/fs.rm";
import { command as copyKeycloakResourcesToPublicCommand } from "../bin/copy-keycloak-resources-to-public";
import { copyKeycloakResourcesToPublic } from "../bin/shared/copyKeycloakResourcesToPublic";
import { assert } from "tsafe/assert";
import {
getBuildContext,
@ -15,8 +15,7 @@ import {
type ResolvedViteConfig
} from "../bin/shared/buildContext";
import MagicString from "magic-string";
import { command as updateKcGenCommand } from "../bin/update-kc-gen";
import { replaceAll } from "../bin/tools/String.prototype.replaceAll";
import { generateKcGenTs } from "../bin/shared/generateKcGenTs";
export namespace keycloakify {
export type Params = BuildOptions & {
@ -123,16 +122,15 @@ export function keycloakify(params: keycloakify.Params) {
}
const buildContext = getBuildContext({
projectDirPath
cliCommandOptions: {
projectDirPath
}
});
await copyKeycloakResourcesToPublicCommand({ buildContext });
await updateKcGenCommand({ buildContext });
copyKeycloakResourcesToPublic({ buildContext }),
await generateKcGenTs({ buildContext });
},
transform: (code, id) => {
id = replaceAll(id, "/", pathSep);
assert(command !== undefined);
assert(shouldGenerateSourcemap !== undefined);

View File

@ -16,105 +16,3 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* UsernameNotEditable:
* - Purpose: Test the scenario where the username field is not editable.
* - Scenario: The component renders, but the username field is disabled.
* - Key Aspect: Ensures that the `editUsernameAllowed` condition is respected and the username field is read-only.
*/
export const UsernameNotEditable: Story = {
render: () => (
<KcPageStory
kcContext={{
account: {
username: "john_doe",
email: "john.doe@gmail.com",
firstName: "John",
lastName: "Doe"
},
realm: {
registrationEmailAsUsername: false,
editUsernameAllowed: false
},
referrer: {
url: "/home"
},
url: {
accountUrl: "/account"
},
messagesPerField: {
printIfExists: () => ""
},
stateChecker: "state-checker"
}}
/>
)
};
/**
* WithValidationErrors:
* - Purpose: Test the form when there are validation errors.
* - Scenario: The component renders with error messages for invalid input in the fields.
* - Key Aspect: Ensures that error messages are properly displayed and the user can correct their inputs.
*/
export const WithValidationErrors: Story = {
render: () => (
<KcPageStory
kcContext={{
account: {
username: "john_doe",
email: "",
firstName: "",
lastName: "Doe"
},
realm: {
registrationEmailAsUsername: false,
editUsernameAllowed: true
},
referrer: {
url: "/home"
},
url: {
accountUrl: "/account"
},
messagesPerField: {
printIfExists: field => (field === "email" || field === "firstName" ? "has-error" : "")
},
stateChecker: "state-checker"
}}
/>
)
};
/**
* EmailAsUsername:
* - Purpose: Test the form where email is used as the username.
* - Scenario: The component renders without a separate username field, and the email field is treated as the username.
* - Key Aspect: Ensures the form functions correctly when `registrationEmailAsUsername` is enabled.
*/
export const EmailAsUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
account: {
email: "john.doe@gmail.com",
firstName: "John",
lastName: "Doe"
},
realm: {
registrationEmailAsUsername: true
},
referrer: {
url: "/home"
},
url: {
accountUrl: "/account"
},
messagesPerField: {
printIfExists: () => ""
},
stateChecker: "state-checker"
}}
/>
)
};

View File

@ -78,151 +78,3 @@ export const Default: Story = {
/>
)
};
// No Available Roles or Grants Scenario
export const NoAvailableRolesOrGrants: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "applications.ftl",
applications: {
applications: [
{
realmRolesAvailable: [],
resourceRolesAvailable: {},
additionalGrants: [],
clientScopesGranted: [],
effectiveUrl: "#",
client: {
clientId: "application1",
name: "Application 1",
consentRequired: true
}
}
]
}
}}
/>
)
};
// Consent Not Required Scenario
export const ConsentNotRequired: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "applications.ftl",
applications: {
applications: [
{
realmRolesAvailable: [],
resourceRolesAvailable: {},
additionalGrants: [],
clientScopesGranted: [],
effectiveUrl: "#",
client: {
clientId: "application1",
name: "Application 1",
consentRequired: false // No consent required
}
}
]
}
}}
/>
)
};
// No Roles Available but Consent Required Scenario
export const NoRolesButConsentRequired: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "applications.ftl",
applications: {
applications: [
{
realmRolesAvailable: [],
resourceRolesAvailable: {},
additionalGrants: [],
clientScopesGranted: ["scope1", "scope2"], // Consent required but no roles
effectiveUrl: "#",
client: {
clientId: "application1",
name: "Application 1",
consentRequired: true
}
}
]
}
}}
/>
)
};
// Only Resource Roles Available Scenario
export const OnlyResourceRolesAvailable: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "applications.ftl",
applications: {
applications: [
{
realmRolesAvailable: [], // No realm roles
resourceRolesAvailable: {
resource1: [
{
roleName: "Resource Role Name 1",
roleDescription: "Resource role 1 description",
clientName: "Client Name 1",
clientId: "client1"
}
]
},
additionalGrants: [],
clientScopesGranted: [],
effectiveUrl: "#",
client: {
clientId: "application1",
name: "Application 1",
consentRequired: true
}
}
]
}
}}
/>
)
};
// No Additional Grants Scenario
export const NoAdditionalGrants: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "applications.ftl",
applications: {
applications: [
{
realmRolesAvailable: [
{
name: "Realm Role Name 1"
}
],
resourceRolesAvailable: {},
additionalGrants: [], // No additional grants
clientScopesGranted: [],
effectiveUrl: "#",
client: {
clientId: "application1",
name: "Application 1",
consentRequired: true
}
}
]
}
}}
/>
)
};

View File

@ -36,61 +36,3 @@ export const NotConnected: Story = {
/>
)
};
/**
* RemoveLinkNotPossible:
* - Federated identities are connected, but the user cannot remove them due to restrictions.
*/
export const RemoveLinkNotPossible: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "federatedIdentity.ftl",
federatedIdentity: {
identities: [
{
providerId: "google",
displayName: "Google",
userName: "john.doe@gmail.com",
connected: true
}
],
removeLinkPossible: false
},
stateChecker: "1234",
url: {
socialUrl: "/social"
}
}}
/>
)
};
/**
* AddLinkForUnconnectedIdentity:
* - The user has an identity that is not connected and can add it.
*/
export const AddLinkForUnconnectedIdentity: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "federatedIdentity.ftl",
federatedIdentity: {
identities: [
{
providerId: "github",
displayName: "GitHub",
userName: "",
connected: false
}
],
removeLinkPossible: true
},
stateChecker: "1234",
url: {
socialUrl: "/social"
}
}}
/>
)
};

View File

@ -355,107 +355,3 @@ export const Default: Story = {
/>
)
};
export const LogsMissingDetails: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "log.ftl",
log: {
events: [
{
date: "2024-04-26T12:29:08Z",
ipAddress: "127.0.0.1",
client: "",
details: [],
event: "login"
}
]
}
}}
/>
)
};
export const SingleLogEntry: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "log.ftl",
log: {
events: [
{
date: "2024-04-26T12:29:08Z",
ipAddress: "127.0.0.1",
client: "keycloakify-frontend",
details: [
{ key: "auth_method", value: "openid-connect" },
{ key: "username", value: "john.doe" }
],
event: "login"
}
]
}
}}
/>
)
};
export const LogsWithLongDetails: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "log.ftl",
log: {
events: [
{
date: "2024-04-26T12:29:08Z",
ipAddress: "127.0.0.1",
client: "keycloakify-frontend",
details: [
{ key: "auth_method", value: "openid-connect" },
{ key: "username", value: "john.doe" },
{ key: "session_duration", value: "2 hours 30 minutes 45 seconds" },
{ key: "location", value: "Windsor, Ontario, Canada" },
{ key: "user_agent", value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" }
],
event: "login"
}
]
}
}}
/>
)
};
export const EmptyClientField: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "log.ftl",
log: {
events: [
{
date: "2024-04-26T12:29:08Z",
ipAddress: "127.0.0.1",
client: "", // Empty client field
details: [
{ key: "auth_method", value: "openid-connect" },
{ key: "username", value: "john.doe" }
],
event: "login"
}
]
}
}}
/>
)
};
export const NoLogsAvailable: Story = {
render: () => (
<KcPageStory
kcContext={{
pageId: "log.ftl",
log: {
events: [] // No log events
}
}}
/>
)
};

View File

@ -26,79 +26,3 @@ export const WithMessage: Story = {
/>
)
};
/**
* FirstTimePasswordSetup:
* - Purpose: Tests the page when no password is set (e.g., first login).
* - Scenario: This renders the form without the current password field.
* - Key Aspect: Ensures the page only displays fields for setting a new password.
*/
export const FirstTimePasswordSetup: Story = {
render: () => (
<KcPageStory
kcContext={{
account: {
username: "john_doe"
},
password: {
passwordSet: false
},
url: {
passwordUrl: "/password"
},
stateChecker: "state-checker"
}}
/>
)
};
/**
* IncorrectCurrentPassword:
* - Purpose: Simulates validation error when the current password is incorrect.
* - Scenario: This renders the page with an error message indicating the current password is incorrect.
* - Key Aspect: Validates that an error message is correctly displayed for the current password input.
*/
export const IncorrectCurrentPassword: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { type: "error", summary: "Incorrect current password." },
account: {
username: "john_doe"
},
password: {
passwordSet: true
},
url: {
passwordUrl: "/password"
},
stateChecker: "state-checker"
}}
/>
)
};
/**
* SubmissionSuccessWithRedirect:
* - Purpose: Simulates a successful form submission with a redirect or success message.
* - Scenario: After successfully changing the password, a success message and redirect behavior are triggered.
* - Key Aspect: Verifies the handling of successful submissions.
*/
export const SubmissionSuccessWithRedirect: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { type: "success", summary: "Password successfully changed." },
account: {
username: "john_doe"
},
password: {
passwordSet: true
},
url: {
passwordUrl: "/password"
},
stateChecker: "state-checker"
}}
/>
)
};

View File

@ -57,97 +57,3 @@ export const WithError: Story = {
/>
)
};
/**
* No active sessions scenario:
* - Simulates the scenario where no sessions are active for the user.
*/
export const NoActiveSessions: Story = {
render: () => (
<KcPageStory
kcContext={{
sessions: {
sessions: []
},
stateChecker: "randomStateCheckerValue"
}}
/>
)
};
/**
* Single session scenario:
* - Displays only one active session with session details.
*/
export const SingleSession: Story = {
render: () => (
<KcPageStory
kcContext={{
sessions: {
sessions: [
{
expires: "2024-04-26T18:14:19Z",
clients: ["account"],
ipAddress: "172.20.0.1",
started: "2024-04-26T08:14:19Z",
lastAccess: "2024-04-26T08:30:54Z",
id: "single-session-id"
}
]
},
stateChecker: "anotherStateChecker"
}}
/>
)
};
/**
* Multiple clients per session scenario:
* - Displays sessions where each session has multiple associated clients.
*/
export const MultipleClientsSession: Story = {
render: () => (
<KcPageStory
kcContext={{
sessions: {
sessions: [
{
expires: "2024-04-26T18:14:19Z",
clients: ["account", "admin-console", "another-client"],
ipAddress: "172.20.0.1",
started: "2024-04-26T08:14:19Z",
lastAccess: "2024-04-26T08:30:54Z",
id: "multiple-clients-session"
}
]
},
stateChecker: "multiClientsStateChecker"
}}
/>
)
};
/**
* Session without client details scenario:
* - Simulates a session where no client information is provided.
*/
export const SessionWithoutClients: Story = {
render: () => (
<KcPageStory
kcContext={{
sessions: {
sessions: [
{
expires: "2024-04-26T18:14:19Z",
clients: [], // No clients information
ipAddress: "172.20.0.1",
started: "2024-04-26T08:14:19Z",
lastAccess: "2024-04-26T08:30:54Z",
id: "no-clients-session"
}
]
},
stateChecker: "noClientsStateChecker"
}}
/>
)
};

View File

@ -180,64 +180,3 @@ export const MoreThanOneTotpProviders: Story = {
/>
)
};
// TOTP Enabled but No Existing OTP Credentials
export const TotpEnabledNoOtpCredentials: Story = {
render: () => (
<KcPageStory
kcContext={{
totp: {
enabled: true,
totpSecretEncoded: "HE4W MSTC OBKU CY2M",
otpCredentials: [] // No OTP Credentials
},
stateChecker: "stateChecker123",
url: {
totpUrl: "http://localhost:8080/realms/myrealm/account/totp"
}
}}
/>
)
};
// Manual Mode TOTP without Scanning
export const ManualModeTotp: Story = {
render: () => (
<KcPageStory
kcContext={{
mode: "manual", // Manual mode
totp: {
enabled: false,
totpSecretEncoded: "HE4W MSTC OBKU CY2M",
otpCredentials: []
},
stateChecker: "stateChecker123",
url: {
totpUrl: "http://localhost:8080/realms/myrealm/account/totp"
}
}}
/>
)
};
// Multiple OTP Devices Scenario
export const MultipleOtpDevices: Story = {
render: () => (
<KcPageStory
kcContext={{
totp: {
enabled: true,
totpSecretEncoded: "G55E MZKC JFUD",
otpCredentials: [
{ id: "1", userLabel: "Phone 1" },
{ id: "2", userLabel: "Tablet" }
]
},
stateChecker: "stateChecker123",
url: {
totpUrl: "http://localhost:8080/realms/myrealm/account/totp"
}
}}
/>
)
};

View File

@ -16,42 +16,3 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithErrorCode: Story = {
render: () => (
<KcPageStory
kcContext={{
code: {
success: false,
error: "Failed to generate code"
}
}}
/>
)
};
export const WithFrenchLanguage: Story = {
render: () => (
<KcPageStory
kcContext={{
locale: {
currentLanguageTag: "fr"
},
code: {
success: true,
code: "XYZ789"
}
}}
/>
)
};
export const WithHtmlErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
code: {
success: false,
error: "Something went wrong. <a href='https://example.com'>Try again</a>"
}
}}
/>
)
};

View File

@ -16,33 +16,3 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithAIAFlow: Story = {
render: () => (
<KcPageStory
kcContext={{
triggered_from_aia: true,
url: { loginAction: "/login-action" }
}}
/>
)
};
export const WithoutAIAFlow: Story = {
render: () => (
<KcPageStory
kcContext={{
triggered_from_aia: false,
url: { loginAction: "/login-action" }
}}
/>
)
};
export const WithCustomButtonStyle: Story = {
render: () => (
<KcPageStory
kcContext={{
triggered_from_aia: true,
url: { loginAction: "/login-action" }
}}
/>
)
};

View File

@ -16,13 +16,3 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithCustomCredentialLabel: Story = {
render: () => (
<KcPageStory
kcContext={{
credentialLabel: "Test Credential",
url: { loginAction: "/login-action" }
}}
/>
)
};

View File

@ -26,38 +26,3 @@ export const WithAnotherMessage: Story = {
/>
)
};
export const WithHtmlErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: {
summary: "<strong>Error:</strong> Something went wrong. <a href='https://example.com'>Go back</a>"
}
}}
/>
)
};
export const FrenchError: Story = {
render: () => (
<KcPageStory
kcContext={{
locale: { currentLanguageTag: "fr" },
message: { summary: "Une erreur s'est produite" }
}}
/>
)
};
export const WithSkipLink: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { summary: "An error occurred" },
skipLink: true,
client: {
baseUrl: "https://example.com"
}
}}
/>
)
};

View File

@ -16,14 +16,3 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithoutRedirectUrl: Story = {
render: () => (
<KcPageStory
kcContext={{
logout: {
clients: []
}
}}
/>
)
};

View File

@ -16,47 +16,3 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithFormValidationErrors: Story = {
render: () => (
<KcPageStory
kcContext={{
messagesPerField: {
existsError: (fieldName: string) => ["email", "firstName"].includes(fieldName),
get: (fieldName: string) => {
if (fieldName === "email") return "Invalid email format.";
if (fieldName === "firstName") return "First name is required.";
}
}
}}
/>
)
};
export const WithReadOnlyFields: Story = {
render: () => (
<KcPageStory
kcContext={{
profile: {
attributesByName: {
email: { value: "jane.doe@example.com", readOnly: true },
firstName: { value: "Jane", readOnly: false }
}
}
}}
/>
)
};
export const WithPrefilledFormFields: Story = {
render: () => (
<KcPageStory
kcContext={{
profile: {
attributesByName: {
firstName: { value: "Jane" },
lastName: { value: "Doe" },
email: { value: "jane.doe@example.com" }
}
}
}}
/>
)
};

View File

@ -17,7 +17,6 @@ export const Default: Story = {
render: () => (
<KcPageStory
kcContext={{
messageHeader: "Message header",
message: {
summary: "Server info message"
}
@ -30,7 +29,6 @@ export const WithLinkBack: Story = {
render: () => (
<KcPageStory
kcContext={{
messageHeader: "Message header",
message: {
summary: "Server message"
},
@ -44,7 +42,6 @@ export const WithRequiredActions: Story = {
render: () => (
<KcPageStory
kcContext={{
messageHeader: "Message header",
message: {
summary: "Required actions: "
},

View File

@ -231,131 +231,3 @@ export const WithErrorMessage: Story = {
/>
)
};
export const WithOneSocialProvider: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
}
]
}
}}
/>
)
};
export const WithTwoSocialProviders: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
},
{
loginUrl: "microsoft",
alias: "microsoft",
providerId: "microsoft",
displayName: "Microsoft",
iconClasses: "fa fa-windows"
}
]
}
}}
/>
)
};
export const WithNoSocialProviders: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: []
}
}}
/>
)
};
export const WithMoreThanTwoSocialProviders: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
},
{
loginUrl: "microsoft",
alias: "microsoft",
providerId: "microsoft",
displayName: "Microsoft",
iconClasses: "fa fa-windows"
},
{
loginUrl: "facebook",
alias: "facebook",
providerId: "facebook",
displayName: "Facebook",
iconClasses: "fa fa-facebook"
},
{
loginUrl: "twitter",
alias: "twitter",
providerId: "twitter",
displayName: "Twitter",
iconClasses: "fa fa-twitter"
}
]
}
}}
/>
)
};
export const WithSocialProvidersAndWithoutRememberMe: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
}
]
},
realm: { rememberMe: false }
}}
/>
)
};

View File

@ -41,24 +41,3 @@ export const WithError: Story = {
/>
)
};
export const WithAppInitiatedAction: Story = {
render: () => (
<KcPageStory
kcContext={{
isAppInitiatedAction: true
}}
/>
)
};
export const WithPreFilledUserLabel: Story = {
render: () => (
<KcPageStory
kcContext={{
totp: {
otpCredentials: [{ userLabel: "MyDevice" }]
}
}}
/>
)
};

View File

@ -0,0 +1,18 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-oauth2-device-verify-user-code.ftl" });
const meta = {
title: "login/login-oauth2-device-verify-user-code.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};

View File

@ -2,14 +2,6 @@ import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
// Mock kcContext to avoid the TS2304 error
const mockKcContext = {
url: {
loginAction: "/login-action"
},
idpAlias: "mockIdpAlias"
};
const { KcPageStory } = createKcPageStory({ pageId: "login-idp-link-confirm.ftl" });
const meta = {
@ -21,35 +13,6 @@ export default meta;
type Story = StoryObj<typeof meta>;
/**
* Default:
* - Purpose: Tests standard behavior with mock data.
* - Scenario: The component renders with a mocked identity provider alias (`mockIdpAlias`) and a login action URL (`/login-action`).
* - Key Aspect: Ensures the default behavior of the component with standard values for kcContext.
*/
export const Default: Story = {
render: () => <KcPageStory kcContext={mockKcContext} />
};
/**
* WithFormSubmissionError:
* - Purpose: Tests how the component handles form submission errors.
* - Scenario: Simulates a form submission error by setting the login action URL to `/error` and displays an error message.
* - Key Aspect: Verifies that the component can display error messages during form submission failure, ensuring proper error handling.
*/
export const WithFormSubmissionError: Story = {
render: () => (
<KcPageStory
kcContext={{
...mockKcContext,
url: {
loginAction: "/error"
},
message: {
type: "error",
summary: "An error occurred during form submission."
}
}}
/>
)
render: () => <KcPageStory />
};

View File

@ -2,20 +2,6 @@ import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
// Mock kcContext to avoid TS2304 error and to simulate the real environment
const mockKcContext = {
url: {
loginAction: "/login-action"
},
idpAlias: "mockIdpAlias",
brokerContext: {
username: "mockUser"
},
realm: {
displayName: "MockRealm"
}
};
const { KcPageStory } = createKcPageStory({ pageId: "login-idp-link-email.ftl" });
const meta = {
@ -27,81 +13,6 @@ export default meta;
type Story = StoryObj<typeof meta>;
/**
* Default:
* - Purpose: Tests the default behavior with mock data.
* - Scenario: The component renders with a mocked identity provider alias (`mockIdpAlias`), a default broker username (`mockUser`), and a default realm name (`MockRealm`).
* - Key Aspect: Ensures the default behavior of the component with typical kcContext values.
*/
export const Default: Story = {
render: () => <KcPageStory kcContext={mockKcContext} />
};
/**
* WithIdpAlias:
* - Purpose: Tests behavior when the idpAlias is set to "Google".
* - Scenario: Simulates the component being used with a Google identity provider, showing the username "john.doe" and realm "MyRealm".
* - Key Aspect: Ensures the correct identity provider alias ("Google") and broker context (user info) are displayed in the email linking instructions.
*/
export const WithIdpAlias: Story = {
render: () => (
<KcPageStory
kcContext={{
...mockKcContext,
idpAlias: "Google",
brokerContext: {
username: "john.doe"
},
realm: {
displayName: "MyRealm"
}
}}
/>
)
};
/**
* WithCustomRealmDisplayName:
* - Purpose: Tests behavior when the realm display name is customized.
* - Scenario: Simulates the component with a Facebook identity provider, a broker username "jane.doe", and a custom realm name "CustomRealm".
* - Key Aspect: Ensures that custom realm display names are rendered correctly alongside the idpAlias and broker context.
*/
export const WithCustomRealmDisplayName: Story = {
render: () => (
<KcPageStory
kcContext={{
...mockKcContext,
idpAlias: "Facebook",
brokerContext: {
username: "jane.doe"
},
realm: {
displayName: "CUSTOM REALM DISPLAY NAME"
}
}}
/>
)
};
/**
* WithFormSubmissionError:
* - Purpose: Tests how the component handles form submission errors.
* - Scenario: Simulates a form submission error by setting the login action URL to `/error` and displays an error message.
* - Key Aspect: Verifies that the component can display error messages during form submission failure, ensuring proper error handling.
*/
export const WithFormSubmissionError: Story = {
render: () => (
<KcPageStory
kcContext={{
...mockKcContext,
url: {
loginAction: "/error"
},
message: {
type: "error",
summary: "An error occurred during form submission."
}
}}
/>
)
render: () => <KcPageStory />
};

View File

@ -16,47 +16,3 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithErrorMessage:
* - Purpose: Tests when there is an error with the OAuth2 device user code entry.
* - Scenario: The component renders with an error message displayed to the user.
* - Key Aspect: Ensures the error message is properly shown when the user enters an invalid code.
*/
export const WithErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
oauth2DeviceVerificationAction: "/mock-oauth2-device-verification"
},
message: {
summary: "The user code you entered is invalid. Please try again.",
type: "error"
}
}}
/>
)
};
/**
* WithEmptyInputField:
* - Purpose: Tests when the user code field is left empty.
* - Scenario: The component renders the form, and the user tries to submit without entering any code.
* - Key Aspect: Ensures the form displays validation errors when the field is left empty.
*/
export const WithEmptyInputField: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
oauth2DeviceVerificationAction: "/mock-oauth2-device-verification"
},
message: {
summary: "User code cannot be empty. Please enter a valid code.",
type: "error"
}
}}
/>
)
};

View File

@ -2,25 +2,6 @@ import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
// Mock kcContext to simulate real environment
const mockKcContext = {
url: {
oauthAction: "/oauth-action"
},
oauth: {
clientScopesRequested: [{ consentScreenText: "Scope1", dynamicScopeParameter: "dynamicScope1" }, { consentScreenText: "Scope2" }],
code: "mockCode"
},
client: {
attributes: {
policyUri: "https://twitter.com/en/tos",
tosUri: "https://twitter.com/en/privacy"
},
name: "Twitter",
clientId: "twitter-client-id"
}
};
const { KcPageStory } = createKcPageStory({ pageId: "login-oauth-grant.ftl" });
const meta = {
@ -32,55 +13,6 @@ export default meta;
type Story = StoryObj<typeof meta>;
/**
* Default:
* - Purpose: Tests the default behavior with meaningful logo (Twitter).
* - Scenario: The component renders with Twitter as the client, displaying its logo, policy, and terms of service links.
* - Key Aspect: Ensures the component works with a realistic `logoUri` and client name.
*/
export const Default: Story = {
render: () => <KcPageStory kcContext={mockKcContext} />
};
/**
* WithoutScopes:
* - Purpose: Tests the component when no OAuth scopes are requested.
* - Scenario: The component renders with no scopes listed under the consent screen.
* - Key Aspect: Ensures the component renders correctly when there are no requested scopes.
*/
export const WithoutScopes: Story = {
render: () => (
<KcPageStory
kcContext={{
...mockKcContext,
oauth: {
...mockKcContext.oauth,
clientScopesRequested: []
}
}}
/>
)
};
/**
* WithFormSubmissionError:
* - Purpose: Tests how the component handles form submission errors.
* - Scenario: The `oauthAction` URL is set to an error route and an error message is displayed.
* - Key Aspect: Ensures that the component can display error messages when form submission fails.
*/
export const WithFormSubmissionError: Story = {
render: () => (
<KcPageStory
kcContext={{
...mockKcContext,
url: {
oauthAction: "/error"
},
message: {
type: "error",
summary: "An error occurred during form submission."
}
}}
/>
)
render: () => <KcPageStory />
};

View File

@ -16,113 +16,3 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* MultipleOtpCredentials:
* - Purpose: Tests the behavior when the user has multiple OTP credentials to choose from.
* - Scenario: Simulates the scenario where the user is presented with multiple OTP credentials and must select one to proceed.
* - Key Aspect: Ensures that multiple OTP credentials are listed and selectable, and the correct credential is selected by default.
*/
export const MultipleOtpCredentials: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
userOtpCredentials: [
{ id: "credential1", userLabel: "Device 1" },
{ id: "credential2", userLabel: "Device 2" },
{ id: "credential2", userLabel: "Device 3" },
{ id: "credential2", userLabel: "Device 4" },
{ id: "credential2", userLabel: "Device 5" },
{ id: "credential2", userLabel: "Device 6" }
],
selectedCredentialId: "credential1"
},
url: {
loginAction: "/login-action"
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};
/**
* WithOtpError:
* - Purpose: Tests the behavior when an error occurs with the OTP field (e.g., invalid OTP code).
* - Scenario: Simulates an invalid OTP code scenario where an error message is displayed.
* - Key Aspect: Ensures that the OTP input displays error messages correctly and the error is visible.
*/
export const WithOtpError: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
userOtpCredentials: []
},
url: {
loginAction: "/login-action"
},
messagesPerField: {
existsError: (field: string) => field === "totp",
get: () => "Invalid OTP code"
}
}}
/>
)
};
/**
* NoOtpCredentials:
* - Purpose: Tests the behavior when no OTP credentials are provided for the user.
* - Scenario: Simulates the scenario where the user is not presented with any OTP credentials, and only the OTP input is displayed.
* - Key Aspect: Ensures that the component handles cases where there are no user OTP credentials, and the user is only prompted for the OTP code.
*/
export const NoOtpCredentials: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
userOtpCredentials: []
},
url: {
loginAction: "/login-action"
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};
/**
* WithErrorAndMultipleOtpCredentials:
* - Purpose: Tests behavior when there is both an error in the OTP field and multiple OTP credentials.
* - Scenario: Simulates the case where the user has multiple OTP credentials and encounters an error with the OTP input.
* - Key Aspect: Ensures that the component can handle both multiple OTP credentials and display an error message simultaneously.
*/
export const WithErrorAndMultipleOtpCredentials: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
userOtpCredentials: [
{ id: "credential1", userLabel: "Device 1" },
{ id: "credential2", userLabel: "Device 2" }
],
selectedCredentialId: "credential1"
},
url: {
loginAction: "/login-action"
},
messagesPerField: {
existsError: (field: string) => field === "totp",
get: () => "Invalid OTP code"
}
}}
/>
)
};

View File

@ -16,26 +16,3 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithErrorMessage:
* - Purpose: Tests behavior when an error message is displayed along with the page expiration message.
* - Scenario: Simulates a case where the session expired due to an error, and an error message is displayed alongside the expiration message.
* - Key Aspect: Ensures that error messages are displayed correctly in addition to the page expiration notice.
*/
export const WithErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginRestartFlowUrl: "/mock-restart-flow",
loginAction: "/mock-continue-login"
},
message: {
type: "error",
summary: "An error occurred while processing your session."
}
}}
/>
)
};

View File

@ -16,54 +16,3 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithPasswordError:
* - Purpose: Tests the behavior when an error occurs in the password field (e.g., incorrect password).
* - Scenario: Simulates a scenario where an invalid password is entered, and an error message is displayed.
* - Key Aspect: Ensures that the password input field displays error messages correctly.
*/
export const WithPasswordError: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: {
resetPasswordAllowed: true
},
url: {
loginAction: "/mock-login",
loginResetCredentialsUrl: "/mock-reset-password"
},
messagesPerField: {
existsError: (field: string) => field === "password",
get: () => "Invalid password"
}
}}
/>
)
};
/**
* WithoutResetPasswordOption:
* - Purpose: Tests the behavior when the reset password option is disabled.
* - Scenario: Simulates a scenario where the `resetPasswordAllowed` is set to `false`, and the "Forgot Password" link is not rendered.
* - Key Aspect: Ensures that the component handles cases where resetting the password is not allowed.
*/
export const WithoutResetPasswordOption: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: {
resetPasswordAllowed: false
},
url: {
loginAction: "/mock-login",
loginResetCredentialsUrl: "/mock-reset-password"
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};

View File

@ -16,25 +16,3 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithErrorDuringCodeGeneration:
* - Purpose: Tests when an error occurs while generating recovery authentication codes.
* - Scenario: The component renders an error message to inform the user of the failure during code generation.
* - Key Aspect: Ensures that error messages are properly displayed when recovery code generation fails.
*/
export const WithErrorDuringCodeGeneration: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login-action"
},
message: {
summary: "An error occurred during recovery code generation. Please try again.",
type: "error"
}
}}
/>
)
};

View File

@ -16,82 +16,3 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithoutOtpCredentials:
* - Purpose: Tests the behavior when no OTP credentials are available.
* - Scenario: The component renders without any OTP credentials, showing only the submit button.
* - Key Aspect: Ensures that the component handles the absence of OTP credentials correctly.
*/
export const WithoutOtpCredentials: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login"
},
configuredOtpCredentials: {
userOtpCredentials: [],
selectedCredentialId: undefined
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};
/**
* WithOtpError:
* - Purpose: Tests the behavior when an error occurs with the OTP selection.
* - Scenario: Simulates a scenario where an error occurs (e.g., no OTP selected), and an error message is displayed.
* - Key Aspect: Ensures that error messages are displayed correctly for OTP-related errors.
*/
export const WithOtpError: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login"
},
configuredOtpCredentials: {
userOtpCredentials: [
{ id: "otp1", userLabel: "Device 1" },
{ id: "otp2", userLabel: "Device 2" }
],
selectedCredentialId: "otp1"
},
messagesPerField: {
existsError: (field: string) => field === "totp",
get: () => "Invalid OTP selection"
}
}}
/>
)
};
/**
* WithOnlyOneOtpCredential:
* - Purpose: Tests the behavior when there is only one OTP credential available.
* - Scenario: Simulates the case where the user has only one OTP credential, and it is pre-selected by default.
* - Key Aspect: Ensures that the component renders correctly with only one OTP credential pre-selected.
*/
export const WithOnlyOneOtpCredential: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login"
},
configuredOtpCredentials: {
userOtpCredentials: [{ id: "otp1", userLabel: "Device 1" }],
selectedCredentialId: "otp1"
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};

Some files were not shown because too many files have changed in this diff Show More