V16: Upgrade Login dependencies to Umbraco 16 (#19433)
* build: updates login page to match Umbraco 16 * build(deps): bump heyapi to latest * chore: simplify logic
This commit is contained in:
1724
src/Umbraco.Web.UI.Login/package-lock.json
generated
1724
src/Umbraco.Web.UI.Login/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -14,12 +14,12 @@
|
||||
"npm": ">=10.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/client-fetch": "^0.10.0",
|
||||
"@hey-api/openapi-ts": "^0.66.3",
|
||||
"@umbraco-cms/backoffice": "15.3.0",
|
||||
"@hey-api/client-fetch": "^0.10.2",
|
||||
"@hey-api/openapi-ts": "^0.67.6",
|
||||
"@umbraco-cms/backoffice": "16.0.0-rc3",
|
||||
"msw": "^2.7.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.6",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"msw": {
|
||||
|
||||
@@ -34,9 +34,9 @@ export default class UmbNewPasswordLayoutElement extends UmbLitElement {
|
||||
super();
|
||||
|
||||
this.consumeContext(UMB_AUTH_CONTEXT, (authContext) => {
|
||||
this._passwordConfiguration = authContext.passwordConfiguration;
|
||||
// Build a pattern
|
||||
let pattern = '';
|
||||
this._passwordConfiguration = authContext?.passwordConfiguration;
|
||||
if (this._passwordConfiguration?.requireDigit) {
|
||||
pattern += '(?=.*\\d)';
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default class UmbLoginPageElement extends UmbLitElement {
|
||||
|
||||
this.consumeContext(UMB_AUTH_CONTEXT, (authContext) => {
|
||||
this.#authContext = authContext;
|
||||
this.supportPersistLogin = authContext.supportsPersistLogin;
|
||||
this.supportPersistLogin = authContext?.supportsPersistLogin ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,168 +1,173 @@
|
||||
import type {UUIButtonState} from '@umbraco-cms/backoffice/external/uui';
|
||||
import {type CSSResultGroup, css, html, nothing, customElement, state} from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from "@umbraco-cms/backoffice/lit-element";
|
||||
import type { UUIButtonState } from '@umbraco-cms/backoffice/external/uui';
|
||||
import { type CSSResultGroup, css, html, nothing, customElement, state } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
|
||||
import { UMB_AUTH_CONTEXT } from '../../contexts';
|
||||
|
||||
@customElement('umb-reset-password-page')
|
||||
export default class UmbResetPasswordPageElement extends UmbLitElement {
|
||||
@state()
|
||||
resetCallState: UUIButtonState = undefined;
|
||||
@state()
|
||||
resetCallState: UUIButtonState = undefined;
|
||||
|
||||
@state()
|
||||
error = '';
|
||||
@state()
|
||||
error = '';
|
||||
|
||||
#handleResetSubmit = async (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
#handleResetSubmit = async (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
|
||||
if (!form) return;
|
||||
if (!form.checkValidity()) return;
|
||||
if (!form) return;
|
||||
if (!form.checkValidity()) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const username = formData.get('email') as string;
|
||||
const formData = new FormData(form);
|
||||
const username = formData.get('email') as string;
|
||||
|
||||
this.resetCallState = 'waiting';
|
||||
const authContext = await this.getContext(UMB_AUTH_CONTEXT);
|
||||
const response = await authContext.resetPassword(username);
|
||||
this.resetCallState = response.error ? 'failed' : 'success';
|
||||
this.error = response.error || '';
|
||||
};
|
||||
this.resetCallState = 'waiting';
|
||||
const authContext = await this.getContext(UMB_AUTH_CONTEXT);
|
||||
if (!authContext) {
|
||||
this.resetCallState = 'failed';
|
||||
this.error = 'Authentication context not available.';
|
||||
return;
|
||||
}
|
||||
const response = await authContext.resetPassword(username);
|
||||
this.resetCallState = response.error ? 'failed' : 'success';
|
||||
this.error = response.error || '';
|
||||
};
|
||||
|
||||
#renderResetPage() {
|
||||
return html`
|
||||
<uui-form>
|
||||
<form id="LoginForm" name="login" @submit="${this.#handleResetSubmit}">
|
||||
<header id="header">
|
||||
<h1>
|
||||
<umb-localize key="auth_forgottenPassword">Forgotten password?</umb-localize>
|
||||
</h1>
|
||||
<span>
|
||||
#renderResetPage() {
|
||||
return html`
|
||||
<uui-form>
|
||||
<form id="LoginForm" name="login" @submit="${this.#handleResetSubmit}">
|
||||
<header id="header">
|
||||
<h1>
|
||||
<umb-localize key="auth_forgottenPassword">Forgotten password?</umb-localize>
|
||||
</h1>
|
||||
<span>
|
||||
<umb-localize key="auth_forgottenPasswordInstruction">
|
||||
An email will be sent to the address specified with a link to reset your password
|
||||
</umb-localize>
|
||||
An email will be sent to the address specified with a link to reset your password
|
||||
</umb-localize>
|
||||
</span>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
<uui-form-layout-item>
|
||||
<uui-label for="email" slot="label" required>
|
||||
<umb-localize key="auth_email">Email</umb-localize>
|
||||
</uui-label>
|
||||
<uui-input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
.label=${this.localize.term('auth_email')}
|
||||
required
|
||||
required-message=${this.localize.term('auth_required')}>
|
||||
</uui-input>
|
||||
</uui-form-layout-item>
|
||||
<uui-form-layout-item>
|
||||
<uui-label for="email" slot="label" required>
|
||||
<umb-localize key="auth_email">Email</umb-localize>
|
||||
</uui-label>
|
||||
<uui-input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
.label=${this.localize.term('auth_email')}
|
||||
required
|
||||
required-message=${this.localize.term('auth_required')}>
|
||||
</uui-input>
|
||||
</uui-form-layout-item>
|
||||
|
||||
${this.#renderErrorMessage()}
|
||||
${this.#renderErrorMessage()}
|
||||
|
||||
<uui-button
|
||||
type="submit"
|
||||
.label=${this.localize.term('auth_submit')}
|
||||
look="primary"
|
||||
color="default"
|
||||
.state=${this.resetCallState}></uui-button>
|
||||
</form>
|
||||
</uui-form>
|
||||
<uui-button
|
||||
type="submit"
|
||||
.label=${this.localize.term('auth_submit')}
|
||||
look="primary"
|
||||
color="default"
|
||||
.state=${this.resetCallState}></uui-button>
|
||||
</form>
|
||||
</uui-form>
|
||||
|
||||
<umb-back-to-login-button style="margin-top: var(--uui-size-space-6)"></umb-back-to-login-button>
|
||||
`;
|
||||
}
|
||||
<umb-back-to-login-button style="margin-top: var(--uui-size-space-6)"></umb-back-to-login-button>
|
||||
`;
|
||||
}
|
||||
|
||||
#renderErrorMessage() {
|
||||
if (!this.error || this.resetCallState !== 'failed') return nothing;
|
||||
#renderErrorMessage() {
|
||||
if (!this.error || this.resetCallState !== 'failed') return nothing;
|
||||
|
||||
return html`<span class="text-danger">${this.error}</span>`;
|
||||
}
|
||||
return html`<span class="text-danger">${this.error}</span>`;
|
||||
}
|
||||
|
||||
#renderConfirmationPage() {
|
||||
return html`
|
||||
<umb-confirmation-layout
|
||||
header=${this.localize.term('auth_forgottenPassword')}
|
||||
message=${this.localize.term('auth_requestPasswordResetConfirmation')}>
|
||||
</umb-confirmation-layout>
|
||||
`;
|
||||
}
|
||||
#renderConfirmationPage() {
|
||||
return html`
|
||||
<umb-confirmation-layout
|
||||
header=${this.localize.term('auth_forgottenPassword')}
|
||||
message=${this.localize.term('auth_requestPasswordResetConfirmation')}>
|
||||
</umb-confirmation-layout>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.resetCallState === 'success' ? this.#renderConfirmationPage() : this.#renderResetPage();
|
||||
}
|
||||
render() {
|
||||
return this.resetCallState === 'success' ? this.#renderConfirmationPage() : this.#renderResetPage();
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
css`
|
||||
#header {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--uui-size-space-5);
|
||||
}
|
||||
static styles: CSSResultGroup = [
|
||||
css`
|
||||
#header {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--uui-size-space-5);
|
||||
}
|
||||
|
||||
#header span {
|
||||
color: var(--uui-color-text-alt); /* TODO Change to uui color when uui gets a muted text variable */
|
||||
font-size: 14px;
|
||||
}
|
||||
#header span {
|
||||
color: var(--uui-color-text-alt); /* TODO Change to uui color when uui gets a muted text variable */
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#header h1 {
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
font-size: var(--header-secondary-font-size);
|
||||
color: var(--uui-color-interactive);
|
||||
line-height: 1.2;
|
||||
}
|
||||
#header h1 {
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
font-size: var(--header-secondary-font-size);
|
||||
color: var(--uui-color-interactive);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--uui-size-layout-2);
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--uui-size-layout-2);
|
||||
}
|
||||
|
||||
uui-form-layout-item {
|
||||
margin: 0;
|
||||
}
|
||||
uui-form-layout-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
uui-input,
|
||||
uui-input-password {
|
||||
width: 100%;
|
||||
height: var(--input-height);
|
||||
border-radius: var(--uui-border-radius);
|
||||
}
|
||||
uui-input,
|
||||
uui-input-password {
|
||||
width: 100%;
|
||||
height: var(--input-height);
|
||||
border-radius: var(--uui-border-radius);
|
||||
}
|
||||
|
||||
uui-input {
|
||||
width: 100%;
|
||||
}
|
||||
uui-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
uui-button {
|
||||
width: 100%;
|
||||
--uui-button-padding-top-factor: 1.5;
|
||||
--uui-button-padding-bottom-factor: 1.5;
|
||||
}
|
||||
uui-button {
|
||||
width: 100%;
|
||||
--uui-button-padding-top-factor: 1.5;
|
||||
--uui-button-padding-bottom-factor: 1.5;
|
||||
}
|
||||
|
||||
#resend {
|
||||
display: inline-flex;
|
||||
font-size: 14px;
|
||||
align-self: center;
|
||||
gap: var(--uui-size-space-1);
|
||||
}
|
||||
#resend {
|
||||
display: inline-flex;
|
||||
font-size: 14px;
|
||||
align-self: center;
|
||||
gap: var(--uui-size-space-1);
|
||||
}
|
||||
|
||||
#resend a {
|
||||
color: var(--uui-color-selected);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
#resend a {
|
||||
color: var(--uui-color-selected);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#resend a:hover {
|
||||
color: var(--uui-color-interactive-emphasis);
|
||||
}
|
||||
`,
|
||||
];
|
||||
#resend a:hover {
|
||||
color: var(--uui-color-interactive-emphasis);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'umb-reset-password-page': UmbResetPasswordPageElement;
|
||||
}
|
||||
interface HTMLElementTagNameMap {
|
||||
'umb-reset-password-page': UmbResetPasswordPageElement;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +1,85 @@
|
||||
import {
|
||||
LoginRequestModel,
|
||||
LoginResponse,
|
||||
ResetPasswordResponse,
|
||||
ValidatePasswordResetCodeResponse,
|
||||
NewPasswordResponse,
|
||||
PasswordConfigurationModel, ValidateInviteCodeResponse, MfaCodeResponse
|
||||
} from "../types.js";
|
||||
LoginRequestModel,
|
||||
LoginResponse,
|
||||
ResetPasswordResponse,
|
||||
ValidatePasswordResetCodeResponse,
|
||||
NewPasswordResponse,
|
||||
PasswordConfigurationModel,
|
||||
ValidateInviteCodeResponse,
|
||||
MfaCodeResponse,
|
||||
} from '../types.js';
|
||||
import { UmbAuthRepository } from './auth.repository.js';
|
||||
import { UmbContextBase } from "@umbraco-cms/backoffice/class-api";
|
||||
import { UmbContextToken } from "@umbraco-cms/backoffice/context-api";
|
||||
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
|
||||
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
|
||||
|
||||
export class UmbAuthContext extends UmbContextBase<UmbAuthContext> {
|
||||
readonly supportsPersistLogin = false;
|
||||
twoFactorView = '';
|
||||
isMfaEnabled = false;
|
||||
mfaProviders: string[] = [];
|
||||
passwordConfiguration?: PasswordConfigurationModel;
|
||||
export class UmbAuthContext extends UmbContextBase {
|
||||
readonly supportsPersistLogin = false;
|
||||
twoFactorView = '';
|
||||
isMfaEnabled = false;
|
||||
mfaProviders: string[] = [];
|
||||
passwordConfiguration?: PasswordConfigurationModel;
|
||||
|
||||
#authRepository = new UmbAuthRepository(this);
|
||||
#authRepository = new UmbAuthRepository(this);
|
||||
|
||||
#returnPath = '';
|
||||
#returnPath = '';
|
||||
|
||||
set returnPath(value: string) {
|
||||
this.#returnPath = value;
|
||||
}
|
||||
set returnPath(value: string) {
|
||||
this.#returnPath = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the return path from the query string.
|
||||
*
|
||||
* It will first look for a `ReturnUrl` parameter, then a `returnPath` parameter, and finally the `returnPath` property.
|
||||
*
|
||||
* @returns The return path from the query string.
|
||||
*/
|
||||
get returnPath(): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let returnPath = params.get('ReturnUrl') ?? params.get('returnPath') ?? this.#returnPath;
|
||||
/**
|
||||
* Gets the return path from the query string.
|
||||
*
|
||||
* It will first look for a `ReturnUrl` parameter, then a `returnPath` parameter, and finally the `returnPath` property.
|
||||
*
|
||||
* @returns The return path from the query string.
|
||||
*/
|
||||
get returnPath(): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let returnPath = params.get('ReturnUrl') ?? params.get('returnPath') ?? this.#returnPath;
|
||||
|
||||
// If return path is empty, return an empty string.
|
||||
if (!returnPath) {
|
||||
return '';
|
||||
}
|
||||
// If return path is empty, return an empty string.
|
||||
if (!returnPath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Safely check that the return path is valid and doesn't link to an external site.
|
||||
const url = new URL(returnPath, window.location.origin);
|
||||
// Safely check that the return path is valid and doesn't link to an external site.
|
||||
const url = new URL(returnPath, window.location.origin);
|
||||
|
||||
if (url.origin !== window.location.origin) {
|
||||
return '';
|
||||
}
|
||||
if (url.origin !== window.location.origin) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
login(data: LoginRequestModel): Promise<LoginResponse> {
|
||||
return this.#authRepository.login(data);
|
||||
}
|
||||
login(data: LoginRequestModel): Promise<LoginResponse> {
|
||||
return this.#authRepository.login(data);
|
||||
}
|
||||
|
||||
resetPassword(username: string): Promise<ResetPasswordResponse> {
|
||||
return this.#authRepository.resetPassword(username);
|
||||
}
|
||||
resetPassword(username: string): Promise<ResetPasswordResponse> {
|
||||
return this.#authRepository.resetPassword(username);
|
||||
}
|
||||
|
||||
validatePasswordResetCode(userId: string, resetCode: string): Promise<ValidatePasswordResetCodeResponse> {
|
||||
return this.#authRepository.validatePasswordResetCode(userId, resetCode);
|
||||
}
|
||||
validatePasswordResetCode(userId: string, resetCode: string): Promise<ValidatePasswordResetCodeResponse> {
|
||||
return this.#authRepository.validatePasswordResetCode(userId, resetCode);
|
||||
}
|
||||
|
||||
newPassword(password: string, resetCode: string, userId: string): Promise<NewPasswordResponse> {
|
||||
return this.#authRepository.newPassword(password, resetCode, userId);
|
||||
}
|
||||
newPassword(password: string, resetCode: string, userId: string): Promise<NewPasswordResponse> {
|
||||
return this.#authRepository.newPassword(password, resetCode, userId);
|
||||
}
|
||||
|
||||
newInvitedUserPassword(password: string, token: string, userId: string): Promise<NewPasswordResponse> {
|
||||
return this.#authRepository.newInvitedUserPassword(password, token, userId);
|
||||
}
|
||||
newInvitedUserPassword(password: string, token: string, userId: string): Promise<NewPasswordResponse> {
|
||||
return this.#authRepository.newInvitedUserPassword(password, token, userId);
|
||||
}
|
||||
|
||||
validateInviteCode(token: string, userId: string): Promise<ValidateInviteCodeResponse> {
|
||||
return this.#authRepository.validateInviteCode(token, userId);
|
||||
}
|
||||
validateInviteCode(token: string, userId: string): Promise<ValidateInviteCodeResponse> {
|
||||
return this.#authRepository.validateInviteCode(token, userId);
|
||||
}
|
||||
|
||||
validateMfaCode(code: string, provider: string): Promise<MfaCodeResponse> {
|
||||
return this.#authRepository.validateMfaCode(code, provider);
|
||||
}
|
||||
validateMfaCode(code: string, provider: string): Promise<MfaCodeResponse> {
|
||||
return this.#authRepository.validateMfaCode(code, provider);
|
||||
}
|
||||
}
|
||||
|
||||
export const UMB_AUTH_CONTEXT = new UmbContextToken<UmbAuthContext>('UmbAuthContext');
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
ValidateInviteCodeResponse,
|
||||
ValidatePasswordResetCodeResponse,
|
||||
} from '../types.js';
|
||||
import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository';
|
||||
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
|
||||
import {
|
||||
postSecurityForgotPassword,
|
||||
postSecurityForgotPasswordReset,
|
||||
@@ -17,6 +15,8 @@ import {
|
||||
postUserInviteVerify,
|
||||
} from '../api/index.js';
|
||||
import { isProblemDetails } from '../utils/is-problem-details.function.js';
|
||||
import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository';
|
||||
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
|
||||
|
||||
export class UmbAuthRepository extends UmbRepositoryBase {
|
||||
#localize = new UmbLocalizationController(this);
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { UmbBundleExtensionInitializer, UmbServerExtensionRegistrator } from '@umbraco-cms/backoffice/extension-api';
|
||||
import {
|
||||
UmbBundleExtensionInitializer,
|
||||
UmbServerExtensionRegistrator
|
||||
} from "@umbraco-cms/backoffice/extension-api";
|
||||
import {
|
||||
UmbAppEntryPointExtensionInitializer,
|
||||
umbExtensionsRegistry
|
||||
} from "@umbraco-cms/backoffice/extension-registry";
|
||||
import type { UmbElement } from "@umbraco-cms/backoffice/element-api";
|
||||
import { UmbControllerBase } from "@umbraco-cms/backoffice/class-api";
|
||||
import { UUIIconRegistryEssential } from "@umbraco-cms/backoffice/external/uui";
|
||||
UmbAppEntryPointExtensionInitializer,
|
||||
umbExtensionsRegistry,
|
||||
} from '@umbraco-cms/backoffice/extension-registry';
|
||||
import type { UmbElement } from '@umbraco-cms/backoffice/element-api';
|
||||
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
|
||||
import { UUIIconRegistryEssential } from '@umbraco-cms/backoffice/external/uui';
|
||||
|
||||
// We import what we need from the Backoffice app.
|
||||
// In the future the login screen app will be a part of the Backoffice app, and we will not need to import these.
|
||||
@@ -19,17 +16,20 @@ import '@umbraco-cms/backoffice/localization';
|
||||
* It is responsible for initializing the backoffice and only the extensions that is needed to run the login screen.
|
||||
*/
|
||||
export class UmbSlimBackofficeController extends UmbControllerBase {
|
||||
#uuiIconRegistry = new UUIIconRegistryEssential();
|
||||
#uuiIconRegistry = new UUIIconRegistryEssential();
|
||||
|
||||
constructor(host: UmbElement) {
|
||||
super(host);
|
||||
new UmbBundleExtensionInitializer(host, umbExtensionsRegistry);
|
||||
new UmbAppEntryPointExtensionInitializer(host, umbExtensionsRegistry);
|
||||
new UmbServerExtensionRegistrator(host, umbExtensionsRegistry).registerPublicExtensions();
|
||||
constructor(host: UmbElement) {
|
||||
super(host);
|
||||
new UmbBundleExtensionInitializer(host, umbExtensionsRegistry);
|
||||
new UmbAppEntryPointExtensionInitializer(host, umbExtensionsRegistry);
|
||||
new UmbServerExtensionRegistrator(host, umbExtensionsRegistry).registerPublicExtensions().catch(() => {
|
||||
// We don't care about errors here, as this is just a fallback for the login screen.
|
||||
// If the extensions are not registered, the login screen will still work, but some features may not be available.
|
||||
});
|
||||
|
||||
this.#uuiIconRegistry.attach(host);
|
||||
this.#uuiIconRegistry.attach(host);
|
||||
|
||||
host.classList.add('uui-text');
|
||||
host.classList.add('uui-font');
|
||||
}
|
||||
host.classList.add('uui-text');
|
||||
host.classList.add('uui-font');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user