Website access management (#589)
* list websites * persist in local storage * with filter and styling * cleanup * styling for long list * fix icon alignment and lint * Apply suggestions from code review Co-authored-by: Jaco Greeff <jacogr@gmail.com> * add return ( ) * lint Co-authored-by: Jaco Greeff <jacogr@gmail.com>
This commit is contained in:
parent
7ba13b0640
commit
722fc4bf1a
@ -4,7 +4,7 @@
|
||||
import type { MetadataDef } from '@polkadot/extension-inject/types';
|
||||
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
|
||||
import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
|
||||
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, ResponseAccountExport, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types';
|
||||
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, ResponseAccountExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types';
|
||||
|
||||
import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@polkadot/extension-base/defaults';
|
||||
import chrome from '@polkadot/extension-inject/chrome';
|
||||
@ -167,6 +167,10 @@ export default class Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
private getAuthList (): ResponseAuthorizeList {
|
||||
return { list: this.#state.authUrls };
|
||||
}
|
||||
|
||||
private authorizeReject ({ id }: RequestAuthorizeReject): boolean {
|
||||
const queued = this.#state.getAuthRequest(id);
|
||||
|
||||
@ -447,6 +451,10 @@ export default class Extension {
|
||||
return true;
|
||||
}
|
||||
|
||||
private toggleAuthorization (url: string):ResponseAuthorizeList {
|
||||
return { list: this.#state.toggleAuthorization(url) };
|
||||
}
|
||||
|
||||
// Weird thought, the eslint override is not needed in Tabs
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
public async handle<TMessageType extends MessageTypes> (id: string, type: TMessageType, request: RequestTypes[TMessageType], port: chrome.runtime.Port): Promise<ResponseType<TMessageType>> {
|
||||
@ -454,9 +462,15 @@ export default class Extension {
|
||||
case 'pri(authorize.approve)':
|
||||
return this.authorizeApprove(request as RequestAuthorizeApprove);
|
||||
|
||||
case 'pri(authorize.list)':
|
||||
return this.getAuthList();
|
||||
|
||||
case 'pri(authorize.reject)':
|
||||
return this.authorizeReject(request as RequestAuthorizeReject);
|
||||
|
||||
case 'pri(authorize.toggle)':
|
||||
return this.toggleAuthorization(request as string);
|
||||
|
||||
case 'pri(authorize.requests)':
|
||||
return this.authorizeSubscribe(id, port);
|
||||
|
||||
|
||||
@ -25,13 +25,15 @@ interface AuthRequest extends Resolver<boolean> {
|
||||
url: string;
|
||||
}
|
||||
|
||||
type AuthUrls = Record<string, {
|
||||
export type AuthUrls = Record<string, AuthUrlInfo>;
|
||||
|
||||
export interface AuthUrlInfo {
|
||||
count: number;
|
||||
id: string;
|
||||
isAllowed: boolean;
|
||||
origin: string;
|
||||
url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MetaRequest extends Resolver<boolean> {
|
||||
id: string;
|
||||
@ -68,6 +70,8 @@ const WINDOW_OPTS = {
|
||||
width: 560
|
||||
};
|
||||
|
||||
const AUTH_URLS_KEY = 'authUrls';
|
||||
|
||||
function getId (): string {
|
||||
return `${Date.now()}.${++idCounter}`;
|
||||
}
|
||||
@ -103,6 +107,12 @@ export default class State {
|
||||
this.#metaStore.all((_key: string, def: MetadataDef): void => {
|
||||
addMetadata(def);
|
||||
});
|
||||
|
||||
// retrieve previously set authorizations
|
||||
const authString = localStorage.getItem(AUTH_URLS_KEY) || '{}';
|
||||
const previousAuth = JSON.parse(authString) as AuthUrls;
|
||||
|
||||
this.#authUrls = previousAuth;
|
||||
}
|
||||
|
||||
public get knownMetadata (): MetadataDef[] {
|
||||
@ -139,6 +149,10 @@ export default class State {
|
||||
.map(({ account, id, request, url }): SigningRequest => ({ account, id, request, url }));
|
||||
}
|
||||
|
||||
public get authUrls (): AuthUrls {
|
||||
return this.#authUrls;
|
||||
}
|
||||
|
||||
private popupClose (): void {
|
||||
this.#windows.forEach((id: number): void =>
|
||||
chrome.windows.remove(id)
|
||||
@ -167,6 +181,7 @@ export default class State {
|
||||
url
|
||||
};
|
||||
|
||||
this.saveCurrentAuthList();
|
||||
delete this.#authRequests[id];
|
||||
this.updateIconAuth(true);
|
||||
};
|
||||
@ -183,6 +198,10 @@ export default class State {
|
||||
};
|
||||
}
|
||||
|
||||
private saveCurrentAuthList () {
|
||||
localStorage.setItem(AUTH_URLS_KEY, JSON.stringify(this.#authUrls));
|
||||
}
|
||||
|
||||
private metaComplete = (id: string, resolve: (result: boolean) => void, reject: (error: Error) => void): Resolver<boolean> => {
|
||||
const complete = (): void => {
|
||||
delete this.#metaRequests[id];
|
||||
@ -246,6 +265,18 @@ export default class State {
|
||||
}
|
||||
}
|
||||
|
||||
public toggleAuthorization (url: string): AuthUrls {
|
||||
const entry = this.#authUrls[url];
|
||||
|
||||
assert(entry, `The source ${url} is not known`);
|
||||
|
||||
const newAllowState = !entry.isAllowed;
|
||||
|
||||
this.#authUrls[url].isAllowed = newAllowState;
|
||||
|
||||
return this.#authUrls;
|
||||
}
|
||||
|
||||
private updateIconAuth (shouldClose?: boolean): void {
|
||||
this.authSubject.next(this.allAuthRequests);
|
||||
this.updateIcon(shouldClose);
|
||||
@ -265,6 +296,7 @@ export default class State {
|
||||
const idStr = this.stripUrl(url);
|
||||
|
||||
if (this.#authUrls[idStr]) {
|
||||
// this url was seen in the past
|
||||
assert(this.#authUrls[idStr].isAllowed, `The source ${url} is not allowed to interact with this extension`);
|
||||
|
||||
return false;
|
||||
|
||||
@ -12,6 +12,7 @@ import type { KeypairType } from '@polkadot/util-crypto/types';
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
|
||||
import { ALLOWED_PATH } from '../defaults';
|
||||
import { AuthUrls } from './handlers/State';
|
||||
|
||||
type KeysWithDefinedValues<T> = {
|
||||
[K in keyof T]: T[K] extends undefined ? never : K
|
||||
@ -81,8 +82,10 @@ export interface RequestSignatures {
|
||||
'pri(accounts.validate)': [RequestAccountValidate, boolean];
|
||||
'pri(accounts.changePassword)': [RequestAccountChangePassword, boolean];
|
||||
'pri(authorize.approve)': [RequestAuthorizeApprove, boolean];
|
||||
'pri(authorize.list)': [null, ResponseAuthorizeList];
|
||||
'pri(authorize.reject)': [RequestAuthorizeReject, boolean];
|
||||
'pri(authorize.requests)': [RequestAuthorizeSubscribe, boolean, AuthorizeRequest[]];
|
||||
'pri(authorize.toggle)': [string, ResponseAuthorizeList];
|
||||
'pri(derivation.create)': [RequestDeriveCreate, boolean];
|
||||
'pri(derivation.validate)': [RequestDeriveValidate, ResponseDeriveValidate];
|
||||
'pri(json.restore)': [RequestJsonRestore, void];
|
||||
@ -365,3 +368,7 @@ export interface ResponseJsonGetAccountInfo {
|
||||
name: string;
|
||||
genesisHash: string;
|
||||
}
|
||||
|
||||
export interface ResponseAuthorizeList {
|
||||
list: AuthUrls;
|
||||
}
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
// Copyright 2019-2020 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ThemeProps } from '../../types';
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { AuthUrlInfo } from '@polkadot/extension-base/background/handlers/State';
|
||||
import { Switch } from '@polkadot/extension-ui/components';
|
||||
|
||||
import useTranslation from '../../hooks/useTranslation';
|
||||
|
||||
interface Props extends ThemeProps {
|
||||
className?: string;
|
||||
info: AuthUrlInfo;
|
||||
toggleAuth: (url: string) => void
|
||||
url: string;
|
||||
}
|
||||
|
||||
function WebsiteEntry ({ className = '', info, toggleAuth, url }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const switchAccess = useCallback(() => {
|
||||
toggleAuth(url);
|
||||
}, [toggleAuth, url]);
|
||||
|
||||
return (
|
||||
<div className={`${className} ${info.isAllowed ? 'allowed' : 'denied'}`}>
|
||||
<div className='url'>
|
||||
{url}
|
||||
</div>
|
||||
<Switch
|
||||
checked={info.isAllowed}
|
||||
checkedLabel={t<string>('allowed')}
|
||||
className='info'
|
||||
onChange={switchAccess}
|
||||
uncheckedLabel={t<string>('denied')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(WebsiteEntry)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.url{
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&.denied {
|
||||
.slider::before {
|
||||
opacity: 0.4;
|
||||
background-color: rgb(36, 37, 41);
|
||||
}
|
||||
}
|
||||
`;
|
||||
88
packages/extension-ui/src/Popup/AuthManagement/index.tsx
Normal file
88
packages/extension-ui/src/Popup/AuthManagement/index.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
// Copyright 2019-2020 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ThemeProps } from '../../types';
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { AuthUrlInfo, AuthUrls } from '@polkadot/extension-base/background/handlers/State';
|
||||
import { InputFilter } from '@polkadot/extension-ui/components';
|
||||
|
||||
import useTranslation from '../../hooks/useTranslation';
|
||||
import { getAuthList, toggleAuthorization } from '../../messaging';
|
||||
import { Header } from '../../partials';
|
||||
import WebsiteEntry from './WebsiteEntry';
|
||||
|
||||
interface Props extends ThemeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function AuthManagement ({ className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [authList, setAuthList] = useState<AuthUrls | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getAuthList()
|
||||
.then(({ list }) => setAuthList(list))
|
||||
.catch((e) => console.error(e));
|
||||
}, []);
|
||||
|
||||
const _onChangeFilter = useCallback((filter: string) => {
|
||||
setFilter(filter);
|
||||
}, []);
|
||||
|
||||
const toggleAuth = useCallback((url: string) => {
|
||||
toggleAuthorization(url)
|
||||
.then(({ list }) => setAuthList(list))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
smallMargin
|
||||
text={t<string>('Manage Website Access')}
|
||||
/>
|
||||
<>
|
||||
<InputFilter
|
||||
onChange={_onChangeFilter}
|
||||
placeholder={t<string>('example.com')}
|
||||
value={filter}
|
||||
/>
|
||||
<div className={className}>
|
||||
{
|
||||
!authList || !Object.entries(authList)?.length
|
||||
? <div className='empty-list'>{t<string>('No website request yet!')}</div>
|
||||
: <>
|
||||
<div className='website-list'>
|
||||
{Object.entries(authList)
|
||||
.filter(([url]:[string, AuthUrlInfo]) => url.includes(filter))
|
||||
.map(
|
||||
([url, info]: [string, AuthUrlInfo]) =>
|
||||
<WebsiteEntry
|
||||
info={info}
|
||||
key={url}
|
||||
toggleAuth={toggleAuth}
|
||||
url={url}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(AuthManagement)`
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
.empty-list {
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
@ -16,6 +16,7 @@ import ToastProvider from '../components/Toast/ToastProvider';
|
||||
import { subscribeAccounts, subscribeAuthorizeRequests, subscribeMetadataRequests, subscribeSigningRequests } from '../messaging';
|
||||
import { buildHierarchy } from '../util/buildHierarchy';
|
||||
import Accounts from './Accounts';
|
||||
import AuthList from './AuthManagement';
|
||||
import Authorize from './Authorize';
|
||||
import CreateAccount from './CreateAccount';
|
||||
import Derive from './Derive';
|
||||
@ -129,6 +130,7 @@ export default function Popup (): React.ReactElement {
|
||||
<SigningReqContext.Provider value={signRequests}>
|
||||
<ToastProvider>
|
||||
<Switch>
|
||||
<Route path='/auth-list'>{wrapWithErrorBoundary(<AuthList />, 'auth-list')}</Route>
|
||||
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
|
||||
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
|
||||
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
|
||||
|
||||
40
packages/extension-ui/src/components/InputFilter.tsx
Normal file
40
packages/extension-ui/src/components/InputFilter.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
// Copyright 2017-2020 @polkadot/react-components authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Input } from './TextInputs';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onChange: (filter: string) => void;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function InputFilter ({ className, onChange, placeholder, value } : Props) {
|
||||
const onChangeFilter = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(event.target.value);
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Input
|
||||
autoCapitalize='off'
|
||||
autoCorrect='off'
|
||||
autoFocus
|
||||
onChange={onChangeFilter}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
type='text'
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(InputFilter)`
|
||||
padding-left: 1rem !important;
|
||||
padding-right: 1rem !important;
|
||||
`;
|
||||
@ -14,6 +14,7 @@ export { default as Dropdown } from './Dropdown';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { default as Icon } from './Icon';
|
||||
export { default as Identicon } from './Identicon';
|
||||
export { default as InputFilter } from './InputFilter';
|
||||
export { default as InputWithLabel } from './InputWithLabel';
|
||||
export { default as InputFileWithLabel } from './InputFileWithLabel';
|
||||
export { default as Label } from './Label';
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Copyright 2019-2020 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, SeedLengths, SigningRequest, SubscriptionMessageTypes } from '@polkadot/extension-base/background/types';
|
||||
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MessageTypesWithNoSubscriptions, MessageTypesWithNullRequest, MessageTypesWithSubscriptions, MetadataRequest, RequestTypes, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSigningIsLocked, ResponseTypes, SeedLengths, SigningRequest, SubscriptionMessageTypes } from '@polkadot/extension-base/background/types';
|
||||
import type { Message } from '@polkadot/extension-base/types';
|
||||
import type { Chain } from '@polkadot/extension-chains/types';
|
||||
import type { KeyringPair$Json } from '@polkadot/keyring/types';
|
||||
@ -174,6 +174,14 @@ export async function subscribeAuthorizeRequests (cb: (accounts: AuthorizeReques
|
||||
return sendMessage('pri(authorize.requests)', null, cb);
|
||||
}
|
||||
|
||||
export async function getAuthList (): Promise<ResponseAuthorizeList> {
|
||||
return sendMessage('pri(authorize.list)');
|
||||
}
|
||||
|
||||
export async function toggleAuthorization (url: string): Promise<ResponseAuthorizeList> {
|
||||
return sendMessage('pri(authorize.toggle)', url);
|
||||
}
|
||||
|
||||
export async function subscribeMetadataRequests (cb: (accounts: MetadataRequest[]) => void): Promise<boolean> {
|
||||
return sendMessage('pri(metadata.requests)', null, cb);
|
||||
}
|
||||
|
||||
@ -3,13 +3,13 @@
|
||||
|
||||
import type { Theme, ThemeProps } from '../types';
|
||||
|
||||
import { faExpand } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faExpand, faTasks } from '@fortawesome/free-solid-svg-icons';
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import styled, { ThemeContext } from 'styled-components';
|
||||
|
||||
import settings from '@polkadot/ui-settings';
|
||||
|
||||
import { ActionText, Checkbox, Dropdown, Menu, MenuDivider, MenuItem, Svg, Switch, themes, ThemeSwitchContext } from '../components';
|
||||
import { ActionContext, ActionText, Checkbox, Dropdown, Menu, MenuDivider, MenuItem, Svg, Switch, themes, ThemeSwitchContext } from '../components';
|
||||
import useIsPopup from '../hooks/useIsPopup';
|
||||
import useTranslation from '../hooks/useTranslation';
|
||||
import { windowOpen } from '../messaging';
|
||||
@ -37,6 +37,7 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
||||
const setTheme = useContext(ThemeSwitchContext);
|
||||
const isPopup = useIsPopup();
|
||||
const languageOptions = useMemo(() => getLanguageOptions(), []);
|
||||
const onAction = useContext(ActionContext);
|
||||
|
||||
useEffect(() => {
|
||||
settings.set({ camera: camera ? 'on' : 'off' });
|
||||
@ -67,6 +68,12 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
||||
[]
|
||||
);
|
||||
|
||||
const _goToAuthList = useCallback(
|
||||
() => {
|
||||
onAction('auth-list');
|
||||
}, [onAction]
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={className}
|
||||
@ -84,6 +91,15 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem className='setting'>
|
||||
<ActionText
|
||||
className='manageWebsiteAccess'
|
||||
icon={faTasks}
|
||||
onClick={_goToAuthList}
|
||||
text={t<string>('Manage website access')}
|
||||
/>
|
||||
</MenuItem>
|
||||
<MenuDivider/>
|
||||
<MenuItem
|
||||
className='setting'
|
||||
title={t<string>('External QR accounts and Access')}
|
||||
@ -143,12 +159,13 @@ export default React.memo(styled(MenuSettings)(({ theme }: Props) => `
|
||||
right: 24px;
|
||||
user-select: none;
|
||||
|
||||
.openWindow {
|
||||
.openWindow, .manageWebsiteAccess{
|
||||
span {
|
||||
color: ${theme.textColor};
|
||||
font-size: ${theme.fontSize};
|
||||
line-height: ${theme.lineHeight};
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
${Svg} {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user