Merge branch 'develop' into feature/subscription-info-in-preferences
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { FunctionComponent } from 'preact';
|
import { FunctionComponent } from 'preact';
|
||||||
import { MenuItem } from './components';
|
import { MenuItem } from './components';
|
||||||
import { PreferencesMenu } from './preferences-menu';
|
import { PreferencesMenu } from './PreferencesMenu';
|
||||||
|
|
||||||
export const PreferencesMenuView: FunctionComponent<{
|
export const PreferencesMenuView: FunctionComponent<{
|
||||||
menu: PreferencesMenu;
|
menu: PreferencesMenu;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { TitleBar, Title } from '@/components/TitleBar';
|
|||||||
import { FunctionComponent } from 'preact';
|
import { FunctionComponent } from 'preact';
|
||||||
import { AccountPreferences, HelpAndFeedback, Security } from './panes';
|
import { AccountPreferences, HelpAndFeedback, Security } from './panes';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { PreferencesMenu } from './preferences-menu';
|
import { PreferencesMenu } from './PreferencesMenu';
|
||||||
import { PreferencesMenuView } from './PreferencesMenuView';
|
import { PreferencesMenuView } from './PreferencesMenuView';
|
||||||
import { WebApplication } from '@/ui_models/application';
|
import { WebApplication } from '@/ui_models/application';
|
||||||
import { MfaProps } from './panes/two-factor-auth/MfaProps';
|
import { MfaProps } from './panes/two-factor-auth/MfaProps';
|
||||||
@@ -24,7 +24,12 @@ const PaneSelector: FunctionComponent<
|
|||||||
case 'appearance':
|
case 'appearance':
|
||||||
return null;
|
return null;
|
||||||
case 'security':
|
case 'security':
|
||||||
return <Security mfaGateway={props.mfaGateway} />;
|
return (
|
||||||
|
<Security
|
||||||
|
mfaProvider={props.mfaProvider}
|
||||||
|
userProvider={props.userProvider}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'listed':
|
case 'listed':
|
||||||
return null;
|
return null;
|
||||||
case 'shortcuts':
|
case 'shortcuts':
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export const PreferencesViewWrapper: FunctionComponent<PreferencesViewWrapperPro
|
|||||||
<PreferencesView
|
<PreferencesView
|
||||||
closePreferences={() => appState.preferences.closePreferences()}
|
closePreferences={() => appState.preferences.closePreferences()}
|
||||||
application={application}
|
application={application}
|
||||||
mfaGateway={application}
|
mfaProvider={application}
|
||||||
|
userProvider={application}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export const PreferencesGroup: FunctionComponent = ({ children }) => (
|
|||||||
<div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col gap-2">
|
<div className="bg-default border-1 border-solid rounded border-gray-300 px-6 py-6 flex flex-col gap-2">
|
||||||
{Array.isArray(children)
|
{Array.isArray(children)
|
||||||
? children
|
? children
|
||||||
.filter((child) => child != undefined && child !== '')
|
.filter(
|
||||||
|
(child) => child != undefined && child !== '' && child !== false
|
||||||
|
)
|
||||||
.map((child, i, arr) => (
|
.map((child, i, arr) => (
|
||||||
<>
|
<>
|
||||||
{child}
|
{child}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { PreferencesPane } from '../components';
|
|||||||
import { TwoFactorAuthWrapper } from './two-factor-auth';
|
import { TwoFactorAuthWrapper } from './two-factor-auth';
|
||||||
import { MfaProps } from './two-factor-auth/MfaProps';
|
import { MfaProps } from './two-factor-auth/MfaProps';
|
||||||
|
|
||||||
interface SecurityProps extends MfaProps {}
|
export const Security: FunctionComponent<MfaProps> = (props) => (
|
||||||
|
|
||||||
export const Security: FunctionComponent<SecurityProps> = (props) => (
|
|
||||||
<PreferencesPane>
|
<PreferencesPane>
|
||||||
<TwoFactorAuthWrapper mfaGateway={props.mfaGateway} />
|
<TwoFactorAuthWrapper
|
||||||
|
mfaProvider={props.mfaProvider}
|
||||||
|
userProvider={props.userProvider}
|
||||||
|
/>
|
||||||
</PreferencesPane>
|
</PreferencesPane>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
export interface MfaGateway {
|
import { MfaProvider, UserProvider } from '../../providers';
|
||||||
getUser(): { uuid: string; email: string } | undefined;
|
|
||||||
|
|
||||||
isMfaActivated(): Promise<boolean>;
|
|
||||||
|
|
||||||
generateMfaSecret(): Promise<string>;
|
|
||||||
|
|
||||||
getOtpToken(secret: string): Promise<string>;
|
|
||||||
|
|
||||||
enableMfa(secret: string, otpToken: string): Promise<void>;
|
|
||||||
|
|
||||||
disableMfa(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MfaProps {
|
export interface MfaProps {
|
||||||
mfaGateway: MfaGateway;
|
userProvider: UserProvider;
|
||||||
|
mfaProvider: MfaProvider;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { action, makeAutoObservable, observable, untracked } from 'mobx';
|
import { MfaProvider, UserProvider } from '../../providers';
|
||||||
import { MfaGateway } from './MfaProps';
|
import { action, makeAutoObservable, observable } from 'mobx';
|
||||||
|
|
||||||
type ActivationStep = 'scan-qr-code' | 'save-secret-key' | 'verification';
|
type ActivationStep = 'scan-qr-code' | 'save-secret-key' | 'verification';
|
||||||
type VerificationStatus = 'none' | 'invalid' | 'valid';
|
type VerificationStatus = 'none' | 'invalid' | 'valid';
|
||||||
@@ -15,7 +15,8 @@ export class TwoFactorActivation {
|
|||||||
private inputOtpToken = '';
|
private inputOtpToken = '';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private mfaGateway: MfaGateway,
|
private mfaProvider: MfaProvider,
|
||||||
|
private userProvider: UserProvider,
|
||||||
private readonly _secretKey: string,
|
private readonly _secretKey: string,
|
||||||
private _cancelActivation: () => void,
|
private _cancelActivation: () => void,
|
||||||
private _enabled2FA: () => void
|
private _enabled2FA: () => void
|
||||||
@@ -59,7 +60,7 @@ export class TwoFactorActivation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get qrCode(): string {
|
get qrCode(): string {
|
||||||
const email = this.mfaGateway.getUser()!.email;
|
const email = this.userProvider.getUser()!.email;
|
||||||
return `otpauth://totp/2FA?secret=${this._secretKey}&issuer=Standard%20Notes&label=${email}`;
|
return `otpauth://totp/2FA?secret=${this._secretKey}&issuer=Standard%20Notes&label=${email}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ export class TwoFactorActivation {
|
|||||||
|
|
||||||
enable2FA(): void {
|
enable2FA(): void {
|
||||||
if (this.inputSecretKey === this._secretKey) {
|
if (this.inputSecretKey === this._secretKey) {
|
||||||
this.mfaGateway
|
this.mfaProvider
|
||||||
.enableMfa(this.inputSecretKey, this.inputOtpToken)
|
.enableMfa(this.inputSecretKey, this.inputOtpToken)
|
||||||
.then(
|
.then(
|
||||||
action(() => {
|
action(() => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { MfaProvider, UserProvider } from '@/preferences/providers';
|
||||||
import { action, makeAutoObservable, observable } from 'mobx';
|
import { action, makeAutoObservable, observable } from 'mobx';
|
||||||
import { MfaGateway } from './MfaProps';
|
|
||||||
import { TwoFactorActivation } from './TwoFactorActivation';
|
import { TwoFactorActivation } from './TwoFactorActivation';
|
||||||
|
|
||||||
type TwoFactorStatus =
|
type TwoFactorStatus =
|
||||||
@@ -7,20 +7,23 @@ type TwoFactorStatus =
|
|||||||
| TwoFactorActivation
|
| TwoFactorActivation
|
||||||
| 'two-factor-disabled';
|
| 'two-factor-disabled';
|
||||||
|
|
||||||
export const is2FADisabled = (s: TwoFactorStatus): s is 'two-factor-disabled' =>
|
export const is2FADisabled = (status: TwoFactorStatus): status is 'two-factor-disabled' =>
|
||||||
s === 'two-factor-disabled';
|
status === 'two-factor-disabled';
|
||||||
|
|
||||||
export const is2FAActivation = (s: TwoFactorStatus): s is TwoFactorActivation =>
|
export const is2FAActivation = (status: TwoFactorStatus): status is TwoFactorActivation =>
|
||||||
(s as any).type === 'two-factor-activation';
|
(status as TwoFactorActivation)?.type === 'two-factor-activation';
|
||||||
|
|
||||||
export const is2FAEnabled = (s: TwoFactorStatus): s is 'two-factor-enabled' =>
|
export const is2FAEnabled = (status: TwoFactorStatus): status is 'two-factor-enabled' =>
|
||||||
s === 'two-factor-enabled';
|
status === 'two-factor-enabled';
|
||||||
|
|
||||||
export class TwoFactorAuth {
|
export class TwoFactorAuth {
|
||||||
private _status: TwoFactorStatus | 'fetching' = 'fetching';
|
private _status: TwoFactorStatus | 'fetching' = 'fetching';
|
||||||
private _errorMessage: string | null;
|
private _errorMessage: string | null;
|
||||||
|
|
||||||
constructor(private readonly mfaGateway: MfaGateway) {
|
constructor(
|
||||||
|
private readonly mfaProvider: MfaProvider,
|
||||||
|
private readonly userProvider: UserProvider
|
||||||
|
) {
|
||||||
this._errorMessage = null;
|
this._errorMessage = null;
|
||||||
|
|
||||||
makeAutoObservable<
|
makeAutoObservable<
|
||||||
@@ -37,12 +40,13 @@ export class TwoFactorAuth {
|
|||||||
private startActivation(): void {
|
private startActivation(): void {
|
||||||
const setDisabled = action(() => (this._status = 'two-factor-disabled'));
|
const setDisabled = action(() => (this._status = 'two-factor-disabled'));
|
||||||
const setEnabled = action(() => (this._status = 'two-factor-enabled'));
|
const setEnabled = action(() => (this._status = 'two-factor-enabled'));
|
||||||
this.mfaGateway
|
this.mfaProvider
|
||||||
.generateMfaSecret()
|
.generateMfaSecret()
|
||||||
.then(
|
.then(
|
||||||
action((secret) => {
|
action((secret) => {
|
||||||
this._status = new TwoFactorActivation(
|
this._status = new TwoFactorActivation(
|
||||||
this.mfaGateway,
|
this.mfaProvider,
|
||||||
|
this.userProvider,
|
||||||
secret,
|
secret,
|
||||||
setDisabled,
|
setDisabled,
|
||||||
setEnabled
|
setEnabled
|
||||||
@@ -57,7 +61,7 @@ export class TwoFactorAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private deactivate2FA(): void {
|
private deactivate2FA(): void {
|
||||||
this.mfaGateway
|
this.mfaProvider
|
||||||
.disableMfa()
|
.disableMfa()
|
||||||
.then(
|
.then(
|
||||||
action(() => {
|
action(() => {
|
||||||
@@ -72,18 +76,21 @@ export class TwoFactorAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get isLoggedIn(): boolean {
|
private get isLoggedIn(): boolean {
|
||||||
return this.mfaGateway.getUser() != undefined;
|
return this.userProvider.getUser() != undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchStatus(): void {
|
fetchStatus(): void {
|
||||||
this._status = 'fetching';
|
this._status = 'fetching';
|
||||||
|
|
||||||
if (!this.isLoggedIn) {
|
if (!this.isLoggedIn) {
|
||||||
this.setError('To enable 2FA, sign in or register for an account.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.mfaGateway
|
if (!this.isMfaFeatureAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mfaProvider
|
||||||
.isMfaActivated()
|
.isMfaActivated()
|
||||||
.then(
|
.then(
|
||||||
action((active) => {
|
action((active) => {
|
||||||
@@ -99,7 +106,7 @@ export class TwoFactorAuth {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(errorMessage: string | null): void {
|
private setError(errorMessage: string | null): void {
|
||||||
this._errorMessage = errorMessage;
|
this._errorMessage = errorMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +115,10 @@ export class TwoFactorAuth {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.isMfaFeatureAvailable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this._status === 'two-factor-disabled') {
|
if (this._status === 'two-factor-disabled') {
|
||||||
return this.startActivation();
|
return this.startActivation();
|
||||||
}
|
}
|
||||||
@@ -118,6 +129,12 @@ export class TwoFactorAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get errorMessage(): string | null {
|
get errorMessage(): string | null {
|
||||||
|
if (!this.isLoggedIn) {
|
||||||
|
return 'Two-factor authentication not available / Sign in or register for an account to configure 2FA';
|
||||||
|
}
|
||||||
|
if (!this.isMfaFeatureAvailable) {
|
||||||
|
return 'Two-factor authentication not available / A paid subscription plan is required to enable 2FA.';
|
||||||
|
}
|
||||||
return this._errorMessage;
|
return this._errorMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,4 +144,8 @@ export class TwoFactorAuth {
|
|||||||
}
|
}
|
||||||
return this._status;
|
return this._status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get isMfaFeatureAvailable(): boolean {
|
||||||
|
return this.mfaProvider.isMfaFeatureAvailable();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,45 +7,38 @@ import {
|
|||||||
} from '../../components';
|
} from '../../components';
|
||||||
import { Switch } from '../../../components/Switch';
|
import { Switch } from '../../../components/Switch';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import {
|
import { is2FAActivation, is2FADisabled, TwoFactorAuth } from './TwoFactorAuth';
|
||||||
is2FAActivation,
|
|
||||||
is2FADisabled,
|
|
||||||
is2FAEnabled,
|
|
||||||
TwoFactorAuth,
|
|
||||||
} from './TwoFactorAuth';
|
|
||||||
import { TwoFactorDisabledView } from './TwoFactorDisabledView';
|
|
||||||
import { TwoFactorActivationView } from './TwoFactorActivationView';
|
import { TwoFactorActivationView } from './TwoFactorActivationView';
|
||||||
|
|
||||||
export const TwoFactorAuthView: FunctionComponent<{
|
export const TwoFactorAuthView: FunctionComponent<{
|
||||||
auth: TwoFactorAuth;
|
auth: TwoFactorAuth;
|
||||||
}> = observer(({ auth }) => (
|
}> = observer(({ auth }) => {
|
||||||
<PreferencesGroup>
|
return (
|
||||||
<PreferencesSegment>
|
<PreferencesGroup>
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
<div className="flex-grow flex flex-col">
|
|
||||||
<Title>Two-factor authentication</Title>
|
|
||||||
<Text>
|
|
||||||
An extra layer of security when logging in to your account.
|
|
||||||
</Text>
|
|
||||||
{auth.errorMessage != null && (
|
|
||||||
<Text className="color-danger">{auth.errorMessage}</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={!is2FADisabled(auth.status)}
|
|
||||||
onChange={() => auth.toggle2FA()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PreferencesSegment>
|
|
||||||
|
|
||||||
{is2FAActivation(auth.status) ? (
|
|
||||||
<TwoFactorActivationView activation={auth.status} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!is2FAEnabled(auth.status) ? (
|
|
||||||
<PreferencesSegment>
|
<PreferencesSegment>
|
||||||
<TwoFactorDisabledView />
|
<div className="flex flex-row items-center">
|
||||||
|
<div className="flex-grow flex flex-col">
|
||||||
|
<Title>Two-factor authentication</Title>
|
||||||
|
<Text>
|
||||||
|
An extra layer of security when logging in to your account.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={!is2FADisabled(auth.status)}
|
||||||
|
onChange={auth.toggle2FA}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</PreferencesSegment>
|
</PreferencesSegment>
|
||||||
) : null}
|
|
||||||
</PreferencesGroup>
|
{is2FAActivation(auth.status) && (
|
||||||
));
|
<TwoFactorActivationView activation={auth.status} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{auth.errorMessage != null && (
|
||||||
|
<PreferencesSegment>
|
||||||
|
<Text className="color-danger">{auth.errorMessage}</Text>
|
||||||
|
</PreferencesSegment>
|
||||||
|
)}
|
||||||
|
</PreferencesGroup>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { Text } from '../../components';
|
|
||||||
import { FunctionComponent } from 'preact';
|
|
||||||
|
|
||||||
export const TwoFactorDisabledView: FunctionComponent = () => (
|
|
||||||
<Text>
|
|
||||||
Enabling two-factor authentication will sign you out of all other sessions.{' '}
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
href="https://standardnotes.com/help/21/where-should-i-store-my-two-factor-authentication-secret-key"
|
|
||||||
>
|
|
||||||
Learn more
|
|
||||||
</a>
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
@@ -4,10 +4,10 @@ import { MfaProps } from './MfaProps';
|
|||||||
import { TwoFactorAuth } from './TwoFactorAuth';
|
import { TwoFactorAuth } from './TwoFactorAuth';
|
||||||
import { TwoFactorAuthView } from './TwoFactorAuthView';
|
import { TwoFactorAuthView } from './TwoFactorAuthView';
|
||||||
|
|
||||||
export const TwoFactorAuthWrapper: FunctionComponent<MfaProps> = ({
|
export const TwoFactorAuthWrapper: FunctionComponent<MfaProps> = (props) => {
|
||||||
mfaGateway,
|
const [auth] = useState(
|
||||||
}) => {
|
() => new TwoFactorAuth(props.mfaProvider, props.userProvider)
|
||||||
const [auth] = useState(() => new TwoFactorAuth(mfaGateway));
|
);
|
||||||
auth.fetchStatus();
|
auth.fetchStatus();
|
||||||
return <TwoFactorAuthView auth={auth} />;
|
return <TwoFactorAuthView auth={auth} />;
|
||||||
};
|
};
|
||||||
|
|||||||
13
app/assets/javascripts/preferences/providers/MfaProvider.ts
Normal file
13
app/assets/javascripts/preferences/providers/MfaProvider.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export interface MfaProvider {
|
||||||
|
isMfaActivated(): Promise<boolean>;
|
||||||
|
|
||||||
|
generateMfaSecret(): Promise<string>;
|
||||||
|
|
||||||
|
getOtpToken(secret: string): Promise<string>;
|
||||||
|
|
||||||
|
enableMfa(secret: string, otpToken: string): Promise<void>;
|
||||||
|
|
||||||
|
disableMfa(): Promise<void>;
|
||||||
|
|
||||||
|
isMfaFeatureAvailable(): boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface UserProvider {
|
||||||
|
getUser(): { uuid: string; email: string } | undefined;
|
||||||
|
}
|
||||||
2
app/assets/javascripts/preferences/providers/index.ts
Normal file
2
app/assets/javascripts/preferences/providers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './MfaProvider';
|
||||||
|
export * from './UserProvider';
|
||||||
@@ -71,7 +71,8 @@
|
|||||||
"@reach/checkbox": "^0.13.2",
|
"@reach/checkbox": "^0.13.2",
|
||||||
"@reach/dialog": "^0.13.0",
|
"@reach/dialog": "^0.13.0",
|
||||||
"@standardnotes/sncrypto-web": "1.5.2",
|
"@standardnotes/sncrypto-web": "1.5.2",
|
||||||
"@standardnotes/snjs": "2.12.1",
|
"@standardnotes/features": "1.6.1",
|
||||||
|
"@standardnotes/snjs": "2.12.3",
|
||||||
"mobx": "^6.3.2",
|
"mobx": "^6.3.2",
|
||||||
"mobx-react-lite": "^3.2.0",
|
"mobx-react-lite": "^3.2.0",
|
||||||
"preact": "^10.5.12",
|
"preact": "^10.5.12",
|
||||||
|
|||||||
@@ -2042,6 +2042,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@standardnotes/common" "^1.1.0"
|
"@standardnotes/common" "^1.1.0"
|
||||||
|
|
||||||
|
"@standardnotes/features@1.6.1":
|
||||||
|
version "1.6.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.6.1.tgz#bfa227bd231dc1b54449936663731f5132b08e23"
|
||||||
|
integrity sha512-IC6fEotUqs23JdZx96JnEgARxwYzjmPz3UwU/uVn8hHjxPev/W0nyZFRiSlj4v+dod0jSa6FTR8iLLsOQ6M4Ug==
|
||||||
|
dependencies:
|
||||||
|
"@standardnotes/common" "^1.1.0"
|
||||||
|
|
||||||
"@standardnotes/settings@1.2.0":
|
"@standardnotes/settings@1.2.0":
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/@standardnotes/settings/-/settings-1.2.0.tgz#d7936c788138265b0720085ca9e63358d3092459"
|
resolved "https://registry.yarnpkg.com/@standardnotes/settings/-/settings-1.2.0.tgz#d7936c788138265b0720085ca9e63358d3092459"
|
||||||
|
|||||||
Reference in New Issue
Block a user