Merge branch 'main' into feat/additional-account-pages
This commit is contained in:
commit
2e6321342e
3
.github/workflows/ci.yaml
vendored
3
.github/workflows/ci.yaml
vendored
@ -3,9 +3,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- v5
|
||||
- v6
|
||||
- v7
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
@ -40,7 +40,7 @@
|
||||
|
||||
Keycloakify is fully compatible with Keycloak 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, [~~22~~](https://github.com/keycloakify/keycloakify/issues/389#issuecomment-1822509763), **23** [and up](https://github.com/keycloakify/keycloakify/discussions/346#discussioncomment-5889791)!
|
||||
|
||||
## Sponsor 👼
|
||||
## Sponsor
|
||||
|
||||
We are exclusively sponsored by [Cloud IAM](https://cloud-iam.com/?mtm_campaign=keycloakify-deal&mtm_source=keycloakify-github), a French company offering Keycloak as a service.
|
||||
Their dedicated support helps us continue the development and maintenance of this project.
|
||||
@ -123,6 +123,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
|
||||
# Changelog highlights
|
||||
|
||||
## 9.5
|
||||
|
||||
- Post build hook: You can now apply custom transformation to your theme files. [Learn more](https://docs.keycloakify.dev/build-options#postbuild-hook).
|
||||
- You can now specify your option in the Keycloakify's Vite plugin instead in the package.json. [See example](https://docs.keycloakify.dev/build-options#themename).
|
||||
|
||||
## 9.4
|
||||
|
||||
**Vite Support! 🎉**
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "keycloakify",
|
||||
"version": "9.4.1",
|
||||
"version": "9.5.8",
|
||||
"description": "Create Keycloak themes using React",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@ -125,6 +125,7 @@
|
||||
"tsafe": "^1.6.0",
|
||||
"yauzl": "^2.10.0",
|
||||
"yazl": "^2.5.1",
|
||||
"zod": "^3.17.10"
|
||||
"zod": "^3.17.10",
|
||||
"magic-string": "^0.30.7"
|
||||
}
|
||||
}
|
||||
|
@ -90,6 +90,7 @@ export declare namespace KcContext {
|
||||
lastName?: string;
|
||||
username?: string;
|
||||
};
|
||||
properties: Record<string, string | undefined>;
|
||||
sessions: {
|
||||
sessions: {
|
||||
ipAddress: string;
|
||||
|
@ -8,8 +8,9 @@ import { kcContextMocks, kcContextCommonMock } from "keycloakify/account/kcConte
|
||||
|
||||
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
|
||||
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
|
||||
mockProperties?: Record<string, string>;
|
||||
}) {
|
||||
const { mockData } = params ?? {};
|
||||
const { mockData, mockProperties } = params ?? {};
|
||||
|
||||
function getKcContext<PageId extends ExtendKcContext<KcContextExtension>["pageId"] | undefined = undefined>(params?: {
|
||||
mockPageId?: PageId;
|
||||
@ -82,6 +83,13 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
|
||||
});
|
||||
}
|
||||
|
||||
if (mockProperties !== undefined) {
|
||||
deepAssign({
|
||||
"target": kcContext.properties,
|
||||
"source": mockProperties
|
||||
});
|
||||
}
|
||||
|
||||
return { kcContext };
|
||||
}
|
||||
|
||||
|
@ -146,6 +146,17 @@ export const kcContextCommonMock: KcContext.Common = {
|
||||
"email": "john.doe@code.gouv.fr",
|
||||
"username": "doe_j"
|
||||
},
|
||||
"properties": {
|
||||
"parent": "account-v1",
|
||||
"kcButtonLargeClass": "btn-lg",
|
||||
"locales": "ar,ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN",
|
||||
"kcButtonPrimaryClass": "btn-primary",
|
||||
"accountResourceProvider": "account-v1",
|
||||
"styles":
|
||||
"css/account.css img/icon-sidebar-active.png img/logo.png resources-common/node_modules/patternfly/dist/css/patternfly.min.css resources-common/node_modules/patternfly/dist/css/patternfly-additions.min.css resources-common/node_modules/patternfly/dist/css/patternfly-additions.min.css",
|
||||
"kcButtonClass": "btn",
|
||||
"kcButtonDefaultClass": "btn-default"
|
||||
},
|
||||
"sessions": {
|
||||
"sessions": [
|
||||
{
|
||||
|
@ -10,3 +10,5 @@ export const retrocompatPostfix = "_retrocompat";
|
||||
export const accountV1ThemeName = "account-v1";
|
||||
|
||||
export type ThemeType = (typeof themeTypes)[number];
|
||||
|
||||
export const keycloakifyBuildOptionsForPostPostBuildScriptEnvName = "KEYCLOAKIFY_BUILD_OPTIONS_POST_POST_BUILD_SCRIPT";
|
||||
|
@ -181,34 +181,6 @@ export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: st
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const totpFtlFilePath = pathJoin(destDirPath, "base", "account", "totp.ftl");
|
||||
|
||||
fs.writeFileSync(
|
||||
totpFtlFilePath,
|
||||
Buffer.from(
|
||||
fs
|
||||
.readFileSync(totpFtlFilePath)
|
||||
.toString("utf8")
|
||||
.replace(
|
||||
[
|
||||
" <#list totp.policy.supportedApplications as app>",
|
||||
" <li>${app}</li>",
|
||||
" </#list>"
|
||||
].join("\n"),
|
||||
[
|
||||
" <#if totp.policy.supportedApplications?has_content>",
|
||||
" <#list totp.policy.supportedApplications as app>",
|
||||
" <li>${app}</li>",
|
||||
" </#list>",
|
||||
" </#if>"
|
||||
].join("\n")
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Note, this is an optimization for reducing the size of the jar,
|
||||
// For this version we know exactly which resources are used.
|
||||
{
|
||||
|
25
src/bin/keycloakify/buildOptions/UserProvidedBuildOptions.ts
Normal file
25
src/bin/keycloakify/buildOptions/UserProvidedBuildOptions.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export type UserProvidedBuildOptions = {
|
||||
extraThemeProperties?: string[];
|
||||
artifactId?: string;
|
||||
groupId?: string;
|
||||
doCreateJar?: boolean;
|
||||
loginThemeResourcesFromKeycloakVersion?: string;
|
||||
reactAppBuildDirPath?: string;
|
||||
keycloakifyBuildDirPath?: string;
|
||||
themeName?: string | string[];
|
||||
doBuildRetrocompatAccountTheme?: boolean;
|
||||
};
|
||||
|
||||
export const zUserProvidedBuildOptions = z.object({
|
||||
"extraThemeProperties": z.array(z.string()).optional(),
|
||||
"artifactId": z.string().optional(),
|
||||
"groupId": z.string().optional(),
|
||||
"doCreateJar": z.boolean().optional(),
|
||||
"loginThemeResourcesFromKeycloakVersion": z.string().optional(),
|
||||
"reactAppBuildDirPath": z.string().optional(),
|
||||
"keycloakifyBuildDirPath": z.string().optional(),
|
||||
"themeName": z.union([z.string(), z.array(z.string())]).optional(),
|
||||
"doBuildRetrocompatAccountTheme": z.boolean().optional()
|
||||
});
|
@ -47,10 +47,15 @@ export function readBuildOptions(params: { processArgv: string[] }): BuildOption
|
||||
throw new Error("Keycloakify's Vite plugin output not found");
|
||||
}
|
||||
|
||||
const parsedPackageJson = readParsedPackageJson({ reactAppRootDirPath });
|
||||
const { keycloakify: userProvidedBuildOptionsFromPackageJson, ...parsedPackageJson } = readParsedPackageJson({ reactAppRootDirPath });
|
||||
|
||||
const userProvidedBuildOptions = {
|
||||
...userProvidedBuildOptionsFromPackageJson,
|
||||
...resolvedViteConfig?.userProvidedBuildOptions
|
||||
};
|
||||
|
||||
const themeNames = (() => {
|
||||
if (parsedPackageJson.keycloakify?.themeName === undefined) {
|
||||
if (userProvidedBuildOptions.themeName === undefined) {
|
||||
return [
|
||||
parsedPackageJson.name
|
||||
.replace(/^@(.*)/, "$1")
|
||||
@ -59,11 +64,11 @@ export function readBuildOptions(params: { processArgv: string[] }): BuildOption
|
||||
];
|
||||
}
|
||||
|
||||
if (typeof parsedPackageJson.keycloakify.themeName === "string") {
|
||||
return [parsedPackageJson.keycloakify.themeName];
|
||||
if (typeof userProvidedBuildOptions.themeName === "string") {
|
||||
return [userProvidedBuildOptions.themeName];
|
||||
}
|
||||
|
||||
return parsedPackageJson.keycloakify.themeName;
|
||||
return userProvidedBuildOptions.themeName;
|
||||
})();
|
||||
|
||||
const reactAppBuildDirPath = (() => {
|
||||
@ -72,9 +77,9 @@ export function readBuildOptions(params: { processArgv: string[] }): BuildOption
|
||||
break webpack;
|
||||
}
|
||||
|
||||
if (parsedPackageJson.keycloakify?.reactAppBuildDirPath !== undefined) {
|
||||
if (userProvidedBuildOptions.reactAppBuildDirPath !== undefined) {
|
||||
return getAbsoluteAndInOsFormatPath({
|
||||
"pathIsh": parsedPackageJson.keycloakify?.reactAppBuildDirPath,
|
||||
"pathIsh": userProvidedBuildOptions.reactAppBuildDirPath,
|
||||
"cwd": reactAppRootDirPath
|
||||
});
|
||||
}
|
||||
@ -94,13 +99,13 @@ export function readBuildOptions(params: { processArgv: string[] }): BuildOption
|
||||
"isSilent": typeof argv["silent"] === "boolean" ? argv["silent"] : false,
|
||||
"themeVersion": process.env.KEYCLOAKIFY_THEME_VERSION ?? parsedPackageJson.version ?? "0.0.0",
|
||||
themeNames,
|
||||
"extraThemeProperties": parsedPackageJson.keycloakify?.extraThemeProperties,
|
||||
"extraThemeProperties": userProvidedBuildOptions.extraThemeProperties,
|
||||
"groupId": (() => {
|
||||
const fallbackGroupId = `${themeNames[0]}.keycloak`;
|
||||
|
||||
return (
|
||||
process.env.KEYCLOAKIFY_GROUP_ID ??
|
||||
parsedPackageJson.keycloakify?.groupId ??
|
||||
userProvidedBuildOptions.groupId ??
|
||||
(parsedPackageJson.homepage === undefined
|
||||
? fallbackGroupId
|
||||
: urlParse(parsedPackageJson.homepage)
|
||||
@ -110,20 +115,23 @@ export function readBuildOptions(params: { processArgv: string[] }): BuildOption
|
||||
.join(".") ?? fallbackGroupId) + ".keycloak"
|
||||
);
|
||||
})(),
|
||||
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? parsedPackageJson.keycloakify?.artifactId ?? `${themeNames[0]}-keycloak-theme`,
|
||||
"doCreateJar": parsedPackageJson.keycloakify?.doCreateJar ?? true,
|
||||
"loginThemeResourcesFromKeycloakVersion": parsedPackageJson.keycloakify?.loginThemeResourcesFromKeycloakVersion ?? "11.0.3",
|
||||
"artifactId": process.env.KEYCLOAKIFY_ARTIFACT_ID ?? userProvidedBuildOptions.artifactId ?? `${themeNames[0]}-keycloak-theme`,
|
||||
"doCreateJar": userProvidedBuildOptions.doCreateJar ?? true,
|
||||
"loginThemeResourcesFromKeycloakVersion": userProvidedBuildOptions.loginThemeResourcesFromKeycloakVersion ?? "11.0.3",
|
||||
reactAppRootDirPath,
|
||||
reactAppBuildDirPath,
|
||||
"keycloakifyBuildDirPath": (() => {
|
||||
if (parsedPackageJson.keycloakify?.keycloakifyBuildDirPath !== undefined) {
|
||||
if (userProvidedBuildOptions.keycloakifyBuildDirPath !== undefined) {
|
||||
return getAbsoluteAndInOsFormatPath({
|
||||
"pathIsh": parsedPackageJson.keycloakify?.keycloakifyBuildDirPath,
|
||||
"pathIsh": userProvidedBuildOptions.keycloakifyBuildDirPath,
|
||||
"cwd": reactAppRootDirPath
|
||||
});
|
||||
}
|
||||
|
||||
return resolvedViteConfig?.buildDir === undefined ? "build_keycloak" : `${resolvedViteConfig.buildDir}_keycloak`;
|
||||
return pathJoin(
|
||||
reactAppRootDirPath,
|
||||
resolvedViteConfig?.buildDir === undefined ? "build_keycloak" : `${resolvedViteConfig.buildDir}_keycloak`
|
||||
);
|
||||
})(),
|
||||
"publicDirPath": (() => {
|
||||
webpack: {
|
||||
@ -179,7 +187,7 @@ export function readBuildOptions(params: { processArgv: string[] }): BuildOption
|
||||
|
||||
return pathJoin(reactAppBuildDirPath, resolvedViteConfig.assetsDir);
|
||||
})(),
|
||||
"doBuildRetrocompatAccountTheme": parsedPackageJson.keycloakify?.doBuildRetrocompatAccountTheme ?? true,
|
||||
"doBuildRetrocompatAccountTheme": userProvidedBuildOptions.doBuildRetrocompatAccountTheme ?? true,
|
||||
npmWorkspaceRootDirPath
|
||||
};
|
||||
}
|
||||
|
@ -3,41 +3,20 @@ import { assert } from "tsafe";
|
||||
import type { Equals } from "tsafe";
|
||||
import { z } from "zod";
|
||||
import { join as pathJoin } from "path";
|
||||
import { type UserProvidedBuildOptions, zUserProvidedBuildOptions } from "./UserProvidedBuildOptions";
|
||||
|
||||
export type ParsedPackageJson = {
|
||||
name: string;
|
||||
version?: string;
|
||||
homepage?: string;
|
||||
keycloakify?: {
|
||||
extraThemeProperties?: string[];
|
||||
artifactId?: string;
|
||||
groupId?: string;
|
||||
doCreateJar?: boolean;
|
||||
loginThemeResourcesFromKeycloakVersion?: string;
|
||||
reactAppBuildDirPath?: string;
|
||||
keycloakifyBuildDirPath?: string;
|
||||
themeName?: string | string[];
|
||||
doBuildRetrocompatAccountTheme?: boolean;
|
||||
};
|
||||
keycloakify?: UserProvidedBuildOptions;
|
||||
};
|
||||
|
||||
const zParsedPackageJson = z.object({
|
||||
"name": z.string(),
|
||||
"version": z.string().optional(),
|
||||
"homepage": z.string().optional(),
|
||||
"keycloakify": z
|
||||
.object({
|
||||
"extraThemeProperties": z.array(z.string()).optional(),
|
||||
"artifactId": z.string().optional(),
|
||||
"groupId": z.string().optional(),
|
||||
"doCreateJar": z.boolean().optional(),
|
||||
"loginThemeResourcesFromKeycloakVersion": z.string().optional(),
|
||||
"reactAppBuildDirPath": z.string().optional(),
|
||||
"keycloakifyBuildDirPath": z.string().optional(),
|
||||
"themeName": z.union([z.string(), z.array(z.string())]).optional(),
|
||||
"doBuildRetrocompatAccountTheme": z.boolean().optional()
|
||||
})
|
||||
.optional()
|
||||
"keycloakify": zUserProvidedBuildOptions.optional()
|
||||
});
|
||||
|
||||
assert<Equals<ReturnType<(typeof zParsedPackageJson)["parse"]>, ParsedPackageJson>>();
|
||||
|
@ -5,19 +5,22 @@ import { z } from "zod";
|
||||
import { join as pathJoin } from "path";
|
||||
import { resolvedViteConfigJsonBasename } from "../../constants";
|
||||
import type { OptionalIfCanBeUndefined } from "../../tools/OptionalIfCanBeUndefined";
|
||||
import { UserProvidedBuildOptions, zUserProvidedBuildOptions } from "./UserProvidedBuildOptions";
|
||||
|
||||
export type ResolvedViteConfig = {
|
||||
buildDir: string;
|
||||
publicDir: string;
|
||||
assetsDir: string;
|
||||
urlPathname: string | undefined;
|
||||
userProvidedBuildOptions: UserProvidedBuildOptions;
|
||||
};
|
||||
|
||||
const zResolvedViteConfig = z.object({
|
||||
"buildDir": z.string(),
|
||||
"publicDir": z.string(),
|
||||
"assetsDir": z.string(),
|
||||
"urlPathname": z.string().optional()
|
||||
"urlPathname": z.string().optional(),
|
||||
"userProvidedBuildOptions": zUserProvidedBuildOptions
|
||||
});
|
||||
|
||||
{
|
||||
|
@ -431,7 +431,7 @@
|
||||
<#if isHash>
|
||||
|
||||
<#if path?size gt 10>
|
||||
<#return "ABORT: Too many recursive calls">
|
||||
<#return "ABORT: Too many recursive calls, path: " + path?join(".")>
|
||||
</#if>
|
||||
|
||||
<#local keys = "">
|
||||
@ -463,9 +463,10 @@
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/91#issue-1212319466 (reports with error.ftl and Kc18) -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/109#issuecomment-1134610163 -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/357 -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/discussions/406#discussioncomment-7514787 -->
|
||||
key == "loginAction" &&
|
||||
are_same_path(path, ["url"]) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl"]?seq_contains(pageId) &&
|
||||
["saml-post-form.ftl", "error.ftl", "info.ftl", "login-oauth-grant.ftl", "logout-confirm.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
|
||||
!(auth?has_content && auth.showTryAnotherWayLink())
|
||||
) || (
|
||||
<#-- https://github.com/keycloakify/keycloakify/issues/362 -->
|
||||
@ -488,24 +489,33 @@
|
||||
!["name", "displayName", "displayNameHtml", "internationalizationEnabled", "registrationEmailAsUsername" ]?seq_contains(key)
|
||||
) || (
|
||||
"applications.ftl" == pageId &&
|
||||
are_same_path(path, ["applications", "applications", "*", "client", "realm"])
|
||||
is_subpath(path, ["applications", "applications"]) &&
|
||||
(
|
||||
key == "realm" ||
|
||||
key == "container"
|
||||
)
|
||||
) || (
|
||||
"applications.ftl" == pageId &&
|
||||
"masterAdminClient" == key
|
||||
are_same_path(path, ["user"]) &&
|
||||
key == "delegateForUpdate"
|
||||
)
|
||||
>
|
||||
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
|
||||
<#local out_seq += ["/*If you need '" + path?join(".") + "." + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#if pageId == "register.ftl" && key == "attemptedUsername" && are_same_path(path, ["auth"])>
|
||||
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
|
||||
<#if (
|
||||
["register.ftl", "info.ftl", "login.ftl", "login-update-password.ftl", "login-oauth2-device-verify-user-code.ftl"]?seq_contains(pageId) &&
|
||||
key == "attemptedUsername" && are_same_path(path, ["auth"])
|
||||
)>
|
||||
<#attempt>
|
||||
<#-- https://github.com/keycloak/keycloak/blob/3a2bf0c04bcde185e497aaa32d0bb7ab7520cf4a/themes/src/main/resources/theme/base/login/template.ftl#L63 -->
|
||||
<#-- https://github.com/keycloakify/keycloakify/discussions/406 -->
|
||||
<#if !(auth?has_content && auth.showUsername() && !auth.showResetCredentials())>
|
||||
<#local out_seq += ["/*If you need '" + key + "' on " + pageId + ", please submit an issue to the Keycloakify repo*/"]>
|
||||
<#continue>
|
||||
</#if>
|
||||
<#recover>
|
||||
<#local out_seq += ["/*Testing if attemptedUsername should be skipped throwed an exception */"]>
|
||||
</#attempt>
|
||||
</#if>
|
||||
|
||||
@ -658,9 +668,9 @@
|
||||
<#return "ABORT: Couldn't convert into string non hash, non method, non boolean, non enumerable object">
|
||||
|
||||
</#function>
|
||||
<#function are_same_path path searchedPath>
|
||||
<#function is_subpath path searchedPath>
|
||||
|
||||
<#if path?size != searchedPath?size>
|
||||
<#if path?size < searchedPath?size>
|
||||
<#return false>
|
||||
</#if>
|
||||
|
||||
@ -668,8 +678,14 @@
|
||||
|
||||
<#list path as property>
|
||||
|
||||
<#if i == searchedPath?size >
|
||||
<#continue>
|
||||
</#if>
|
||||
|
||||
<#local searchedProperty=searchedPath[i]>
|
||||
|
||||
<#local i+= 1>
|
||||
|
||||
<#if searchedProperty?is_string && searchedProperty == "*">
|
||||
<#continue>
|
||||
</#if>
|
||||
@ -686,11 +702,13 @@
|
||||
<#return false>
|
||||
</#if>
|
||||
|
||||
<#local i+= 1>
|
||||
|
||||
</#list>
|
||||
|
||||
<#return true>
|
||||
|
||||
</#function>
|
||||
|
||||
<#function are_same_path path searchedPath>
|
||||
<#return path?size == searchedPath?size && is_subpath(path, searchedPath)>
|
||||
</#function>
|
||||
</script>
|
@ -9,6 +9,7 @@ import { getLogger } from "../tools/logger";
|
||||
import { getThemeSrcDirPath } from "../getThemeSrcDirPath";
|
||||
import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath";
|
||||
import { readThisNpmProjectVersion } from "../tools/readThisNpmProjectVersion";
|
||||
import { keycloakifyBuildOptionsForPostPostBuildScriptEnvName } from "../constants";
|
||||
|
||||
export async function main() {
|
||||
const buildOptions = readBuildOptions({
|
||||
@ -36,9 +37,31 @@ export async function main() {
|
||||
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, "pom.xml"), Buffer.from(pomFileCode, "utf8"));
|
||||
}
|
||||
|
||||
const containerKeycloakVersion = "23.0.6";
|
||||
|
||||
const jarFilePath = pathJoin(buildOptions.keycloakifyBuildDirPath, "target", `${buildOptions.artifactId}-${buildOptions.themeVersion}.jar`);
|
||||
|
||||
if (buildOptions.doCreateJar) {
|
||||
generateStartKeycloakTestingContainer({
|
||||
"keycloakVersion": containerKeycloakVersion,
|
||||
jarFilePath,
|
||||
buildOptions
|
||||
});
|
||||
|
||||
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, ".gitignore"), Buffer.from("*", "utf8"));
|
||||
|
||||
child_process.execSync("npx vite", {
|
||||
"cwd": buildOptions.reactAppRootDirPath,
|
||||
"env": {
|
||||
...process.env,
|
||||
[keycloakifyBuildOptionsForPostPostBuildScriptEnvName]: JSON.stringify(buildOptions)
|
||||
}
|
||||
});
|
||||
|
||||
create_jar: {
|
||||
if (!buildOptions.doCreateJar) {
|
||||
break create_jar;
|
||||
}
|
||||
|
||||
child_process.execSync("mvn clean install", { "cwd": buildOptions.keycloakifyBuildDirPath });
|
||||
|
||||
const jarDirPath = pathDirname(jarFilePath);
|
||||
@ -59,16 +82,6 @@ export async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
const containerKeycloakVersion = "23.0.6";
|
||||
|
||||
generateStartKeycloakTestingContainer({
|
||||
"keycloakVersion": containerKeycloakVersion,
|
||||
jarFilePath,
|
||||
buildOptions
|
||||
});
|
||||
|
||||
fs.writeFileSync(pathJoin(buildOptions.keycloakifyBuildDirPath, ".gitignore"), Buffer.from("*", "utf8"));
|
||||
|
||||
logger.log(
|
||||
[
|
||||
"",
|
||||
|
@ -84,7 +84,7 @@ export declare namespace KcContext {
|
||||
description?: string;
|
||||
attributes: Record<string, string>;
|
||||
};
|
||||
isAppInitiatedAction: boolean;
|
||||
isAppInitiatedAction?: boolean;
|
||||
messagesPerField: {
|
||||
/**
|
||||
* Return text if message for given field exists. Useful eg. to add css styles for fields with message.
|
||||
@ -116,6 +116,7 @@ export declare namespace KcContext {
|
||||
*/
|
||||
exists: (fieldName: string) => boolean;
|
||||
};
|
||||
properties: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
export type SamlPostForm = Common & {
|
||||
@ -244,6 +245,17 @@ export declare namespace KcContext {
|
||||
|
||||
export type Terms = Common & {
|
||||
pageId: "terms.ftl";
|
||||
//NOTE: Optional because maybe it wasn't defined in older keycloak versions.
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
attributes: Record<string, string[]>;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
markedForEviction?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type LoginDeviceVerifyUserCode = Common & {
|
||||
|
@ -12,8 +12,9 @@ import { symToStr } from "tsafe/symToStr";
|
||||
|
||||
export function createGetKcContext<KcContextExtension extends { pageId: string } = never>(params?: {
|
||||
mockData?: readonly DeepPartial<ExtendKcContext<KcContextExtension>>[];
|
||||
mockProperties?: Record<string, string>;
|
||||
}) {
|
||||
const { mockData } = params ?? {};
|
||||
const { mockData, mockProperties } = params ?? {};
|
||||
|
||||
function getKcContext<PageId extends ExtendKcContext<KcContextExtension>["pageId"] | undefined = undefined>(params?: {
|
||||
mockPageId?: PageId;
|
||||
@ -141,6 +142,13 @@ export function createGetKcContext<KcContextExtension extends { pageId: string }
|
||||
}
|
||||
}
|
||||
|
||||
if (mockProperties !== undefined) {
|
||||
deepAssign({
|
||||
"target": kcContext.properties,
|
||||
"source": mockProperties
|
||||
});
|
||||
}
|
||||
|
||||
return { kcContext };
|
||||
}
|
||||
|
||||
|
@ -236,7 +236,127 @@ export const kcContextCommonMock: KcContext.Common = {
|
||||
"attributes": {}
|
||||
},
|
||||
"scripts": [],
|
||||
"isAppInitiatedAction": false
|
||||
"isAppInitiatedAction": false,
|
||||
"properties": {
|
||||
"kcLogoIdP-facebook": "fa fa-facebook",
|
||||
"parent": "keycloak",
|
||||
"kcAuthenticatorOTPClass": "fa fa-mobile list-view-pf-icon-lg",
|
||||
"kcLogoIdP-bitbucket": "fa fa-bitbucket",
|
||||
"kcAuthenticatorWebAuthnClass": "fa fa-key list-view-pf-icon-lg",
|
||||
"kcWebAuthnDefaultIcon": "pficon pficon-key",
|
||||
"kcLogoIdP-stackoverflow": "fa fa-stack-overflow",
|
||||
"kcSelectAuthListItemClass": "pf-l-stack__item select-auth-box-parent pf-l-split",
|
||||
"kcLogoIdP-microsoft": "fa fa-windows",
|
||||
"kcLocaleItemClass": "pf-c-dropdown__menu-item",
|
||||
"kcLoginOTPListItemHeaderClass": "pf-c-tile__header",
|
||||
"kcLoginOTPListItemIconBodyClass": "pf-c-tile__icon",
|
||||
"kcInputHelperTextAfterClass": "pf-c-form__helper-text pf-c-form__helper-text-after",
|
||||
"kcFormClass": "form-horizontal",
|
||||
"kcSelectAuthListClass": "pf-l-stack select-auth-container",
|
||||
"kcInputClassRadioCheckboxLabelDisabled": "pf-m-disabled",
|
||||
"kcSelectAuthListItemIconClass": "pf-l-split__item select-auth-box-icon",
|
||||
"kcRecoveryCodesWarning": "kc-recovery-codes-warning",
|
||||
"kcFormSettingClass": "login-pf-settings",
|
||||
"kcWebAuthnBLE": "fa fa-bluetooth-b",
|
||||
"kcInputWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
|
||||
"kcSelectAuthListItemArrowIconClass": "fa fa-angle-right fa-lg",
|
||||
"meta": "viewport==width=device-width,initial-scale=1",
|
||||
"styles": "css/login.css css/tile.css",
|
||||
"kcFeedbackAreaClass": "col-md-12",
|
||||
"kcLogoIdP-google": "fa fa-google",
|
||||
"kcCheckLabelClass": "pf-c-check__label",
|
||||
"kcSelectAuthListItemFillClass": "pf-l-split__item pf-m-fill",
|
||||
"kcAuthenticatorDefaultClass": "fa fa-list list-view-pf-icon-lg",
|
||||
"kcLogoIdP-gitlab": "fa fa-gitlab",
|
||||
"kcFormAreaClass": "col-sm-10 col-sm-offset-1 col-md-8 col-md-offset-2 col-lg-8 col-lg-offset-2",
|
||||
"kcFormButtonsClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
|
||||
"kcInputClassRadioLabel": "pf-c-radio__label",
|
||||
"kcAuthenticatorWebAuthnPasswordlessClass": "fa fa-key list-view-pf-icon-lg",
|
||||
"kcSelectAuthListItemHeadingClass": "pf-l-stack__item select-auth-box-headline pf-c-title",
|
||||
"kcInfoAreaClass": "col-xs-12 col-sm-4 col-md-4 col-lg-5 details",
|
||||
"kcLogoLink": "http://www.keycloak.org",
|
||||
"kcContainerClass": "container-fluid",
|
||||
"kcSelectAuthListItemTitle": "select-auth-box-paragraph",
|
||||
"kcHtmlClass": "login-pf",
|
||||
"kcLoginOTPListItemTitleClass": "pf-c-tile__title",
|
||||
"locales": "ca,cs,da,de,en,es,fr,fi,hu,it,ja,lt,nl,no,pl,pt-BR,ru,sk,sv,tr,zh-CN",
|
||||
"serviceTitle": "CodeGouv",
|
||||
"kcLogoIdP-openshift-v4": "pf-icon pf-icon-openshift",
|
||||
"kcWebAuthnUnknownIcon": "pficon pficon-key unknown-transport-class",
|
||||
"kcFormSocialAccountNameClass": "kc-social-provider-name",
|
||||
"kcLogoIdP-openshift-v3": "pf-icon pf-icon-openshift",
|
||||
"kcLoginOTPListInputClass": "pf-c-tile__input",
|
||||
"kcWebAuthnUSB": "fa fa-usb",
|
||||
"kcInputClassRadio": "pf-c-radio",
|
||||
"kcWebAuthnKeyIcon": "pficon pficon-key",
|
||||
"kcFeedbackInfoIcon": "fa fa-fw fa-info-circle",
|
||||
"kcCommonLogoIdP": "kc-social-provider-logo kc-social-gray",
|
||||
"stylesCommon":
|
||||
"web_modules/@patternfly/react-core/dist/styles/base.css web_modules/@patternfly/react-core/dist/styles/app.css node_modules/patternfly/dist/css/patternfly.min.css node_modules/patternfly/dist/css/patternfly-additions.min.css lib/pficon/pficon.css",
|
||||
"kcRecoveryCodesActions": "kc-recovery-codes-actions",
|
||||
"kcFormGroupHeader": "pf-c-form__group",
|
||||
"kcFormSocialAccountSectionClass": "kc-social-section kc-social-gray",
|
||||
"kcLogoIdP-instagram": "fa fa-instagram",
|
||||
"kcAlertClass": "pf-c-alert pf-m-inline",
|
||||
"kcHeaderClass": "login-pf-page-header",
|
||||
"kcLabelWrapperClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
|
||||
"kcFormSocialAccountLinkClass": "pf-c-login__main-footer-links-item-link",
|
||||
"kcLocaleMainClass": "pf-c-dropdown",
|
||||
"kcTextareaClass": "form-control",
|
||||
"kcButtonBlockClass": "pf-m-block",
|
||||
"kcButtonClass": "pf-c-button",
|
||||
"kcWebAuthnNFC": "fa fa-wifi",
|
||||
"kcLocaleClass": "col-xs-12 col-sm-1",
|
||||
"kcInputClassCheckboxInput": "pf-c-check__input",
|
||||
"kcFeedbackErrorIcon": "fa fa-fw fa-exclamation-circle",
|
||||
"kcInputLargeClass": "input-lg",
|
||||
"kcInputErrorMessageClass": "pf-c-form__helper-text pf-m-error required kc-feedback-text",
|
||||
"kcRecoveryCodesList": "kc-recovery-codes-list",
|
||||
"kcFormSocialAccountListClass": "pf-c-login__main-footer-links kc-social-links",
|
||||
"kcAlertTitleClass": "pf-c-alert__title kc-feedback-text",
|
||||
"kcAuthenticatorPasswordClass": "fa fa-unlock list-view-pf-icon-lg",
|
||||
"kcCheckInputClass": "pf-c-check__input",
|
||||
"kcLogoIdP-linkedin": "fa fa-linkedin",
|
||||
"kcLogoIdP-twitter": "fa fa-twitter",
|
||||
"kcFeedbackWarningIcon": "fa fa-fw fa-exclamation-triangle",
|
||||
"kcResetFlowIcon": "pficon pficon-arrow fa",
|
||||
"kcSelectAuthListItemIconPropertyClass": "fa-2x select-auth-box-icon-properties",
|
||||
"kcFeedbackSuccessIcon": "fa fa-fw fa-check-circle",
|
||||
"kcLoginOTPListClass": "pf-c-tile",
|
||||
"kcSrOnlyClass": "sr-only",
|
||||
"kcFormSocialAccountListGridClass": "pf-l-grid kc-social-grid",
|
||||
"kcButtonDefaultClass": "btn-default",
|
||||
"kcFormGroupErrorClass": "has-error",
|
||||
"kcSelectAuthListItemDescriptionClass": "pf-l-stack__item select-auth-box-desc",
|
||||
"kcSelectAuthListItemBodyClass": "pf-l-split__item pf-l-stack",
|
||||
"import": "common/keycloak",
|
||||
"kcWebAuthnInternal": "pficon pficon-key",
|
||||
"kcSelectAuthListItemArrowClass": "pf-l-split__item select-auth-box-arrow",
|
||||
"kcCheckClass": "pf-c-check",
|
||||
"kcContentClass": "col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3 col-lg-6 col-lg-offset-3",
|
||||
"kcLogoClass": "login-pf-brand",
|
||||
"kcLoginOTPListItemIconClass": "fa fa-mobile",
|
||||
"kcLoginClass": "login-pf-page",
|
||||
"kcSignUpClass": "login-pf-signup",
|
||||
"kcButtonLargeClass": "btn-lg",
|
||||
"kcFormCardClass": "card-pf",
|
||||
"kcLocaleListClass": "pf-c-dropdown__menu pf-m-align-right",
|
||||
"kcInputClass": "pf-c-form-control",
|
||||
"kcFormGroupClass": "form-group",
|
||||
"kcLogoIdP-paypal": "fa fa-paypal",
|
||||
"kcInputClassCheckbox": "pf-c-check",
|
||||
"kcRecoveryCodesConfirmation": "kc-recovery-codes-confirmation",
|
||||
"kcInputClassRadioInput": "pf-c-radio__input",
|
||||
"kcFormSocialAccountListButtonClass": "pf-c-button pf-m-control pf-m-block kc-social-item kc-social-gray",
|
||||
"kcInputClassCheckboxLabel": "pf-c-check__label",
|
||||
"kcFormOptionsClass": "col-xs-12 col-sm-12 col-md-12 col-lg-12",
|
||||
"kcFormHeaderClass": "login-pf-header",
|
||||
"kcFormSocialAccountGridItem": "pf-l-grid__item",
|
||||
"kcButtonPrimaryClass": "pf-m-primary",
|
||||
"kcInputHelperTextBeforeClass": "pf-c-form__helper-text pf-c-form__helper-text-before",
|
||||
"kcLogoIdP-github": "fa fa-github",
|
||||
"kcLabelClass": "pf-c-form__label pf-c-form__label-text"
|
||||
}
|
||||
};
|
||||
|
||||
const loginUrl = {
|
||||
|
@ -1,31 +1,66 @@
|
||||
import { join as pathJoin, relative as pathRelative, sep as pathSep } from "path";
|
||||
import type { Plugin } from "vite";
|
||||
import * as fs from "fs";
|
||||
import { resolvedViteConfigJsonBasename, nameOfTheGlobal, basenameOfTheKeycloakifyResourcesDir, keycloak_resources } from "../bin/constants";
|
||||
import {
|
||||
resolvedViteConfigJsonBasename,
|
||||
nameOfTheGlobal,
|
||||
basenameOfTheKeycloakifyResourcesDir,
|
||||
keycloak_resources,
|
||||
keycloakifyBuildOptionsForPostPostBuildScriptEnvName
|
||||
} from "../bin/constants";
|
||||
import type { ResolvedViteConfig } from "../bin/keycloakify/buildOptions/resolvedViteConfig";
|
||||
import { getCacheDirPath } from "../bin/keycloakify/buildOptions/getCacheDirPath";
|
||||
import { replaceAll } from "../bin/tools/String.prototype.replaceAll";
|
||||
import { id } from "tsafe/id";
|
||||
import { rm } from "../bin/tools/fs.rm";
|
||||
import { copyKeycloakResourcesToPublic } from "../bin/copy-keycloak-resources-to-public";
|
||||
import { assert } from "tsafe/assert";
|
||||
import type { BuildOptions } from "../bin/keycloakify/buildOptions";
|
||||
import type { UserProvidedBuildOptions } from "../bin/keycloakify/buildOptions/UserProvidedBuildOptions";
|
||||
import MagicString from "magic-string";
|
||||
|
||||
export type Params = UserProvidedBuildOptions & {
|
||||
postBuild?: (buildOptions: Omit<BuildOptions, "bundler">) => Promise<void>;
|
||||
};
|
||||
|
||||
export function keycloakify(params?: Params) {
|
||||
const { postBuild, ...userProvidedBuildOptions } = params ?? {};
|
||||
|
||||
export function keycloakify() {
|
||||
let reactAppRootDirPath: string | undefined = undefined;
|
||||
let urlPathname: string | undefined = undefined;
|
||||
let buildDirPath: string | undefined = undefined;
|
||||
let command: "build" | "serve" | undefined = undefined;
|
||||
let shouldGenerateSourcemap: boolean | undefined = undefined;
|
||||
|
||||
const plugin = {
|
||||
"name": "keycloakify" as const,
|
||||
"configResolved": async resolvedConfig => {
|
||||
shouldGenerateSourcemap = resolvedConfig.build.sourcemap !== false;
|
||||
|
||||
run_post_build_script: {
|
||||
const buildOptionJson = process.env[keycloakifyBuildOptionsForPostPostBuildScriptEnvName];
|
||||
|
||||
if (buildOptionJson === undefined) {
|
||||
break run_post_build_script;
|
||||
}
|
||||
|
||||
if (postBuild === undefined) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const buildOptions: BuildOptions = JSON.parse(buildOptionJson);
|
||||
|
||||
await postBuild(buildOptions);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
command = resolvedConfig.command;
|
||||
|
||||
reactAppRootDirPath = resolvedConfig.root;
|
||||
urlPathname = (() => {
|
||||
let out = resolvedConfig.env.BASE_URL;
|
||||
|
||||
if (out.startsWith(".") && command === "build") {
|
||||
if (out.startsWith(".") && command === "build" && resolvedConfig.envPrefix?.includes("STORYBOOK_") !== true) {
|
||||
throw new Error(
|
||||
[
|
||||
`BASE_URL=${out} is not supported By Keycloakify. Use an absolute URL instead.`,
|
||||
@ -67,7 +102,8 @@ export function keycloakify() {
|
||||
"publicDir": pathRelative(reactAppRootDirPath, resolvedConfig.publicDir),
|
||||
"assetsDir": resolvedConfig.build.assetsDir,
|
||||
"buildDir": resolvedConfig.build.outDir,
|
||||
urlPathname
|
||||
urlPathname,
|
||||
userProvidedBuildOptions
|
||||
}),
|
||||
null,
|
||||
2
|
||||
@ -82,6 +118,7 @@ export function keycloakify() {
|
||||
},
|
||||
"transform": (code, id) => {
|
||||
assert(command !== undefined);
|
||||
assert(shouldGenerateSourcemap !== undefined);
|
||||
|
||||
if (command !== "build") {
|
||||
return;
|
||||
@ -89,49 +126,53 @@ export function keycloakify() {
|
||||
|
||||
assert(reactAppRootDirPath !== undefined);
|
||||
|
||||
let transformedCode: string | undefined = undefined;
|
||||
{
|
||||
const isWithinSourceDirectory = id.startsWith(pathJoin(reactAppRootDirPath, "src") + pathSep);
|
||||
|
||||
replace_import_meta_env_base_url_in_source_code: {
|
||||
{
|
||||
const isWithinSourceDirectory = id.startsWith(pathJoin(reactAppRootDirPath, "src") + pathSep);
|
||||
|
||||
if (!isWithinSourceDirectory) {
|
||||
break replace_import_meta_env_base_url_in_source_code;
|
||||
}
|
||||
if (!isWithinSourceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
const isJavascriptFile = id.endsWith(".js") || id.endsWith(".jsx");
|
||||
const isTypeScriptFile = id.endsWith(".ts") || id.endsWith(".tsx");
|
||||
|
||||
if (!isTypeScriptFile && !isJavascriptFile) {
|
||||
break replace_import_meta_env_base_url_in_source_code;
|
||||
}
|
||||
}
|
||||
|
||||
if (transformedCode === undefined) {
|
||||
transformedCode = code;
|
||||
}
|
||||
|
||||
transformedCode = replaceAll(
|
||||
transformedCode,
|
||||
"import.meta.env.BASE_URL",
|
||||
[
|
||||
`(`,
|
||||
`(window.${nameOfTheGlobal} === undefined || import.meta.env.MODE === "development")?`,
|
||||
`"${urlPathname ?? "/"}":`,
|
||||
`(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/")`,
|
||||
`)`
|
||||
].join("")
|
||||
);
|
||||
}
|
||||
|
||||
if (transformedCode === undefined) {
|
||||
{
|
||||
const isJavascriptFile = id.endsWith(".js") || id.endsWith(".jsx");
|
||||
const isTypeScriptFile = id.endsWith(".ts") || id.endsWith(".tsx");
|
||||
|
||||
if (!isTypeScriptFile && !isJavascriptFile) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const transformedCode = new MagicString(code);
|
||||
|
||||
transformedCode.replaceAll(
|
||||
/import\.meta\.env(?:(?:\.BASE_URL)|(?:\["BASE_URL"\]))/g,
|
||||
[
|
||||
`(`,
|
||||
`(window.${nameOfTheGlobal} === undefined || import.meta.env.MODE === "development")?`,
|
||||
`"${urlPathname ?? "/"}":`,
|
||||
`(window.${nameOfTheGlobal}.url.resourcesPath + "/${basenameOfTheKeycloakifyResourcesDir}/")`,
|
||||
`)`
|
||||
].join("")
|
||||
);
|
||||
|
||||
if (!transformedCode.hasChanged()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldGenerateSourcemap) {
|
||||
return transformedCode.toString();
|
||||
}
|
||||
|
||||
const map = transformedCode.generateMap({
|
||||
"source": id,
|
||||
"includeContent": true,
|
||||
"hires": true
|
||||
});
|
||||
|
||||
return {
|
||||
"code": transformedCode
|
||||
"code": transformedCode.toString(),
|
||||
"map": map.toString()
|
||||
};
|
||||
},
|
||||
"closeBundle": async () => {
|
||||
|
@ -1665,7 +1665,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
|
||||
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
|
||||
|
||||
"@jridgewell/sourcemap-codec@^1.4.10":
|
||||
"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.15":
|
||||
version "1.4.15"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
|
||||
integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
|
||||
@ -8352,6 +8352,13 @@ lz-string@^1.4.4:
|
||||
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
|
||||
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
|
||||
|
||||
magic-string@^0.30.7:
|
||||
version "0.30.7"
|
||||
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.7.tgz#0cecd0527d473298679da95a2d7aeb8c64048505"
|
||||
integrity sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec" "^1.4.15"
|
||||
|
||||
make-dir@^2.0.0, make-dir@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
|
||||
|
Loading…
x
Reference in New Issue
Block a user