mirror of
https://github.com/dbeaver/cloudbeaver.git
synced 2026-09-24 16:04:36 +08:00
Merge pull request #42 from dbeaver/feat-authentication
refactor(authentication): reuse Tab block
This commit is contained in:
@@ -7,12 +7,11 @@
|
||||
*/
|
||||
|
||||
import { observer } from 'mobx-react';
|
||||
import {
|
||||
useTabState, Tab as BaseTab, TabList
|
||||
} from 'reakit/Tab';
|
||||
import styled, { css } from 'reshadow';
|
||||
|
||||
import { SubmittingForm, ErrorMessage } from '@dbeaver/core/blocks';
|
||||
import {
|
||||
SubmittingForm, ErrorMessage, TabsState, TabList, Tab, TabTitle
|
||||
} from '@dbeaver/core/blocks';
|
||||
import { useController } from '@dbeaver/core/di';
|
||||
import { DialogComponent, CommonDialogWrapper } from '@dbeaver/core/dialogs';
|
||||
import { useTranslate } from '@dbeaver/core/localization';
|
||||
@@ -24,7 +23,7 @@ import { AuthProviderForm } from './AuthProviderForm/AuthProviderForm';
|
||||
|
||||
const styles = composes(
|
||||
css`
|
||||
BaseTab {
|
||||
Tab {
|
||||
composes: theme-ripple theme-background-secondary theme-text-on-secondary from global;
|
||||
}
|
||||
ErrorMessage {
|
||||
@@ -53,34 +52,19 @@ const styles = composes(
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
BaseTab {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
TabList {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
padding-left: 24px;
|
||||
}
|
||||
BaseTab {
|
||||
Tab {
|
||||
composes: theme-typography--body2 from global;
|
||||
text-transform: uppercase;
|
||||
padding: 12px 16px;
|
||||
border-top: solid 2px transparent;
|
||||
height: 48px;
|
||||
font-weight: normal;
|
||||
|
||||
&:global([aria-selected='true']) {
|
||||
border-top-color: #fd1d48;
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:global([aria-selected='true'])) {
|
||||
cursor: pointer;
|
||||
background-color: transparent !important;
|
||||
&:global([aria-selected=true]) {
|
||||
font-weight: normal !important;
|
||||
}
|
||||
}
|
||||
AuthProviderForm {
|
||||
@@ -99,56 +83,52 @@ export const AuthDialog: DialogComponent<null, null> = observer(
|
||||
function AuthDialog(props) {
|
||||
const controller = useController(AuthDialogController, props.rejectDialog);
|
||||
const translate = useTranslate();
|
||||
const tab = useTabState({
|
||||
selectedId: controller.provider?.id,
|
||||
});
|
||||
tab.selectedId = controller.provider?.id || null;
|
||||
|
||||
return styled(useStyles(styles))(
|
||||
<CommonDialogWrapper
|
||||
title={translate('authentication_login_dialog_title')}
|
||||
noBodyPadding
|
||||
header={(
|
||||
<TabList {...tab} aria-label="My tabs">
|
||||
{controller.providers.map(provider => (
|
||||
<BaseTab
|
||||
{...tab}
|
||||
key={provider.id}
|
||||
type='button'
|
||||
stopId={provider.id}
|
||||
onClick={() => controller.selectProvider(provider.id)}
|
||||
>
|
||||
{provider.label}
|
||||
</BaseTab>
|
||||
))}
|
||||
</TabList>
|
||||
)}
|
||||
footer={(
|
||||
<AuthDialogFooter
|
||||
isAuthenticating={controller.isAuthenticating}
|
||||
onLogin={controller.login}
|
||||
/>
|
||||
)}
|
||||
onReject={props.options?.persistent ? undefined : props.rejectDialog}
|
||||
>
|
||||
<SubmittingForm onSubmit={controller.login}>
|
||||
{controller.provider && (
|
||||
<AuthProviderForm
|
||||
provider={controller.provider}
|
||||
credentials={controller.credentials}
|
||||
authenticate={controller.isAuthenticating}
|
||||
<TabsState currentTabId={controller.provider?.id || null}>
|
||||
<CommonDialogWrapper
|
||||
title={translate('authentication_login_dialog_title')}
|
||||
noBodyPadding
|
||||
header={(
|
||||
<TabList>
|
||||
{controller.providers.map(provider => (
|
||||
<Tab
|
||||
key={provider.id}
|
||||
tabId={provider.id}
|
||||
onOpen={() => controller.selectProvider(provider.id)}
|
||||
>
|
||||
<TabTitle title={provider.label} />
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
)}
|
||||
footer={(
|
||||
<AuthDialogFooter
|
||||
isAuthenticating={controller.isAuthenticating}
|
||||
onLogin={controller.login}
|
||||
/>
|
||||
)}
|
||||
{!controller.provider && <>Select available provider</>}
|
||||
</SubmittingForm>
|
||||
{controller.error.responseMessage && (
|
||||
<ErrorMessage
|
||||
text={controller.error.responseMessage}
|
||||
hasDetails={controller.error.hasDetails}
|
||||
onShowDetails={controller.showDetails}
|
||||
/>
|
||||
)}
|
||||
</CommonDialogWrapper>
|
||||
onReject={props.options?.persistent ? undefined : props.rejectDialog}
|
||||
>
|
||||
<SubmittingForm onSubmit={controller.login}>
|
||||
{controller.provider && (
|
||||
<AuthProviderForm
|
||||
provider={controller.provider}
|
||||
credentials={controller.credentials}
|
||||
authenticate={controller.isAuthenticating}
|
||||
/>
|
||||
)}
|
||||
{!controller.provider && <>Select available provider</>}
|
||||
</SubmittingForm>
|
||||
{controller.error.responseMessage && (
|
||||
<ErrorMessage
|
||||
text={controller.error.responseMessage}
|
||||
hasDetails={controller.error.hasDetails}
|
||||
onShowDetails={controller.showDetails}
|
||||
/>
|
||||
)}
|
||||
</CommonDialogWrapper>
|
||||
</TabsState>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -36,19 +36,15 @@
|
||||
Tab {
|
||||
composes: theme-typography--body2 from global;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
height: 48px;
|
||||
padding: 0 24px 0 12px;
|
||||
border-top: solid 2px transparent;
|
||||
|
||||
&:global([aria-selected=true]) {
|
||||
&:global([aria-selected="true"]) {
|
||||
font-weight: 500;
|
||||
cursor: auto;
|
||||
border-top-color: $color-negative;
|
||||
@@ -58,52 +54,64 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:global([aria-selected=true])) {
|
||||
&:not(:global([aria-selected="true"])) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
tab-icon {
|
||||
height: 22px;
|
||||
padding-right: 8px;
|
||||
tab-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
|
||||
& StaticImage,
|
||||
& img {
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
& placeholder {
|
||||
width: 22px;
|
||||
tab-icon {
|
||||
height: 22px;
|
||||
padding-left: 12px;
|
||||
|
||||
StaticImage,
|
||||
img {
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
placeholder {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tab-title placeholder {
|
||||
width: 80px;
|
||||
height: 16px;
|
||||
}
|
||||
tab-title {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
tab-action:before {
|
||||
content: "";
|
||||
display: block;
|
||||
left: -2px;
|
||||
top: -2px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
}
|
||||
tab-title placeholder {
|
||||
width: 80px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
tab-action {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
align-self: flex-start;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
|
||||
& Icon {
|
||||
tab-action:before {
|
||||
content: "";
|
||||
display: block;
|
||||
left: -4px;
|
||||
top: 0;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tab-action {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
align-self: flex-start;
|
||||
padding: 4px;
|
||||
padding-left: 0;
|
||||
|
||||
Icon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { TabsContext } from '../TabsContext';
|
||||
type TabProps = PropsWithChildren<{
|
||||
tabId: string;
|
||||
className?: string;
|
||||
onOpen: (tabId: string) => void;
|
||||
onOpen?: (tabId: string) => void;
|
||||
onClose?: (tabId: string) => void;
|
||||
}>
|
||||
|
||||
@@ -36,8 +36,10 @@ export function Tab({
|
||||
|
||||
const handleOpen = useCallback((e: React.MouseEvent<any>) => {
|
||||
e.preventDefault();
|
||||
onOpen(tabId);
|
||||
}, []);
|
||||
if (onOpen) {
|
||||
onOpen(tabId);
|
||||
}
|
||||
}, [onOpen]);
|
||||
|
||||
const handleClose = useCallback((e: React.MouseEvent<any>) => {
|
||||
e.preventDefault();
|
||||
@@ -54,12 +56,14 @@ export function Tab({
|
||||
className={className}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
{children}
|
||||
{onClose && (
|
||||
<tab-action as="div" onClick={handleClose}>
|
||||
<Icon name="cross-bold" viewBox="0 0 7 8"/>
|
||||
</tab-action>
|
||||
)}
|
||||
<tab-container as='div'>
|
||||
{children}
|
||||
{onClose && (
|
||||
<tab-action as="div" onClick={handleClose}>
|
||||
<Icon name="cross-bold" viewBox="0 0 7 8"/>
|
||||
</tab-action>
|
||||
)}
|
||||
</tab-container>
|
||||
</BaseTab>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* cloudbeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020 DBeaver Corp and others
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0.
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import { useContext } from 'react';
|
||||
import { TabList as BaseTabList, TabListOptions } from 'reakit/Tab';
|
||||
|
||||
import { TabsContext } from './TabsContext';
|
||||
|
||||
export function TabList(props: React.PropsWithChildren<TabListOptions>) {
|
||||
const state = useContext(TabsContext);
|
||||
|
||||
if (!state) {
|
||||
throw new Error('Tabs context was not provided');
|
||||
}
|
||||
|
||||
return <BaseTabList {...props} {...state} />;
|
||||
}
|
||||
@@ -6,15 +6,12 @@
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
useMemo, PropsWithChildren, ReactNode, useEffect,
|
||||
} from 'react';
|
||||
import { useTabState } from 'reakit/Tab';
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
import styled from 'reshadow';
|
||||
|
||||
import { useStyles, Style } from '@dbeaver/core/theming';
|
||||
|
||||
import { TabsContext } from '../TabsContext';
|
||||
import { TabsState } from '../TabsState';
|
||||
|
||||
type TabsBoxProps = PropsWithChildren<{
|
||||
currentTabId: string;
|
||||
@@ -30,16 +27,9 @@ export function TabsBox({
|
||||
className,
|
||||
style = [],
|
||||
}: TabsBoxProps) {
|
||||
const state = useTabState({
|
||||
selectedId: currentTabId,
|
||||
manual: true,
|
||||
});
|
||||
state.selectedId = currentTabId;
|
||||
useEffect(() => state.select(currentTabId), [currentTabId]); // hack currentId and selectedId not works
|
||||
const value = useMemo(() => state, Object.values(state));
|
||||
|
||||
return styled(useStyles(...style))(
|
||||
<TabsContext.Provider value={value}>
|
||||
<TabsState currentTabId={currentTabId}>
|
||||
<tabs-box as="div" className={className}>
|
||||
{tabs && (
|
||||
<tabs as="div">
|
||||
@@ -50,6 +40,6 @@ export function TabsBox({
|
||||
{children}
|
||||
</tab-panels>
|
||||
</tabs-box>
|
||||
</TabsContext.Provider>
|
||||
</TabsState>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* cloudbeaver - Cloud Database Manager
|
||||
* Copyright (C) 2020 DBeaver Corp and others
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0.
|
||||
* you may not use this file except in compliance with the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTabState } from 'reakit/Tab';
|
||||
|
||||
import { TabsContext } from './TabsContext';
|
||||
|
||||
type Props = React.PropsWithChildren<{
|
||||
selectedId?: string;
|
||||
currentTabId?: string | null;
|
||||
}>
|
||||
|
||||
export function TabsState({ selectedId, currentTabId, children }: Props) {
|
||||
const state = useTabState({
|
||||
selectedId: selectedId || currentTabId,
|
||||
manual: true,
|
||||
});
|
||||
if (currentTabId) {
|
||||
state.selectedId = currentTabId;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!currentTabId) {
|
||||
return;
|
||||
}
|
||||
state.select(currentTabId);
|
||||
}, [currentTabId]); // hack currentId and selectedId not works
|
||||
const value = useMemo(() => state, Object.values(state));
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={value}>
|
||||
{children}
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export * from './Tab';
|
||||
export * from './TabPanel';
|
||||
export * from './TabsContext';
|
||||
export * from './TabsState';
|
||||
export * from './TabList';
|
||||
|
||||
export * from './ITab';
|
||||
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ export const ConnectionForm = observer(function ConnectionForm({
|
||||
const translate = useTranslate();
|
||||
|
||||
return styled(useStyles(formStyles))(
|
||||
<>
|
||||
<connection-form as='div'>
|
||||
<connection-type as="div">
|
||||
<Radio
|
||||
name="type"
|
||||
@@ -74,6 +74,6 @@ export const ConnectionForm = observer(function ConnectionForm({
|
||||
</group>
|
||||
)}
|
||||
{!driver?.anonymousAccess && <CredentialsForm controller={controller}/>}
|
||||
</>
|
||||
</connection-form>
|
||||
);
|
||||
});
|
||||
|
||||
+4
@@ -9,6 +9,10 @@
|
||||
import { css } from 'reshadow';
|
||||
|
||||
export const formStyles = css`
|
||||
connection-form {
|
||||
flex-direction: column;
|
||||
padding: 18px 24px;
|
||||
}
|
||||
connection-type {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
+54
-69
@@ -7,13 +7,13 @@
|
||||
*/
|
||||
|
||||
import { observer } from 'mobx-react';
|
||||
import {
|
||||
useTabState, Tab as BaseTab, TabList, TabPanel as BaseTabPanel,
|
||||
} from 'reakit/Tab';
|
||||
import { useState } from 'react';
|
||||
import styled, { css } from 'reshadow';
|
||||
|
||||
import { DBDriver } from '@dbeaver/core/app';
|
||||
import { SubmittingForm, ErrorMessage } from '@dbeaver/core/blocks';
|
||||
import {
|
||||
SubmittingForm, ErrorMessage, TabsState, TabList, Tab, TabTitle, TabPanel
|
||||
} from '@dbeaver/core/blocks';
|
||||
import { useController } from '@dbeaver/core/di';
|
||||
import { CommonDialogWrapper } from '@dbeaver/core/dialogs';
|
||||
import { useTranslate } from '@dbeaver/core/localization';
|
||||
@@ -26,7 +26,7 @@ import { DriverProperties } from './DriverProperties/DriverProperties';
|
||||
|
||||
const styles = composes(
|
||||
css`
|
||||
BaseTab {
|
||||
Tab {
|
||||
composes: theme-ripple theme-background-secondary theme-text-on-secondary from global;
|
||||
}
|
||||
ErrorMessage {
|
||||
@@ -52,39 +52,20 @@ const styles = composes(
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
BaseTab, BaseTabPanel {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
TabList {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
width: 100%;
|
||||
padding-left: 24px;
|
||||
}
|
||||
BaseTab {
|
||||
Tab {
|
||||
composes: theme-typography--body2 from global;
|
||||
text-transform: uppercase;
|
||||
padding: 12px 16px;
|
||||
border-top: solid 2px transparent;
|
||||
height: 48px;
|
||||
font-weight: normal;
|
||||
|
||||
&:global([aria-selected='true']) {
|
||||
border-top-color: #fd1d48;
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
&:global([aria-selected=true]) {
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
&:not(:global([aria-selected='true'])) {
|
||||
cursor: pointer;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
BaseTabPanel::first-child {
|
||||
flex-direction: column;
|
||||
padding: 18px 24px;
|
||||
}
|
||||
ErrorMessage {
|
||||
position: sticky;
|
||||
@@ -110,50 +91,54 @@ export const ConnectionFormDialog = observer(
|
||||
}: ConnectionFormDialogProps) {
|
||||
const translate = useTranslate();
|
||||
const controller = useController(ConnectionFormDialogController, driver, onClose);
|
||||
const tab = useTabState({
|
||||
selectedId: 'options',
|
||||
});
|
||||
const [loadProperties, setLoadProperties] = useState(false);
|
||||
|
||||
return styled(useStyles(styles))(
|
||||
<CommonDialogWrapper
|
||||
title={title}
|
||||
noBodyPadding
|
||||
header={(
|
||||
<TabList {...tab} aria-label="My tabs">
|
||||
<BaseTab {...tab} type='button' stopId='options'>{translate('customConnection_options')}</BaseTab>
|
||||
<BaseTab {...tab} type='button' stopId='driver_properties'>{translate('customConnection_properties')}</BaseTab>
|
||||
</TabList>
|
||||
)}
|
||||
footer={(
|
||||
<ConnectionFormDialogFooter
|
||||
isConnecting={controller.isConnecting}
|
||||
onConnectionTest={controller.onTestConnection}
|
||||
onCreateConnection={controller.onCreateConnection}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
onReject={onClose}
|
||||
>
|
||||
<SubmittingForm onSubmit={controller.onCreateConnection}>
|
||||
<BaseTabPanel {...tab} stopId='options'>
|
||||
<ConnectionForm driver={driver} controller={controller} />
|
||||
</BaseTabPanel>
|
||||
<BaseTabPanel {...tab} stopId='driver_properties'>
|
||||
<DriverProperties
|
||||
driver={driver}
|
||||
state={controller.config.properties}
|
||||
isSelected={tab.selectedId === 'driver_properties'}
|
||||
<TabsState selectedId='options'>
|
||||
<CommonDialogWrapper
|
||||
title={title}
|
||||
noBodyPadding
|
||||
header={(
|
||||
<TabList>
|
||||
<Tab tabId='options' >
|
||||
<TabTitle title={translate('customConnection_options')} />
|
||||
</Tab>
|
||||
<Tab tabId='driver_properties' onOpen={() => setLoadProperties(true)} >
|
||||
<TabTitle title={translate('customConnection_properties')} />
|
||||
</Tab>
|
||||
</TabList>
|
||||
)}
|
||||
footer={(
|
||||
<ConnectionFormDialogFooter
|
||||
isConnecting={controller.isConnecting}
|
||||
onConnectionTest={controller.onTestConnection}
|
||||
onCreateConnection={controller.onCreateConnection}
|
||||
onBack={onBack}
|
||||
/>
|
||||
</BaseTabPanel>
|
||||
</SubmittingForm>
|
||||
{controller.error.responseMessage && (
|
||||
<ErrorMessage
|
||||
text={controller.error.responseMessage}
|
||||
hasDetails={controller.error.hasDetails}
|
||||
onShowDetails={controller.onShowDetails}
|
||||
/>
|
||||
)}
|
||||
</CommonDialogWrapper>
|
||||
)}
|
||||
onReject={onClose}
|
||||
>
|
||||
<SubmittingForm onSubmit={controller.onCreateConnection}>
|
||||
<TabPanel tabId='options'>
|
||||
<ConnectionForm driver={driver} controller={controller} />
|
||||
</TabPanel>
|
||||
<TabPanel tabId='driver_properties'>
|
||||
<DriverProperties
|
||||
driver={driver}
|
||||
state={controller.config.properties!}
|
||||
loadProperties={loadProperties}
|
||||
/>
|
||||
</TabPanel>
|
||||
</SubmittingForm>
|
||||
{controller.error.responseMessage && (
|
||||
<ErrorMessage
|
||||
text={controller.error.responseMessage}
|
||||
hasDetails={controller.error.hasDetails}
|
||||
onShowDetails={controller.onShowDetails}
|
||||
/>
|
||||
)}
|
||||
</CommonDialogWrapper>
|
||||
</TabsState>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
+5
-4
@@ -22,6 +22,7 @@ const styles = css`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -32,21 +33,21 @@ type DriverPropertyState = {
|
||||
type DriverPropertiesProps = {
|
||||
driver: DBDriver;
|
||||
state: DriverPropertyState;
|
||||
isSelected: boolean;
|
||||
loadProperties: boolean;
|
||||
}
|
||||
|
||||
export const DriverProperties = observer(function DriverProperties({
|
||||
driver,
|
||||
state,
|
||||
isSelected,
|
||||
loadProperties,
|
||||
}: DriverPropertiesProps) {
|
||||
const controller = useController(DriverPropertiesController, driver);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
if (loadProperties) {
|
||||
controller.loadDriverProperties();
|
||||
}
|
||||
}, [isSelected]);
|
||||
}, [loadProperties]);
|
||||
|
||||
return styled(useStyles(styles))(
|
||||
<properties as="div">
|
||||
|
||||
Reference in New Issue
Block a user