Compare commits
26 Commits
Author | SHA1 | Date | |
---|---|---|---|
80aeabad51 | |||
419e1f473a | |||
80988125e8 | |||
271ad2da71 | |||
b2732f2595 | |||
53820e1e34 | |||
09dd45e437 | |||
1f654a7820 | |||
0690f40bad | |||
2285883149 | |||
af87e41bb8 | |||
9ba884483d | |||
f5a300953a | |||
ab9a962f58 | |||
484adb607f | |||
e1f38d4196 | |||
5de629acf2 | |||
8b4b24a036 | |||
75ab130249 | |||
981ca7e9a4 | |||
acb4e260a7 | |||
ff20b0a844 | |||
1b77c69a01 | |||
158275f5c2 | |||
a085c8093e | |||
cb358bd745 |
@ -327,6 +327,15 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"doc"
|
"doc"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "EternalSide",
|
||||||
|
"name": "Lesha",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/118743608?v=4",
|
||||||
|
"profile": "http://t.me/AAT_L",
|
||||||
|
"contributions": [
|
||||||
|
"code"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"contributorsPerLine": 7,
|
"contributorsPerLine": 7,
|
||||||
|
@ -168,6 +168,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|||||||
<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/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>
|
<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>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="14.28%"><a href="http://t.me/AAT_L"><img src="https://avatars.githubusercontent.com/u/118743608?v=4?s=100" width="100px;" alt="Lesha"/><br /><sub><b>Lesha</b></sub></a><br /><a href="https://github.com/keycloakify/keycloakify/commits?author=EternalSide" title="Code">💻</a></td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "keycloakify",
|
"name": "keycloakify",
|
||||||
"version": "11.8.6",
|
"version": "11.8.15",
|
||||||
"description": "Framework to create custom Keycloak UIs",
|
"description": "Framework to create custom Keycloak UIs",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -74,7 +74,7 @@ export async function command(params: { buildContext: BuildContext }) {
|
|||||||
|
|
||||||
if (themeType === "admin") {
|
if (themeType === "admin") {
|
||||||
console.log(
|
console.log(
|
||||||
`${chalk.red("✗")} Sorry, there is no Storybook support for the Account UI.`
|
`${chalk.red("✗")} Sorry, there is no Storybook support for the Admin UI.`
|
||||||
);
|
);
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
@ -49,12 +49,15 @@ export async function command(params: { buildContext: BuildContext }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { value: emailThemeType } = await cliSelect({
|
const { value: emailThemeType } = await cliSelect({
|
||||||
values: ["native (FreeMarker)" as const, "jsx-email (React)" as const]
|
values: [
|
||||||
|
"native (FreeMarker)" as const,
|
||||||
|
"Another email templating solution" as const
|
||||||
|
]
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
process.exit(-1);
|
process.exit(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (emailThemeType === "jsx-email (React)") {
|
if (emailThemeType === "Another email templating solution") {
|
||||||
console.log(
|
console.log(
|
||||||
[
|
[
|
||||||
"There is currently no automated support for keycloakify-email, it has to be done manually, see documentation:",
|
"There is currently no automated support for keycloakify-email, it has to be done manually, see documentation:",
|
||||||
@ -103,14 +106,21 @@ export async function command(params: { buildContext: BuildContext }) {
|
|||||||
|
|
||||||
const moduleName = `@keycloakify/email-native`;
|
const moduleName = `@keycloakify/email-native`;
|
||||||
|
|
||||||
const [version] = (
|
const [version] = ((): string[] => {
|
||||||
JSON.parse(
|
const cmdOutput = child_process
|
||||||
child_process
|
.execSync(`npm show ${moduleName} versions --json`)
|
||||||
.execSync(`npm show ${moduleName} versions --json`)
|
.toString("utf8")
|
||||||
.toString("utf8")
|
.trim();
|
||||||
.trim()
|
|
||||||
) as string[]
|
const versions = JSON.parse(cmdOutput) as string | string[];
|
||||||
)
|
|
||||||
|
// NOTE: Bug in some older npm versions
|
||||||
|
if (typeof versions === "string") {
|
||||||
|
return [versions];
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions;
|
||||||
|
})()
|
||||||
.reverse()
|
.reverse()
|
||||||
.filter(version => !version.includes("-"));
|
.filter(version => !version.includes("-"));
|
||||||
|
|
||||||
|
@ -105,14 +105,21 @@ export async function initializeSpa(params: {
|
|||||||
|
|
||||||
const moduleName = `@keycloakify/keycloak-${themeType}-ui`;
|
const moduleName = `@keycloakify/keycloak-${themeType}-ui`;
|
||||||
|
|
||||||
const version = (
|
const version = ((): string[] => {
|
||||||
JSON.parse(
|
const cmdOutput = child_process
|
||||||
child_process
|
.execSync(`npm show ${moduleName} versions --json`)
|
||||||
.execSync(`npm show ${moduleName} versions --json`)
|
.toString("utf8")
|
||||||
.toString("utf8")
|
.trim();
|
||||||
.trim()
|
|
||||||
) as string[]
|
const versions = JSON.parse(cmdOutput) as string | string[];
|
||||||
)
|
|
||||||
|
// NOTE: Bug in some older npm versions
|
||||||
|
if (typeof versions === "string") {
|
||||||
|
return [versions];
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions;
|
||||||
|
})()
|
||||||
.reverse()
|
.reverse()
|
||||||
.filter(version => !version.includes("-"))
|
.filter(version => !version.includes("-"))
|
||||||
.find(version =>
|
.find(version =>
|
||||||
|
@ -53,11 +53,17 @@ export async function command(params: {
|
|||||||
.execSync("docker --version", {
|
.execSync("docker --version", {
|
||||||
stdio: ["ignore", "pipe", "ignore"]
|
stdio: ["ignore", "pipe", "ignore"]
|
||||||
})
|
})
|
||||||
?.toString("utf8");
|
.toString("utf8");
|
||||||
} catch {}
|
} catch {
|
||||||
|
commandOutput = "";
|
||||||
|
}
|
||||||
|
|
||||||
if (commandOutput?.includes("Docker") || commandOutput?.includes("podman")) {
|
commandOutput = commandOutput.trim().toLowerCase();
|
||||||
break exit_if_docker_not_installed;
|
|
||||||
|
for (const term of ["docker", "podman"]) {
|
||||||
|
if (commandOutput.includes(term)) {
|
||||||
|
break exit_if_docker_not_installed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
@ -781,6 +787,40 @@ export async function command(params: {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ignore_patternfly: {
|
||||||
|
if (
|
||||||
|
!isInside({
|
||||||
|
dirPath: pathJoin(
|
||||||
|
buildContext.themeSrcDirPath,
|
||||||
|
"shared",
|
||||||
|
"@patternfly"
|
||||||
|
),
|
||||||
|
filePath
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
break ignore_patternfly;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignore_keycloak_ui_shared: {
|
||||||
|
if (
|
||||||
|
!isInside({
|
||||||
|
dirPath: pathJoin(
|
||||||
|
buildContext.themeSrcDirPath,
|
||||||
|
"shared",
|
||||||
|
"keycloak-ui-shared"
|
||||||
|
),
|
||||||
|
filePath
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
break ignore_keycloak_ui_shared;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Detected changes in ${filePath}`);
|
console.log(`Detected changes in ${filePath}`);
|
||||||
|
@ -769,6 +769,8 @@ export declare namespace Validators {
|
|||||||
export type PasswordPolicies = {
|
export type PasswordPolicies = {
|
||||||
/** The minimum length of the password */
|
/** The minimum length of the password */
|
||||||
length?: number;
|
length?: number;
|
||||||
|
/** The maximum length of the password */
|
||||||
|
maxLength?: number;
|
||||||
/** The minimum number of digits required in the password */
|
/** The minimum number of digits required in the password */
|
||||||
digits?: number;
|
digits?: number;
|
||||||
/** The minimum number of lowercase characters required in the password */
|
/** The minimum number of lowercase characters required in the password */
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import type { JSX } from "keycloakify/tools/JSX";
|
import type { JSX } from "keycloakify/tools/JSX";
|
||||||
import { useEffect, useReducer, Fragment } from "react";
|
import { useEffect, Fragment } from "react";
|
||||||
import { assert } from "keycloakify/tools/assert";
|
import { assert } from "keycloakify/tools/assert";
|
||||||
|
import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
|
||||||
import type { KcClsx } from "keycloakify/login/lib/kcClsx";
|
import type { KcClsx } from "keycloakify/login/lib/kcClsx";
|
||||||
import {
|
import {
|
||||||
useUserProfileForm,
|
useUserProfileForm,
|
||||||
@ -249,15 +250,7 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
|
|||||||
|
|
||||||
const { msgStr } = i18n;
|
const { msgStr } = i18n;
|
||||||
|
|
||||||
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
|
const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const passwordInputElement = document.getElementById(passwordInputId);
|
|
||||||
|
|
||||||
assert(passwordInputElement instanceof HTMLInputElement);
|
|
||||||
|
|
||||||
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
|
|
||||||
}, [isPasswordRevealed]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={kcClsx("kcInputGroup")}>
|
<div className={kcClsx("kcInputGroup")}>
|
||||||
|
@ -509,6 +509,8 @@ function formStateSelector(params: { state: internal.State }): FormState {
|
|||||||
switch (error.source.name) {
|
switch (error.source.name) {
|
||||||
case "length":
|
case "length":
|
||||||
return hasLostFocusAtLeastOnce;
|
return hasLostFocusAtLeastOnce;
|
||||||
|
case "maxLength":
|
||||||
|
return hasLostFocusAtLeastOnce;
|
||||||
case "digits":
|
case "digits":
|
||||||
return hasLostFocusAtLeastOnce;
|
return hasLostFocusAtLeastOnce;
|
||||||
case "lowerCase":
|
case "lowerCase":
|
||||||
@ -967,6 +969,34 @@ function createGetErrors(params: { kcContext: KcContextLike_useGetErrors }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_password_policy_x: {
|
||||||
|
const policyName = "maxLength";
|
||||||
|
|
||||||
|
const policy = passwordPolicies[policyName];
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
break check_password_policy_x;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLength = policy;
|
||||||
|
|
||||||
|
if (value.length <= maxLength) {
|
||||||
|
break check_password_policy_x;
|
||||||
|
}
|
||||||
|
|
||||||
|
errors.push({
|
||||||
|
advancedMsgArgs: [
|
||||||
|
"invalidPasswordMaxLengthMessage" satisfies MessageKey_defaultSet,
|
||||||
|
`${maxLength}`
|
||||||
|
] as const,
|
||||||
|
fieldIndex: undefined,
|
||||||
|
source: {
|
||||||
|
type: "passwordPolicy",
|
||||||
|
name: policyName
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
check_password_policy_x: {
|
check_password_policy_x: {
|
||||||
const policyName = "digits";
|
const policyName = "digits";
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import type { JSX } from "keycloakify/tools/JSX";
|
import type { JSX } from "keycloakify/tools/JSX";
|
||||||
import { useState, useEffect, useReducer } from "react";
|
import { useState } from "react";
|
||||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||||
import { assert } from "keycloakify/tools/assert";
|
import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
|
||||||
import { clsx } from "keycloakify/tools/clsx";
|
import { clsx } from "keycloakify/tools/clsx";
|
||||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||||
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
|
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
|
||||||
@ -200,15 +200,7 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
|
|||||||
|
|
||||||
const { msgStr } = i18n;
|
const { msgStr } = i18n;
|
||||||
|
|
||||||
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
|
const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const passwordInputElement = document.getElementById(passwordInputId);
|
|
||||||
|
|
||||||
assert(passwordInputElement instanceof HTMLInputElement);
|
|
||||||
|
|
||||||
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
|
|
||||||
}, [isPasswordRevealed]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={kcClsx("kcInputGroup")}>
|
<div className={kcClsx("kcInputGroup")}>
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import type { JSX } from "keycloakify/tools/JSX";
|
import type { JSX } from "keycloakify/tools/JSX";
|
||||||
import { useState, useEffect, useReducer } from "react";
|
import { useState } from "react";
|
||||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||||
import { clsx } from "keycloakify/tools/clsx";
|
import { clsx } from "keycloakify/tools/clsx";
|
||||||
import { assert } from "keycloakify/tools/assert";
|
import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
|
||||||
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
|
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
|
||||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||||
import type { KcContext } from "../KcContext";
|
import type { KcContext } from "../KcContext";
|
||||||
@ -107,15 +107,7 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
|
|||||||
|
|
||||||
const { msgStr } = i18n;
|
const { msgStr } = i18n;
|
||||||
|
|
||||||
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
|
const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const passwordInputElement = document.getElementById(passwordInputId);
|
|
||||||
|
|
||||||
assert(passwordInputElement instanceof HTMLInputElement);
|
|
||||||
|
|
||||||
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
|
|
||||||
}, [isPasswordRevealed]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={kcClsx("kcInputGroup")}>
|
<div className={kcClsx("kcInputGroup")}>
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import type { JSX } from "keycloakify/tools/JSX";
|
import type { JSX } from "keycloakify/tools/JSX";
|
||||||
import { useEffect, useReducer } from "react";
|
import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
|
||||||
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
import { kcSanitize } from "keycloakify/lib/kcSanitize";
|
||||||
import { assert } from "keycloakify/tools/assert";
|
|
||||||
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
|
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
|
||||||
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
import type { PageProps } from "keycloakify/login/pages/PageProps";
|
||||||
import type { KcContext } from "../KcContext";
|
import type { KcContext } from "../KcContext";
|
||||||
@ -146,15 +145,7 @@ function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: s
|
|||||||
|
|
||||||
const { msgStr } = i18n;
|
const { msgStr } = i18n;
|
||||||
|
|
||||||
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer((isPasswordRevealed: boolean) => !isPasswordRevealed, false);
|
const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const passwordInputElement = document.getElementById(passwordInputId);
|
|
||||||
|
|
||||||
assert(passwordInputElement instanceof HTMLInputElement);
|
|
||||||
|
|
||||||
passwordInputElement.type = isPasswordRevealed ? "text" : "password";
|
|
||||||
}, [isPasswordRevealed]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={kcClsx("kcInputGroup")}>
|
<div className={kcClsx("kcInputGroup")}>
|
||||||
|
@ -98,7 +98,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
|
|||||||
defaultValue={login.username ?? ""}
|
defaultValue={login.username ?? ""}
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
autoComplete="off"
|
autoComplete="username"
|
||||||
aria-invalid={messagesPerField.existsError("username")}
|
aria-invalid={messagesPerField.existsError("username")}
|
||||||
/>
|
/>
|
||||||
{messagesPerField.existsError("username") && (
|
{messagesPerField.existsError("username") && (
|
||||||
|
45
src/tools/useIsPasswordRevealed.ts
Normal file
45
src/tools/useIsPasswordRevealed.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useReducer } from "react";
|
||||||
|
import { assert } from "keycloakify/tools/assert";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initially false, state that enables to dynamically control if
|
||||||
|
* the type of a password input is "password" (false) or "text" (true).
|
||||||
|
*/
|
||||||
|
export function useIsPasswordRevealed(params: { passwordInputId: string }) {
|
||||||
|
const { passwordInputId } = params;
|
||||||
|
|
||||||
|
const [isPasswordRevealed, toggleIsPasswordRevealed] = useReducer(
|
||||||
|
(isPasswordRevealed: boolean) => !isPasswordRevealed,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const passwordInputElement = document.getElementById(passwordInputId);
|
||||||
|
|
||||||
|
assert(passwordInputElement instanceof HTMLInputElement);
|
||||||
|
|
||||||
|
const type = isPasswordRevealed ? "text" : "password";
|
||||||
|
|
||||||
|
passwordInputElement.type = type;
|
||||||
|
|
||||||
|
const observer = new MutationObserver(mutations => {
|
||||||
|
mutations.forEach(mutation => {
|
||||||
|
if (mutation.attributeName !== "type") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (passwordInputElement.type === type) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
passwordInputElement.type = type;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(passwordInputElement, { attributes: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [isPasswordRevealed]);
|
||||||
|
|
||||||
|
return { isPasswordRevealed, toggleIsPasswordRevealed };
|
||||||
|
}
|
Reference in New Issue
Block a user