mirror of
https://github.com/balzack/databag.git
synced 2025-02-14 12:39:17 +00:00
more webapp profile refactor
This commit is contained in:
parent
91c5a88096
commit
4cba051a5d
@ -79,3 +79,107 @@ export function decryptTopicSubject(subject, contentKey) {
|
||||
return JSON.parse(dec.toString(CryptoJS.enc.Utf8));
|
||||
}
|
||||
|
||||
function convertPem(pem) {
|
||||
var lines = pem.split('\n');
|
||||
var encoded = '';
|
||||
for(var i = 0;i < lines.length;i++){
|
||||
if (lines[i].trim().length > 0 &&
|
||||
lines[i].indexOf('-BEGIN RSA PRIVATE KEY-') < 0 &&
|
||||
lines[i].indexOf('-BEGIN RSA PUBLIC KEY-') < 0 &&
|
||||
lines[i].indexOf('-BEGIN PUBLIC KEY-') < 0 &&
|
||||
lines[i].indexOf('-END PUBLIC KEY-') < 0 &&
|
||||
lines[i].indexOf('-END RSA PRIVATE KEY-') < 0 &&
|
||||
lines[i].indexOf('-END RSA PUBLIC KEY-') < 0) {
|
||||
encoded += lines[i].trim();
|
||||
}
|
||||
}
|
||||
return encoded
|
||||
};
|
||||
|
||||
export async function generateSeal(password) {
|
||||
|
||||
// generate key to encrypt private key
|
||||
const salt = CryptoJS.lib.WordArray.random(128 / 8);
|
||||
const aes = CryptoJS.PBKDF2(password, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 1024,
|
||||
});
|
||||
|
||||
// generate rsa key for sealing channel, delay for activity indicators
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const crypto = new JSEncrypt({ default_key_size: 2048 });
|
||||
crypto.getKey();
|
||||
|
||||
// encrypt private key
|
||||
const iv = CryptoJS.lib.WordArray.random(128 / 8);
|
||||
const privateKey = convertPem(crypto.getPrivateKey());
|
||||
const enc = CryptoJS.AES.encrypt(privateKey, aes, { iv: iv });
|
||||
const publicKey = convertPem(crypto.getPublicKey());
|
||||
|
||||
// update account
|
||||
const seal = {
|
||||
passwordSalt: salt.toString(),
|
||||
privateKeyIv: iv.toString(),
|
||||
privateKeyEncrypted: enc.ciphertext.toString(CryptoJS.enc.Base64),
|
||||
publicKey: publicKey,
|
||||
}
|
||||
const sealKey = {
|
||||
public: publicKey,
|
||||
private: privateKey,
|
||||
}
|
||||
|
||||
return { seal, sealKey };
|
||||
}
|
||||
|
||||
export function unlockSeal(seal, password) {
|
||||
|
||||
// generate key to encrypt private key
|
||||
const salt = CryptoJS.enc.Hex.parse(seal.passwordSalt);
|
||||
const aes = CryptoJS.PBKDF2(password, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 1024,
|
||||
});
|
||||
|
||||
// decrypt private key
|
||||
const iv = CryptoJS.enc.Hex.parse(seal.privateKeyIv);
|
||||
const enc = CryptoJS.enc.Base64.parse(seal.privateKeyEncrypted)
|
||||
|
||||
let cipherParams = CryptoJS.lib.CipherParams.create({
|
||||
ciphertext: enc,
|
||||
iv: iv
|
||||
});
|
||||
const dec = CryptoJS.AES.decrypt(cipherParams, aes, { iv: iv });
|
||||
const privateKey = dec.toString(CryptoJS.enc.Utf8)
|
||||
|
||||
// store ke
|
||||
const sealKey = {
|
||||
public: seal.publicKey,
|
||||
private: privateKey,
|
||||
}
|
||||
|
||||
return sealKey;
|
||||
}
|
||||
|
||||
export function updateSeal(seal, sealKey, password) {
|
||||
|
||||
// generate key to encrypt private key
|
||||
const salt = CryptoJS.lib.WordArray.random(128 / 8);
|
||||
const aes = CryptoJS.PBKDF2(password, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 1024,
|
||||
});
|
||||
|
||||
// encrypt private key
|
||||
const iv = CryptoJS.lib.WordArray.random(128 / 8);
|
||||
const enc = CryptoJS.AES.encrypt(sealKey.private, aes, { iv: iv });
|
||||
|
||||
// update account
|
||||
const updated = {
|
||||
passwordSalt: salt.toString(),
|
||||
privateKeyIv: iv.toString(),
|
||||
privateKeyEncrypted: enc.ciphertext.toString(CryptoJS.enc.Base64),
|
||||
publicKey: seal.publicKey,
|
||||
}
|
||||
|
||||
return { seal: updated, sealKey };
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ export function useProfileContext() {
|
||||
const token = access.current;
|
||||
const revision = curRevision.current;
|
||||
const identity = await getProfile(access.current);
|
||||
const imageUrl = identity.image ? getProfileImageUrl(token, revision) : null;
|
||||
const imageUrl = identity.image ? getProfileImageUrl(token, identity.revision) : null;
|
||||
setRevision.current = revision;
|
||||
updateState({ offsync: false, identity, imageUrl });
|
||||
}
|
||||
|
@ -140,7 +140,7 @@ export function Profile({ closeProfile }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{ state.init && state.display !== 'xlarge' && (
|
||||
{ state.display !== 'xlarge' && (
|
||||
<div className="account">
|
||||
<div className="section">Account Settings</div>
|
||||
<div className="controls">
|
||||
|
@ -173,8 +173,6 @@ export const ProfileWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-top: 1px solid ${Colors.divider};
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
width: 75%;
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { AccountAccessWrapper, SealModal, EditFooter } from './AccountAccess.styled';
|
||||
import { useAccountAccess } from './useAccountAccess.hook';
|
||||
import { AccountLogin } from './accountLogin/AccountLogin';
|
||||
import { Button, Modal, Switch, Input } from 'antd';
|
||||
import { SettingOutlined, LockOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { Button, Modal, Switch, Form, Input } from 'antd';
|
||||
import { SettingOutlined, UserOutlined, LockOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
export function AccountAccess() {
|
||||
|
||||
const [ modal, modalContext ] = Modal.useModal();
|
||||
const { state, actions } = useAccountAccess();
|
||||
|
||||
const saveSeal = async () => {
|
||||
@ -15,7 +15,7 @@ export function AccountAccess() {
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
Modal.error({
|
||||
modal.error({
|
||||
title: 'Failed to Set Sealing Key',
|
||||
comment: 'Please try again.',
|
||||
});
|
||||
@ -28,7 +28,7 @@ export function AccountAccess() {
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
Modal.error({
|
||||
modal.error({
|
||||
title: 'Update Registry Failed',
|
||||
content: 'Please try again.',
|
||||
});
|
||||
@ -42,7 +42,7 @@ export function AccountAccess() {
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
Modal.error({
|
||||
modal.error({
|
||||
title: 'Failed to Save',
|
||||
comment: 'Please try again.',
|
||||
});
|
||||
@ -51,7 +51,7 @@ export function AccountAccess() {
|
||||
|
||||
const editLoginFooter = (
|
||||
<EditFooter>
|
||||
<div class="select"></div>
|
||||
<div className="select"></div>
|
||||
<Button key="back" onClick={actions.clearEditLogin}>Cancel</Button>
|
||||
<Button key="save" type="primary" onClick={saveLogin} disabled={!actions.canSaveLogin()} loading={state.busy}>Save</Button>
|
||||
</EditFooter>
|
||||
@ -59,7 +59,7 @@ export function AccountAccess() {
|
||||
|
||||
const editSealFooter = (
|
||||
<EditFooter>
|
||||
<div class="select"></div>
|
||||
<div className="select"></div>
|
||||
<Button key="back" onClick={actions.clearEditSeal}>Cancel</Button>
|
||||
{ state.sealMode === 'enabled' && (
|
||||
<Button key="save" type="primary" onClick={saveSeal} loading={state.busy}>Forget</Button>
|
||||
@ -75,50 +75,51 @@ export function AccountAccess() {
|
||||
|
||||
return (
|
||||
<AccountAccessWrapper>
|
||||
<div class="switch">
|
||||
{ modalContext }
|
||||
<div className="switch">
|
||||
<Switch size="small" checked={state.searchable} onChange={enable => saveSearchable(enable)} />
|
||||
<div class="switchLabel">Visible in Registry </div>
|
||||
<div className="switchLabel">Visible in Registry </div>
|
||||
</div>
|
||||
<div class="link" onClick={actions.setEditSeal}>
|
||||
<div className="link" onClick={actions.setEditSeal}>
|
||||
<SettingOutlined />
|
||||
<div class="label">Sealed Topics</div>
|
||||
<div className="label">Sealed Topics</div>
|
||||
</div>
|
||||
<div class="link" onClick={actions.setEditLogin}>
|
||||
<div className="link" onClick={actions.setEditLogin}>
|
||||
<LockOutlined />
|
||||
<div class="label">Change Login</div>
|
||||
<div className="label">Change Login</div>
|
||||
</div>
|
||||
<Modal title="Topic Sealing Key" centered visible={state.editSeal} footer={editSealFooter} onCancel={actions.clearEditSeal}>
|
||||
<SealModal>
|
||||
<div class="switch">
|
||||
<div className="switch">
|
||||
<Switch size="small" checked={state.sealEnabled} onChange={enable => actions.enableSeal(enable)} />
|
||||
<div class="switchLabel">Enable Sealed Topics</div>
|
||||
<div className="switchLabel">Enable Sealed Topics</div>
|
||||
</div>
|
||||
{ (state.sealMode === 'updating' || state.sealMode === 'enabling') && (
|
||||
<div class="sealPassword">
|
||||
<div className="sealPassword">
|
||||
<Input.Password placeholder="New Password" spellCheck="false" onChange={(e) => actions.setSealPassword(e.target.value)}
|
||||
autocomplete="new-password" prefix={<LockOutlined />} />
|
||||
</div>
|
||||
)}
|
||||
{ (state.sealMode === 'updating' || state.sealMode === 'enabling') && (
|
||||
<div class="sealPassword">
|
||||
<div className="sealPassword">
|
||||
<Input.Password placeholder="Confirm Password" spellCheck="false" onChange={(e) => actions.setSealConfirm(e.target.value)}
|
||||
autocomplete="new-password" prefix={<LockOutlined />} />
|
||||
</div>
|
||||
)}
|
||||
{ state.sealMode === 'disabling' && (
|
||||
<div class="sealPassword">
|
||||
<div className="sealPassword">
|
||||
<Input placeholder="Type 'delete' to remove key" spellCheck="false" onChange={(e) => actions.setSealDelete(e.target.value)}
|
||||
prefix={<ExclamationCircleOutlined />} />
|
||||
</div>
|
||||
)}
|
||||
{ state.sealMode === 'enabled' && (
|
||||
<div class="sealPassword" onClick={() => actions.updateSeal()}>
|
||||
<div className="sealPassword" onClick={() => actions.updateSeal()}>
|
||||
<Input.Password defaultValue="xxxxxxxxxx" disabled={true} prefix={<LockOutlined />} />
|
||||
<div class="editPassword" />
|
||||
<div className="editPassword" />
|
||||
</div>
|
||||
)}
|
||||
{ state.sealMode === 'unlocking' && (
|
||||
<div class="sealPassword">
|
||||
<div className="sealPassword">
|
||||
<Input.Password placeholder="Password" spellCheck="false" onChange={(e) => actions.setSealUnlock(e.target.value)}
|
||||
prefix={<LockOutlined />} />
|
||||
</div>
|
||||
@ -127,7 +128,22 @@ export function AccountAccess() {
|
||||
</Modal>
|
||||
<Modal title="Account Login" centered visible={state.editLogin} footer={editLoginFooter}
|
||||
onCancel={actions.clearEditLogin}>
|
||||
<AccountLogin state={state} actions={actions} />
|
||||
<Form name="basic" wrapperCol={{ span: 24, }}>
|
||||
<Form.Item name="username" validateStatus={state.editStatus} help={state.editMessage}>
|
||||
<Input placeholder="Username" spellCheck="false" onChange={(e) => actions.setEditHandle(e.target.value)}
|
||||
defaultValue={state.editHandle} autocomplete="username" autocapitalize="none" prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="password">
|
||||
<Input.Password placeholder="Password" spellCheck="false" onChange={(e) => actions.setEditPassword(e.target.value)}
|
||||
autocomplete="new-password" prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="confirm">
|
||||
<Input.Password placeholder="Confirm Password" spellCheck="false" onChange={(e) => actions.setEditConfirm(e.target.value)}
|
||||
autocomplete="new-password" prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</AccountAccessWrapper>
|
||||
);
|
||||
|
@ -1,24 +0,0 @@
|
||||
import { Form, Input } from 'antd';
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
|
||||
export function AccountLogin({ state, actions }) {
|
||||
return (
|
||||
<Form name="basic" wrapperCol={{ span: 24, }}>
|
||||
<Form.Item name="username" validateStatus={state.editStatus} help={state.editMessage}>
|
||||
<Input placeholder="Username" spellCheck="false" onChange={(e) => actions.setEditHandle(e.target.value)}
|
||||
defaultValue={state.editHandle} autocomplete="username" autocapitalize="none" prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="password">
|
||||
<Input.Password placeholder="Password" spellCheck="false" onChange={(e) => actions.setEditPassword(e.target.value)}
|
||||
autocomplete="new-password" prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="confirm">
|
||||
<Input.Password placeholder="Confirm Password" spellCheck="false" onChange={(e) => actions.setEditConfirm(e.target.value)}
|
||||
autocomplete="new-password" prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useRef, useState, useEffect, useContext } from 'react';
|
||||
import { AccountContext } from 'context/AccountContext';
|
||||
import { ProfileContext } from 'context/ProfileContext';
|
||||
import { generateSeal, unlockSeal, updateSeal } from 'context/sealUtil';
|
||||
import { getUsername } from 'api/getUsername';
|
||||
import CryptoJS from 'crypto-js';
|
||||
import { JSEncrypt } from 'jsencrypt'
|
||||
@ -40,63 +41,18 @@ export function useAccountAccess() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (profile.state.identity?.guid) {
|
||||
const { handle } = profile.state.identity;
|
||||
updateState({ handle, editHandle: handle });
|
||||
}
|
||||
}, [profile]);
|
||||
const { handle } = profile.state.identity;
|
||||
updateState({ handle, editHandle: handle });
|
||||
}, [profile.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (account?.state?.status) {
|
||||
const { seal, sealKey, status } = account.state;
|
||||
updateState({ searchable: status.searchable, seal, sealKey });
|
||||
}
|
||||
}, [account]);
|
||||
const { seal, sealKey, status } = account.state;
|
||||
updateState({ searchable: status.searchable, seal, sealKey });
|
||||
}, [account.state]);
|
||||
|
||||
const convertPem = (pem) => {
|
||||
var lines = pem.split('\n');
|
||||
var encoded = '';
|
||||
for(var i = 0;i < lines.length;i++){
|
||||
if (lines[i].trim().length > 0 &&
|
||||
lines[i].indexOf('-BEGIN RSA PRIVATE KEY-') < 0 &&
|
||||
lines[i].indexOf('-BEGIN RSA PUBLIC KEY-') < 0 &&
|
||||
lines[i].indexOf('-BEGIN PUBLIC KEY-') < 0 &&
|
||||
lines[i].indexOf('-END PUBLIC KEY-') < 0 &&
|
||||
lines[i].indexOf('-END RSA PRIVATE KEY-') < 0 &&
|
||||
lines[i].indexOf('-END RSA PUBLIC KEY-') < 0) {
|
||||
encoded += lines[i].trim();
|
||||
}
|
||||
}
|
||||
return encoded
|
||||
};
|
||||
|
||||
const sealUnlock = async () => {
|
||||
|
||||
// generate key to encrypt private key
|
||||
const salt = CryptoJS.enc.Hex.parse(state.seal.passwordSalt);
|
||||
const aes = CryptoJS.PBKDF2(state.sealUnlock, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 1024,
|
||||
});
|
||||
|
||||
// decrypt private key
|
||||
const iv = CryptoJS.enc.Hex.parse(state.seal.privateKeyIv);
|
||||
const enc = CryptoJS.enc.Base64.parse(state.seal.privateKeyEncrypted)
|
||||
|
||||
let cipherParams = CryptoJS.lib.CipherParams.create({
|
||||
ciphertext: enc,
|
||||
iv: iv
|
||||
});
|
||||
const dec = CryptoJS.AES.decrypt(cipherParams, aes, { iv: iv });
|
||||
const privateKey = dec.toString(CryptoJS.enc.Utf8)
|
||||
|
||||
// store ke
|
||||
const sealKey = {
|
||||
public: state.seal.publicKey,
|
||||
private: privateKey,
|
||||
}
|
||||
|
||||
await account.actions.unlockSeal(sealKey);
|
||||
const unlocked = unlockSeal(state.seal, state.sealUnlock);
|
||||
await account.actions.unlockSeal(unlocked);
|
||||
};
|
||||
|
||||
const sealForget = async () => {
|
||||
@ -104,37 +60,8 @@ export function useAccountAccess() {
|
||||
};
|
||||
|
||||
const sealEnable = async () => {
|
||||
|
||||
// generate key to encrypt private key
|
||||
const salt = CryptoJS.lib.WordArray.random(128 / 8);
|
||||
const aes = CryptoJS.PBKDF2(state.sealPassword, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 1024,
|
||||
});
|
||||
|
||||
// generate rsa key for sealing channel, delay for activity indicator
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
const crypto = new JSEncrypt({ default_key_size: 2048 });
|
||||
crypto.getKey();
|
||||
|
||||
// encrypt private key
|
||||
const iv = CryptoJS.lib.WordArray.random(128 / 8);
|
||||
const privateKey = convertPem(crypto.getPrivateKey());
|
||||
const enc = CryptoJS.AES.encrypt(privateKey, aes, { iv: iv });
|
||||
const publicKey = convertPem(crypto.getPublicKey());
|
||||
|
||||
// update account
|
||||
const seal = {
|
||||
passwordSalt: salt.toString(),
|
||||
privateKeyIv: iv.toString(),
|
||||
privateKeyEncrypted: enc.ciphertext.toString(CryptoJS.enc.Base64),
|
||||
publicKey: publicKey,
|
||||
}
|
||||
const sealKey = {
|
||||
public: publicKey,
|
||||
private: privateKey,
|
||||
}
|
||||
await account.actions.setSeal(seal, sealKey);
|
||||
const generated = await generateSeal(state.sealPassword);
|
||||
await account.actions.setSeal(generated.seal, generated.sealKey);
|
||||
};
|
||||
|
||||
const sealRemove = async () => {
|
||||
@ -142,26 +69,8 @@ export function useAccountAccess() {
|
||||
};
|
||||
|
||||
const sealUpdate = async () => {
|
||||
|
||||
// generate key to encrypt private key
|
||||
const salt = CryptoJS.lib.WordArray.random(128 / 8);
|
||||
const aes = CryptoJS.PBKDF2(state.sealPassword, salt, {
|
||||
keySize: 256 / 32,
|
||||
iterations: 1024,
|
||||
});
|
||||
|
||||
// encrypt private key
|
||||
const iv = CryptoJS.lib.WordArray.random(128 / 8);
|
||||
const enc = CryptoJS.AES.encrypt(state.sealKey.private, aes, { iv: iv });
|
||||
|
||||
// update account
|
||||
const seal = {
|
||||
passwordSalt: salt.toString(),
|
||||
privateKeyIv: iv.toString(),
|
||||
privateKeyEncrypted: enc.ciphertext.toString(CryptoJS.enc.Base64),
|
||||
publicKey: state.seal.publicKey,
|
||||
}
|
||||
await account.actions.updateSeal(seal);
|
||||
const updated = updateSeal(state.seal, state.sealKey, state.sealPassword);
|
||||
await account.actions.updateSeal(state.seal);
|
||||
};
|
||||
|
||||
const isEnabled = () => {
|
||||
@ -296,18 +205,20 @@ export function useAccountAccess() {
|
||||
if (editHandle.toLowerCase() === state.handle.toLowerCase()) {
|
||||
updateState({ checked: true, editStatus: 'success', editMessage: '' });
|
||||
}
|
||||
try {
|
||||
let valid = await getUsername(editHandle);
|
||||
if (valid) {
|
||||
else {
|
||||
try {
|
||||
let valid = await getUsername(editHandle);
|
||||
if (valid) {
|
||||
updateState({ checked: true, editStatus: 'success', editMessage: '' });
|
||||
}
|
||||
else {
|
||||
updateState({ checked: true, editStatus: 'error', editMessage: 'Username is not available' });
|
||||
}
|
||||
}
|
||||
catch(err) {
|
||||
console.log(err);
|
||||
updateState({ checked: true, editStatus: 'success', editMessage: '' });
|
||||
}
|
||||
else {
|
||||
updateState({ checked: true, editStatus: 'error', editMessage: 'Username is not available' });
|
||||
}
|
||||
}
|
||||
catch(err) {
|
||||
console.log(err);
|
||||
updateState({ checked: true, editStatus: 'success', editMessage: '' });
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
|
@ -18,10 +18,8 @@ export function useProfile() {
|
||||
editProfileImage: false,
|
||||
editProfileDetails: false,
|
||||
clip: { w: 0, h: 0, x: 0, y: 0 },
|
||||
|
||||
crop: { x: 0, y: 0},
|
||||
zoom: 1,
|
||||
|
||||
busy: false,
|
||||
});
|
||||
|
||||
@ -35,13 +33,11 @@ export function useProfile() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (profile.state.identity.guid) {
|
||||
const { node, name, handle, location, description, image, imageUrl } = profile.state.identity;
|
||||
let url = !image ? null : profile.state.imageUrl;
|
||||
let editImage = !image ? avatar : url;
|
||||
updateState({ name, location, description, node, handle, url,
|
||||
editName: name, editLocation: location, editDescription: description, editHandle: handle, editImage });
|
||||
}
|
||||
const { node, name, handle, location, description, image, imageUrl } = profile.state.identity;
|
||||
let url = !image ? null : profile.state.imageUrl;
|
||||
let editImage = !image ? avatar : url;
|
||||
updateState({ name, location, description, node, handle, url,
|
||||
editName: name, editLocation: location, editDescription: description, editHandle: handle, editImage });
|
||||
}, [profile.state]);
|
||||
|
||||
useEffect(() => {
|
||||
|
Loading…
Reference in New Issue
Block a user