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:
Thibaut Sardan 2020-12-17 14:02:53 +01:00 committed by GitHub
parent 7ba13b0640
commit 722fc4bf1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 274 additions and 7 deletions

View File

@ -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);

View File

@ -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;

View File

@ -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;
}

View File

@ -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);
}
}
`;

View 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;
}
`;

View File

@ -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>

View 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;
`;

View File

@ -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';

View File

@ -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);
}

View File

@ -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} {