diff --git a/packages/extension-base/src/background/handlers/Extension.spec.ts b/packages/extension-base/src/background/handlers/Extension.spec.ts index 43bcae1cd64..673052fc2a3 100644 --- a/packages/extension-base/src/background/handlers/Extension.spec.ts +++ b/packages/extension-base/src/background/handlers/Extension.spec.ts @@ -25,6 +25,7 @@ describe('Extension', () => { let tabs: Tabs; const suri = 'seed sock milk update focus rotate barely fade car face mechanic mercy'; const password = 'passw0rd'; + const address = '5FbSap4BsWfjyRhCchoVdZHkDnmDm3NEgLZ25mesq4aw2WvX'; async function createExtension (): Promise { await cryptoWaitReady(); @@ -33,9 +34,9 @@ describe('Extension', () => { const authUrls: AuthUrls = {}; authUrls['localhost:3000'] = { + authorizedAccounts: [address], count: 0, id: '11', - isAllowed: true, origin: 'example.com', url: 'http://localhost:3000' }; diff --git a/packages/extension-base/src/background/handlers/Extension.ts b/packages/extension-base/src/background/handlers/Extension.ts index 17a38692b1d..aa68b63f0ca 100644 --- a/packages/extension-base/src/background/handlers/Extension.ts +++ b/packages/extension-base/src/background/handlers/Extension.ts @@ -6,7 +6,7 @@ import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/ import type { SignerPayloadJSON, SignerPayloadRaw } from '@polkadot/types/types'; import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types'; import type { KeypairType } from '@polkadot/util-crypto/types'; -import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateHardware, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types'; +import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountBatchExport, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateHardware, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestBatchRestore, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, RequestUpdateAuthorizedAccounts, ResponseAccountExport, ResponseAccountsExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types'; import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@polkadot/extension-base/defaults'; import { TypeRegistry } from '@polkadot/types'; @@ -16,7 +16,7 @@ import { assert, isHex } from '@polkadot/util'; import { keyExtractSuri, mnemonicGenerate, mnemonicValidate } from '@polkadot/util-crypto'; import { withErrorLog } from './helpers'; -import State from './State'; +import State, { AuthorizedAccountsDiff } from './State'; import { createSubscription, unsubscribe } from './subscriptions'; type CachedUnlocks = Record; @@ -115,6 +115,18 @@ export default class Extension { } private accountsForget ({ address }: RequestAccountForget): boolean { + const authorizedAccountsDiff: AuthorizedAccountsDiff = []; + + // cycle through authUrls and prepare the array of diff + Object.entries(this.#state.authUrls).forEach(([url, urlInfo]) => { + if (!urlInfo.authorizedAccounts.includes(address)) { + return; + } + + authorizedAccountsDiff.push([url, urlInfo.authorizedAccounts.filter((previousAddress) => previousAddress !== address)]); + }); + + this.#state.updateAuthorizedAccounts(authorizedAccountsDiff); keyring.forgetAccount(address); return true; @@ -166,7 +178,6 @@ export default class Extension { } } - // FIXME This looks very much like what we have in Tabs private accountsSubscribe (id: string, port: chrome.runtime.Port): boolean { const cb = createSubscription<'pri(accounts.subscribe)'>(id, port); const subscription = accountsObservable.subject.subscribe((accounts: SubjectInfo): void => @@ -181,32 +192,24 @@ export default class Extension { return true; } - private authorizeApprove ({ id }: RequestAuthorizeApprove): boolean { + private authorizeApprove ({ authorizedAccounts, id }: RequestAuthorizeApprove): boolean { const queued = this.#state.getAuthRequest(id); assert(queued, 'Unable to find request'); const { resolve } = queued; - resolve(true); + resolve({ authorizedAccounts, result: true }); return true; } - private getAuthList (): ResponseAuthorizeList { - return { list: this.#state.authUrls }; + private authorizeUpdate ({ authorizedAccounts, url }: RequestUpdateAuthorizedAccounts): void { + return this.#state.updateAuthorizedAccounts([[url, authorizedAccounts]]); } - private authorizeReject ({ id }: RequestAuthorizeReject): boolean { - const queued = this.#state.getAuthRequest(id); - - assert(queued, 'Unable to find request'); - - const { reject } = queued; - - reject(new Error('Rejected')); - - return true; + private getAuthList (): ResponseAuthorizeList { + return { list: this.#state.authUrls }; } // FIXME This looks very much like what we have in accounts @@ -500,14 +503,14 @@ export default class Extension { return true; } - private toggleAuthorization (url: string): ResponseAuthorizeList { - return { list: this.#state.toggleAuthorization(url) }; - } - private removeAuthorization (url: string): ResponseAuthorizeList { return { list: this.#state.removeAuthorization(url) }; } + private deleteAuthRequest (requestId: string): void { + return this.#state.deleteAuthRequest(requestId); + } + // Weird thought, the eslint override is not needed in Tabs // eslint-disable-next-line @typescript-eslint/require-await public async handle (id: string, type: TMessageType, request: RequestTypes[TMessageType], port: chrome.runtime.Port): Promise> { @@ -518,18 +521,18 @@ export default class Extension { 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.remove)': return this.removeAuthorization(request as string); + case 'pri(authorize.delete.request)': + return this.deleteAuthRequest(request as string); + case 'pri(authorize.requests)': return this.authorizeSubscribe(id, port); + case 'pri(authorize.update)': + return this.authorizeUpdate(request as RequestUpdateAuthorizedAccounts); + case 'pri(accounts.create.external)': return this.accountsCreateExternal(request as RequestAccountCreateExternal); diff --git a/packages/extension-base/src/background/handlers/State.ts b/packages/extension-base/src/background/handlers/State.ts index f81c030a0aa..8f74dca5dc2 100644 --- a/packages/extension-base/src/background/handlers/State.ts +++ b/packages/extension-base/src/background/handlers/State.ts @@ -21,7 +21,7 @@ interface Resolver { resolve: (result: T) => void; } -interface AuthRequest extends Resolver { +interface AuthRequest extends Resolver { id: string; idStr: string; request: RequestAuthorizeTab; @@ -30,12 +30,14 @@ interface AuthRequest extends Resolver { export type AuthUrls = Record; +export type AuthorizedAccountsDiff = [url: string, authorizedAccounts: AuthUrlInfo['authorizedAccounts']][] + export interface AuthUrlInfo { count: number; id: string; - isAllowed: boolean; origin: string; url: string; + authorizedAccounts: string[]; } interface MetaRequest extends Resolver { @@ -44,6 +46,11 @@ interface MetaRequest extends Resolver { url: string; } +export interface AuthResponse { + result: boolean; + authorizedAccounts: string[]; +} + // List of providers passed into constructor. This is the list of providers // exposed by the extension. type Providers = Record void, reject: (error: Error) => void): Resolver => { - const complete = (result: boolean | Error) => { - const isAllowed = result === true; + private authComplete = (id: string, resolve: (resValue: AuthResponse) => void, reject: (error: Error) => void): Resolver => { + const complete = (authorizedAccounts: string[] = []) => { const { idStr, request: { origin }, url } = this.#authRequests[id]; this.#authUrls[this.stripUrl(url)] = { + authorizedAccounts, count: 0, id: idStr, - isAllowed, origin, url }; @@ -239,16 +245,21 @@ export default class State { return { reject: (error: Error): void => { - complete(error); + complete(); reject(error); }, - resolve: (result: boolean): void => { - complete(result); - resolve(result); + resolve: ({ authorizedAccounts, result }: AuthResponse): void => { + complete(authorizedAccounts); + resolve({ authorizedAccounts, result }); } }; }; + public deleteAuthRequest (requestId: string) { + delete this.#authRequests[requestId]; + this.updateIconAuth(true); + } + private saveCurrentAuthList () { localStorage.setItem(AUTH_URLS_KEY, JSON.stringify(this.#authUrls)); } @@ -289,7 +300,7 @@ export default class State { }; }; - private stripUrl (url: string): string { + public stripUrl (url: string): string { assert(url && (url.startsWith('http:') || url.startsWith('https:') || url.startsWith('ipfs:') || url.startsWith('ipns:')), `Invalid url ${url}, expected to start with http: or https: or ipfs: or ipns:`); const parts = url.split('/'); @@ -316,17 +327,6 @@ export default class State { } } - public toggleAuthorization (url: string): AuthUrls { - const entry = this.#authUrls[url]; - - assert(entry, `The source ${url} is not known`); - - this.#authUrls[url].isAllowed = !entry.isAllowed; - this.saveCurrentAuthList(); - - return this.#authUrls; - } - public removeAuthorization (url: string): AuthUrls { const entry = this.#authUrls[url]; @@ -353,7 +353,18 @@ export default class State { this.updateIcon(shouldClose); } - public async authorizeUrl (url: string, request: RequestAuthorizeTab): Promise { + public updateAuthorizedAccounts (authorizedAccountDiff: AuthorizedAccountsDiff): void { + authorizedAccountDiff.forEach(([url, authorizedAccountDiff]) => { + // this url was never seen in the past + assert(this.#authUrls[url].authorizedAccounts, `The source ${url} has never been authorized to interact with this extension`); + + this.#authUrls[url].authorizedAccounts = authorizedAccountDiff; + }); + + this.saveCurrentAuthList(); + } + + public async authorizeUrl (url: string, request: RequestAuthorizeTab): Promise { const idStr = this.stripUrl(url); // Do not enqueue duplicate authorization requests. @@ -364,9 +375,12 @@ export default class State { 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`); + assert(this.#authUrls[idStr].authorizedAccounts, `The source ${url} is not allowed to interact with this extension`); - return false; + return ({ + authorizedAccounts: [], + result: false + }); } return new Promise((resolve, reject): void => { @@ -389,7 +403,6 @@ export default class State { const entry = this.#authUrls[this.stripUrl(url)]; assert(entry, `The source ${url} has not been enabled yet`); - assert(entry.isAllowed, `The source ${url} is not allowed to interact with this extension`); return true; } diff --git a/packages/extension-base/src/background/handlers/Tabs.ts b/packages/extension-base/src/background/handlers/Tabs.ts index 0686639f9c3..ad145703b53 100644 --- a/packages/extension-base/src/background/handlers/Tabs.ts +++ b/packages/extension-base/src/background/handlers/Tabs.ts @@ -18,7 +18,7 @@ import { assert, isNumber } from '@polkadot/util'; import RequestBytesSign from '../RequestBytesSign'; import RequestExtrinsicSign from '../RequestExtrinsicSign'; import { withErrorLog } from './helpers'; -import State from './State'; +import State, { AuthResponse } from './State'; import { createSubscription, unsubscribe } from './subscriptions'; function transformAccounts (accounts: SubjectInfo, anyType = false): InjectedAccount[] { @@ -42,21 +42,33 @@ export default class Tabs { this.#state = state; } - private authorize (url: string, request: RequestAuthorizeTab): Promise { + private filterForAuthorizedAccounts (accounts: InjectedAccount[], url: string): InjectedAccount[] { + return accounts.filter( + (allAcc) => this.#state.authUrls[this.#state.stripUrl(url)] + .authorizedAccounts + .includes(allAcc.address) + ); + } + + private authorize (url: string, request: RequestAuthorizeTab): Promise { return this.#state.authorizeUrl(url, request); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - private accountsList (url: string, { anyType }: RequestAccountList): InjectedAccount[] { - return transformAccounts(accountsObservable.subject.getValue(), anyType); + private accountsListAuthorized (url: string, { anyType }: RequestAccountList): InjectedAccount[] { + const transformedAccounts = transformAccounts(accountsObservable.subject.getValue(), anyType); + + return this.filterForAuthorizedAccounts(transformedAccounts, url); } - // FIXME This looks very much like what we have in Extension - private accountsSubscribe (url: string, id: string, port: chrome.runtime.Port): boolean { - const cb = createSubscription<'pub(accounts.subscribe)'>(id, port); - const subscription = accountsObservable.subject.subscribe((accounts: SubjectInfo): void => - cb(transformAccounts(accounts)) - ); + private accountsSubscribeAuthorized (url: string, id: string, port: chrome.runtime.Port): boolean { + const cb = createSubscription<'pub(accounts.subscribeAuthorized)'>(id, port); + const subscription = accountsObservable.subject.subscribe((accounts: SubjectInfo): void => { + const transformedAccounts = transformAccounts(accounts); + + return cb( + this.filterForAuthorizedAccounts(transformedAccounts, url) + ); + }); port.onDisconnect.addListener((): void => { unsubscribe(id); @@ -182,11 +194,11 @@ export default class Tabs { case 'pub(authorize.tab)': return this.authorize(url, request as RequestAuthorizeTab); - case 'pub(accounts.list)': - return this.accountsList(url, request as RequestAccountList); + case 'pub(accounts.listAuthorized)': + return this.accountsListAuthorized(url, request as RequestAccountList); - case 'pub(accounts.subscribe)': - return this.accountsSubscribe(url, id, port); + case 'pub(accounts.subscribeAuthorized)': + return this.accountsSubscribeAuthorized(url, id, port); case 'pub(bytes.sign)': return this.bytesSign(url, request as SignerPayloadRaw); diff --git a/packages/extension-base/src/background/types.ts b/packages/extension-base/src/background/types.ts index d506077db66..3506836f9c7 100644 --- a/packages/extension-base/src/background/types.ts +++ b/packages/extension-base/src/background/types.ts @@ -14,7 +14,7 @@ import type { KeypairType } from '@polkadot/util-crypto/types'; import { TypeRegistry } from '@polkadot/types'; import { ALLOWED_PATH } from '../defaults'; -import { AuthUrls } from './handlers/State'; +import { AuthResponse, AuthUrls } from './handlers/State'; type KeysWithDefinedValues = { [K in keyof T]: T[K] extends undefined ? never : K @@ -51,6 +51,8 @@ export type AccountsContext = { accounts: AccountJson[]; hierarchy: AccountWithChildren[]; master?: AccountJson; + selectedAccounts?: AccountJson['address'][]; + setSelectedAccounts?: (address: AccountJson['address'][]) => void; } export interface AuthorizeRequest { @@ -82,6 +84,7 @@ export interface RequestSignatures { 'pri(accounts.export)': [RequestAccountExport, ResponseAccountExport]; 'pri(accounts.batchExport)': [RequestAccountBatchExport, ResponseAccountsExport] 'pri(accounts.forget)': [RequestAccountForget, boolean]; + 'pri(accounts.list)': [RequestAccountList, InjectedAccount[]]; 'pri(accounts.show)': [RequestAccountShow, boolean]; 'pri(accounts.tie)': [RequestAccountTie, boolean]; 'pri(accounts.subscribe)': [RequestAccountSubscribe, boolean, AccountJson[]]; @@ -89,10 +92,10 @@ export interface RequestSignatures { '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(authorize.remove)': [string, ResponseAuthorizeList]; + 'pri(authorize.delete.request)': [string, void]; + 'pri(authorize.update)': [RequestUpdateAuthorizedAccounts, void] 'pri(derivation.create)': [RequestDeriveCreate, boolean]; 'pri(derivation.validate)': [RequestDeriveValidate, ResponseDeriveValidate]; 'pri(json.restore)': [RequestJsonRestore, void]; @@ -113,9 +116,9 @@ export interface RequestSignatures { 'pri(signing.requests)': [RequestSigningSubscribe, boolean, SigningRequest[]]; 'pri(window.open)': [AllowedPath, boolean]; // public/external requests, i.e. from a page - 'pub(accounts.list)': [RequestAccountList, InjectedAccount[]]; - 'pub(accounts.subscribe)': [RequestAccountSubscribe, boolean, InjectedAccount[]]; - 'pub(authorize.tab)': [RequestAuthorizeTab, null]; + 'pub(accounts.listAuthorized)': [RequestAccountList, InjectedAccount[]]; + 'pub(accounts.subscribeAuthorized)': [RequestAccountSubscribe, boolean, InjectedAccount[]]; + 'pub(authorize.tab)': [RequestAuthorizeTab, Promise]; 'pub(bytes.sign)': [SignerPayloadRaw, ResponseSigning]; 'pub(extrinsic.sign)': [SignerPayloadJSON, ResponseSigning]; 'pub(metadata.list)': [null, InjectedMetadataKnown[]]; @@ -152,10 +155,12 @@ export interface RequestAuthorizeTab { export interface RequestAuthorizeApprove { id: string; + authorizedAccounts: string[] } -export interface RequestAuthorizeReject { - id: string; +export interface RequestUpdateAuthorizedAccounts { + url: string; + authorizedAccounts: string[] } export type RequestAuthorizeSubscribe = null; diff --git a/packages/extension-base/src/page/Accounts.ts b/packages/extension-base/src/page/Accounts.ts index ff5cb4fd023..b821f9b74e4 100644 --- a/packages/extension-base/src/page/Accounts.ts +++ b/packages/extension-base/src/page/Accounts.ts @@ -13,11 +13,11 @@ export default class Accounts implements InjectedAccounts { } public get (anyType?: boolean): Promise { - return sendRequest('pub(accounts.list)', { anyType }); + return sendRequest('pub(accounts.listAuthorized)', { anyType }); } public subscribe (cb: (accounts: InjectedAccount[]) => unknown): Unsubcall { - sendRequest('pub(accounts.subscribe)', null, cb) + sendRequest('pub(accounts.subscribeAuthorized)', null, cb) .catch((error: Error) => console.error(error)); return (): void => { diff --git a/packages/extension-ui/src/Popup/Accounts/Account.tsx b/packages/extension-ui/src/Popup/Accounts/Account.tsx index 76ec996e818..da277f5eb3a 100644 --- a/packages/extension-ui/src/Popup/Accounts/Account.tsx +++ b/packages/extension-ui/src/Popup/Accounts/Account.tsx @@ -3,13 +3,13 @@ import type { AccountJson } from '@polkadot/extension-base/background/types'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; import { canDerive } from '@polkadot/extension-base/utils'; import { ThemeProps } from '@polkadot/extension-ui/types'; -import { Address, Dropdown, Link, MenuDivider } from '../../components'; +import { AccountContext, Address, Checkbox, Dropdown, Link, MenuDivider } from '../../components'; import useGenesisHashOptions from '../../hooks/useGenesisHashOptions'; import useTranslation from '../../hooks/useTranslation'; import { editAccount, tieAccount } from '../../messaging'; @@ -18,6 +18,9 @@ import { Name } from '../../partials'; interface Props extends AccountJson { className?: string; parentName?: string; + showVisibilityAction?: boolean; + withCheckbox?: boolean; + withMenu?: boolean } interface EditState { @@ -25,11 +28,26 @@ interface EditState { toggleActions: number; } -function Account ({ address, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, suri, type }: Props): React.ReactElement { +function Account ({ address, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction, suri, type, withCheckbox = false, withMenu = true }: Props): React.ReactElement { const { t } = useTranslation(); const [{ isEditing, toggleActions }, setEditing] = useState({ isEditing: false, toggleActions: 0 }); const [editedName, setName] = useState(name); + const [checked, setChecked] = useState(false); const genesisOptions = useGenesisHashOptions(); + const { selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext); + const isSelected = useMemo(() => selectedAccounts?.includes(address) || false, [address, selectedAccounts]); + + useEffect(() => { + setChecked(isSelected); + }, [isSelected]); + + const _onCheckboxChange = useCallback(() => { + const newList = selectedAccounts?.includes(address) + ? selectedAccounts.filter((account) => account !== address) + : [...selectedAccounts, address]; + + setSelectedAccounts && setSelectedAccounts(newList); + }, [address, selectedAccounts, setSelectedAccounts]); const _onChangeGenesis = useCallback( (genesisHash?: string | null): void => { @@ -107,8 +125,16 @@ function Account ({ address, className, genesisHash, isExternal, isHardware, isH return (
+ {withCheckbox && ( + + )}
diff --git a/packages/extension-ui/src/Popup/Accounts/AccountsTree.tsx b/packages/extension-ui/src/Popup/Accounts/AccountsTree.tsx index 3a05766891f..89220bd8f55 100644 --- a/packages/extension-ui/src/Popup/Accounts/AccountsTree.tsx +++ b/packages/extension-ui/src/Popup/Accounts/AccountsTree.tsx @@ -4,28 +4,57 @@ import type { AccountWithChildren } from '@polkadot/extension-base/background/types'; import React from 'react'; +import styled from 'styled-components'; import Account from './Account'; interface Props extends AccountWithChildren { + className?: string parentName?: string; + withCheckbox?: boolean + withMenu?: boolean + showHidden?: boolean } -export default function AccountsTree ({ parentName, suri, ...account }: Props): React.ReactElement { +function AccountsTree ({ className, parentName, showHidden = true, suri, withCheckbox = false, withMenu = true, ...account }: Props): React.ReactElement { return ( - <> - +
+ { (showHidden || !account.isHidden) && ( + + )} {account?.children?.map((child, index) => ( ))} - +
); } + +export default styled(AccountsTree)` + .accountWichCheckbox { + display: flex; + align-items: center; + + & .address { + flex: 1; + } + + & .accountTree-checkbox label span { + top: -12px; + } + } +`; diff --git a/packages/extension-ui/src/Popup/AuthManagement/AccountManagement.tsx b/packages/extension-ui/src/Popup/AuthManagement/AccountManagement.tsx new file mode 100644 index 00000000000..2e19b9f85ef --- /dev/null +++ b/packages/extension-ui/src/Popup/AuthManagement/AccountManagement.tsx @@ -0,0 +1,84 @@ +// Copyright 2019-2022 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ThemeProps } from '../../types'; + +import React, { useCallback, useContext, useEffect } from 'react'; +import { useParams } from 'react-router'; +import styled from 'styled-components'; + +import { AccountContext, ActionContext, Button } from '../../components'; +import useTranslation from '../../hooks/useTranslation'; +import { getAuthList, updateAuthorization } from '../../messaging'; +import { AccountSelection, Header } from '../../partials'; + +interface Props extends ThemeProps { + className?: string; +} + +function AccountManagement ({ className }: Props): React.ReactElement { + const { url } = useParams<{url: string}>(); + const { selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext); + const { t } = useTranslation(); + const onAction = useContext(ActionContext); + + useEffect(() => { + getAuthList() + .then(({ list }) => { + if (!list[url]) { + return; + } + + setSelectedAccounts && setSelectedAccounts(list[url].authorizedAccounts); + }) + .catch((e) => console.error(e)); + }, [setSelectedAccounts, url]); + + const _onApprove = useCallback( + (): void => { + updateAuthorization(selectedAccounts, url) + .then(() => onAction('/auth-list')) + .catch((error: Error) => console.error(error)); + }, + [onAction, selectedAccounts, url] + ); + + return ( + <> +
('Accounts connected to {{url}}', { replace: { url } })} + /> +
+ + +
+ + ); +} + +export default styled(AccountManagement)` + .accountSelection{ + .accountList{ + height: 390px; + } + } + .acceptButton { + width: 90%; + margin: 0.5rem auto 0; + } +`; diff --git a/packages/extension-ui/src/Popup/AuthManagement/WebsiteEntry.tsx b/packages/extension-ui/src/Popup/AuthManagement/WebsiteEntry.tsx index 8174997a05d..f96e4b9c070 100644 --- a/packages/extension-ui/src/Popup/AuthManagement/WebsiteEntry.tsx +++ b/packages/extension-ui/src/Popup/AuthManagement/WebsiteEntry.tsx @@ -4,50 +4,42 @@ import type { ThemeProps } from '../../types'; import React, { useCallback } from 'react'; +import { Link } from 'react-router-dom'; import styled from 'styled-components'; import { AuthUrlInfo } from '@polkadot/extension-base/background/handlers/State'; -import { RemoveAuth, Switch } from '@polkadot/extension-ui/components'; - -import useTranslation from '../../hooks/useTranslation'; +import { RemoveAuth } from '@polkadot/extension-ui/components'; +import { useTranslation } from '@polkadot/extension-ui/components/translate'; interface Props extends ThemeProps { className?: string; info: AuthUrlInfo; - toggleAuth: (url: string) => void; removeAuth: (url: string) => void; url: string; } -function WebsiteEntry ({ className = '', info, removeAuth, toggleAuth, url }: Props): React.ReactElement { +function WebsiteEntry ({ className = '', info, removeAuth, url }: Props): React.ReactElement { const { t } = useTranslation(); - - const switchAccess = useCallback(() => { - toggleAuth(url); - }, [toggleAuth, url]); - const _removeAuth = useCallback(() => { removeAuth(url); }, [removeAuth, url]); return ( -
+
+
{url}
- ('allowed')} - className='info' - onChange={switchAccess} - uncheckedLabel={t('denied')} - /> -
- -
+ { + t('{{total}} accounts', { + replace: { + total: info.authorizedAccounts.length + } + }) + }
); } @@ -55,14 +47,19 @@ function WebsiteEntry ({ className = '', info, removeAuth, toggleAuth, url }: Pr export default styled(WebsiteEntry)(({ theme }: Props) => ` display: flex; align-items: center; + margin-top: .2rem; .url{ flex: 1; } - &.denied { - .slider::before { - background-color: ${theme.backButtonBackground}; - } + .connectedAccounts{ + margin-left: .5rem; + background-color: ${theme.primaryColor}; + color: white; + cursor: pointer; + padding: 0 0.5rem; + border-radius: 4px; + text-decoration: none; } `); diff --git a/packages/extension-ui/src/Popup/AuthManagement/index.tsx b/packages/extension-ui/src/Popup/AuthManagement/index.tsx index 256f27e5750..de301957a89 100644 --- a/packages/extension-ui/src/Popup/AuthManagement/index.tsx +++ b/packages/extension-ui/src/Popup/AuthManagement/index.tsx @@ -10,7 +10,7 @@ import { AuthUrlInfo, AuthUrls } from '@polkadot/extension-base/background/handl import { InputFilter } from '@polkadot/extension-ui/components'; import useTranslation from '../../hooks/useTranslation'; -import { getAuthList, removeAuthorization, toggleAuthorization } from '../../messaging'; +import { getAuthList, removeAuthorization } from '../../messaging'; import { Header } from '../../partials'; import WebsiteEntry from './WebsiteEntry'; @@ -33,12 +33,6 @@ function AuthManagement ({ className }: Props): React.ReactElement { setFilter(filter); }, []); - const toggleAuth = useCallback((url: string) => { - toggleAuthorization(url) - .then(({ list }) => setAuthList(list)) - .catch(console.error); - }, []); - const removeAuth = useCallback((url: string) => { removeAuthorization(url) .then(({ list }) => setAuthList(list)) @@ -52,36 +46,34 @@ function AuthManagement ({ className }: Props): React.ReactElement { smallMargin text={t('Manage Website Access')} /> - <> +
('example.com')} value={filter} withReset /> -
- { - !authList || !Object.entries(authList)?.length - ?
{t('No website request yet!')}
- : <> -
- {Object.entries(authList) - .filter(([url]: [string, AuthUrlInfo]) => url.includes(filter)) - .map( - ([url, info]: [string, AuthUrlInfo]) => - - )} -
- - } -
- + { + !authList || !Object.entries(authList)?.length + ?
{t('No website request yet!')}
+ : <> +
+ {Object.entries(authList) + .filter(([url]: [string, AuthUrlInfo]) => url.includes(filter)) + .map( + ([url, info]: [string, AuthUrlInfo]) => + + )} +
+ + } +
); } @@ -90,7 +82,12 @@ export default styled(AuthManagement)` height: calc(100vh - 2px); overflow-y: auto; - .empty-list { + .empty-list{ text-align: center; } + + .inputFilter{ + margin-bottom: 0.8rem; + padding: 0 !important; + } `; diff --git a/packages/extension-ui/src/Popup/Authorize/Authorize.spec.tsx b/packages/extension-ui/src/Popup/Authorize/Authorize.spec.tsx index 04fca775d5d..9b041e1ab77 100644 --- a/packages/extension-ui/src/Popup/Authorize/Authorize.spec.tsx +++ b/packages/extension-ui/src/Popup/Authorize/Authorize.spec.tsx @@ -3,53 +3,110 @@ import '@polkadot/extension-mocks/chrome'; -import type { AuthorizeRequest } from '@polkadot/extension-base/background/types'; +import type { AccountJson, AuthorizeRequest } from '@polkadot/extension-base/background/types'; import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; import { configure, mount, ReactWrapper } from 'enzyme'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { AuthorizeReqContext, Icon, themes } from '../../components'; +import { AccountContext, AuthorizeReqContext, themes, Warning } from '../../components'; import { Header } from '../../partials'; +import { buildHierarchy } from '../../util/buildHierarchy'; +import Account from '../Accounts/Account'; import Request from './Request'; import Authorize from '.'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call configure({ adapter: new Adapter() }); +const oneRequest = [{ id: '1', request: { origin: '???' }, url: 'http://polkadot.org' }]; + +const twoRequests = [ + ...oneRequest, + { id: '2', request: { origin: 'abc' }, url: 'http://polkadot.pl' } +]; + +const oneAccount = [ + { address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', name: 'A', type: 'sr25519' } +] as AccountJson[]; + +const twoAccountsOnehidden = [ + ...oneAccount, + { address: '5GYmFzQCuC5u3tQNiMZNbFGakrz3Jq31NmMg4D2QAkSoQ2g5', isHidden: true, name: 'B', type: 'sr25519' } +] as AccountJson[]; + +const threeAccountsOnehidden = [ + ...twoAccountsOnehidden, + { address: '5D2TPhGEy2FhznvzaNYW9AkuMBbg3cyRemnPsBvBY4ZhkZXA', name: 'BB', parentAddress: twoAccountsOnehidden[1].address, type: 'sr25519' } +] as AccountJson[]; + describe('Authorize', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-return - const mountAuthorize = (authorizeRequests: AuthorizeRequest[] = []): ReactWrapper => mount( + const mountAuthorize = (authorizeRequests: AuthorizeRequest[] = [], accounts: AccountJson[] = oneAccount): ReactWrapper => mount( - - - + + + + + ); it('render component', () => { const wrapper = mountAuthorize(); - expect(wrapper.find(Header).text()).toBe('Authorize'); + expect(wrapper.find(Header).text()).toBe('Account connection request'); expect(wrapper.find(Request).length).toBe(0); }); it('render requests', () => { - const wrapper = mountAuthorize([{ id: '1', request: { origin: '???' }, url: 'http://polkadot.org' }]); + const wrapper = mountAuthorize(oneRequest); expect(wrapper.find(Request).length).toBe(1); - expect(wrapper.find(Request).find('.tab-info').text()).toBe('An application, self-identifying as ??? is requesting access from http://polkadot.org.'); + expect(wrapper.find(Request).find('.warning-message').text()).toBe('An application, self-identifying as ??? is requesting access from http://polkadot.org'); }); it('render more request but just one accept button', () => { - const wrapper = mountAuthorize([ - { id: '1', request: { origin: '???' }, url: 'http://polkadot.org' }, - { id: '2', request: { origin: 'abc' }, url: 'http://polkadot.pl' } - ]); + const wrapper = mountAuthorize(twoRequests); expect(wrapper.find(Request).length).toBe(2); - expect(wrapper.find(Icon).length).toBe(2); - expect(wrapper.find(Request).at(1).find('.tab-info').text()).toBe('An application, self-identifying as abc is requesting access from http://polkadot.pl.'); + expect(wrapper.find(Warning).length).toBe(2); + expect(wrapper.find(Request).at(1).find('.warning-message').text()).toBe('An application, self-identifying as abc is requesting access from http://polkadot.pl'); + expect(wrapper.find('button.acceptButton').length).toBe(1); + }); + + it('render a warning and explication text when there is no account', () => { + const wrapper = mountAuthorize(oneRequest, []); + + expect(wrapper.find(Request).length).toBe(1); + expect(wrapper.find(Request).find('.warning-message').text()).toBe("You do not have any account. Please create an account and refresh the application's page."); expect(wrapper.find('button.acceptButton').length).toBe(1); }); + + it('show the right amount of accounts', () => { + const wrapper = mountAuthorize(oneRequest); + + expect(wrapper.find(Request).length).toBe(1); + expect(wrapper.find(Account).length).toBe(1); + expect(wrapper.find('button.acceptButton').length).toBe(1); + }); + + it('does not show the hidden accounts', () => { + const wrapper = mountAuthorize(oneRequest, twoAccountsOnehidden); + + expect(wrapper.find(Request).length).toBe(1); + expect(wrapper.find(Account).length).toBe(1); + }); + + it('shows the children of hidden accounts', () => { + const wrapper = mountAuthorize(oneRequest, threeAccountsOnehidden); + + expect(wrapper.find(Request).length).toBe(1); + expect(wrapper.find(Account).length).toBe(2); + }); }); diff --git a/packages/extension-ui/src/Popup/Authorize/NoAccount.tsx b/packages/extension-ui/src/Popup/Authorize/NoAccount.tsx new file mode 100644 index 00000000000..4597d2f930f --- /dev/null +++ b/packages/extension-ui/src/Popup/Authorize/NoAccount.tsx @@ -0,0 +1,49 @@ +// Copyright 2019-2022 @polkadot/extension-ui authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ThemeProps } from '../../types'; + +import { t } from 'i18next'; +import React, { useCallback } from 'react'; +import { Trans } from 'react-i18next'; +import styled from 'styled-components'; + +import { Button, Warning } from '@polkadot/extension-ui/components'; +import { deleteAuthRequest } from '@polkadot/extension-ui/messaging'; + +interface Props extends ThemeProps { + authId: string; + className?: string; +} + +function NoAccount ({ authId, className }: Props): React.ReactElement { + const _onClick = useCallback(() => { + deleteAuthRequest(authId).catch(console.error); + }, [authId] + ); + + return ( +
+ + You do not have any account. Please create an account and refresh the application's page. + + +
+ ); +} + +export default styled(NoAccount)` + .acceptButton { + width: 90%; + margin: 25px auto 0; + } + + .warningMargin { + margin: 1rem 24px 0 1.45rem; + } +`; diff --git a/packages/extension-ui/src/Popup/Authorize/Request.tsx b/packages/extension-ui/src/Popup/Authorize/Request.tsx index ef0f477272e..17651abb732 100644 --- a/packages/extension-ui/src/Popup/Authorize/Request.tsx +++ b/packages/extension-ui/src/Popup/Authorize/Request.tsx @@ -4,13 +4,14 @@ import type { RequestAuthorizeTab } from '@polkadot/extension-base/background/types'; import type { ThemeProps } from '../../types'; -import React, { useCallback, useContext } from 'react'; -import { Trans } from 'react-i18next'; +import React, { useCallback, useContext, useEffect } from 'react'; import styled from 'styled-components'; -import { ActionBar, ActionContext, Button, Icon, Link, Warning } from '../../components'; +import { AccountContext, ActionBar, ActionContext, Button, Link } from '../../components'; import useTranslation from '../../hooks/useTranslation'; -import { approveAuthRequest, rejectAuthRequest } from '../../messaging'; +import { approveAuthRequest, deleteAuthRequest } from '../../messaging'; +import { AccountSelection } from '../../partials'; +import NoAccount from './NoAccount'; interface Props extends ThemeProps { authId: string; @@ -21,130 +22,77 @@ interface Props extends ThemeProps { } function Request ({ authId, className, isFirst, request: { origin }, url }: Props): React.ReactElement { + const { accounts, selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext); const { t } = useTranslation(); const onAction = useContext(ActionContext); + useEffect(() => { + setSelectedAccounts && setSelectedAccounts([]); + }, [setSelectedAccounts]); + const _onApprove = useCallback( (): void => { - approveAuthRequest(authId) + approveAuthRequest(authId, selectedAccounts) .then(() => onAction()) .catch((error: Error) => console.error(error)); }, - [authId, onAction] + [authId, onAction, selectedAccounts] ); - const _onReject = useCallback( + const _onClose = useCallback( (): void => { - rejectAuthRequest(authId) + deleteAuthRequest(authId) .then(() => onAction()) .catch((error: Error) => console.error(error)); }, [authId, onAction] ); + if (!accounts.length) { + return ; + } + return (
-
-
- -
- An application, self-identifying as {origin} is requesting access from{' '} - - {url} - . - -
-
- {isFirst && ( - <> - - {t('Only approve this request if you trust the application. Approving gives the application access to the addresses of your accounts.')} - - - - )} - - - Reject - - -
+ + {isFirst && ( + + )} + + + {t('Ask again later')} + +
); } -export default styled(Request)(({ theme }: Props) => ` - - .icon { - background: ${theme.buttonBackgroundDanger}; - color: white; - min-width: 18px; - width: 14px; - height: 18px; - font-size: 10px; - line-height: 20px; - margin: 16px 15px 0 1.35rem; - font-weight: 800; - padding-left: 0.5px; - } - - .tab-info { - overflow: hidden; - margin: 0.75rem 20px 0 0; - } - - .tab-name, - .tab-url { - color: ${theme.textColor}; - display: inline-block; - max-height: 10rem; - max-width: 20rem; - overflow: hidden; - text-overflow: ellipsis; - vertical-align: top; - cursor: pointer; - text-decoration: underline; - } - - .requestInfo { - display: flex; - flex-direction: column; - align-items: center; - margin-bottom: 8px; - background: ${theme.highlightedAreaBackground}; - } - - .info { - display: flex; - flex-direction: row; - } - +export default styled(Request)` .acceptButton { width: 90%; - margin: 25px auto 0; - } - - .warningMargin { - margin: 24px 24px 0 1.45rem; + margin: 1rem auto 0; } .rejectionButton { margin: 8px 0 15px 0; text-decoration: underline; + + .closeLink { + margin: auto; + } } -`); +`; diff --git a/packages/extension-ui/src/Popup/Authorize/index.tsx b/packages/extension-ui/src/Popup/Authorize/index.tsx index 451027adf87..1d873fd9d03 100644 --- a/packages/extension-ui/src/Popup/Authorize/index.tsx +++ b/packages/extension-ui/src/Popup/Authorize/index.tsx @@ -22,7 +22,10 @@ function Authorize ({ className = '' }: Props): React.ReactElement { return ( <>
-
('Authorize')} /> +
('Account connection request')} + /> {requests.map(({ id, request, url }, index): React.ReactNode => ( { return false; } -function initAccountContext (accounts: AccountJson[]): AccountsContext { +function initAccountContext ({ accounts, selectedAccounts, setSelectedAccounts }: Omit): AccountsContext { const hierarchy = buildHierarchy(accounts); const master = hierarchy.find(({ isExternal, type }) => !isExternal && canDerive(type)); return { accounts, hierarchy, - master + master, + selectedAccounts, + setSelectedAccounts }; } export default function Popup (): React.ReactElement { const [accounts, setAccounts] = useState(null); const [accountCtx, setAccountCtx] = useState({ accounts: [], hierarchy: [] }); + const [selectedAccounts, setSelectedAccounts] = useState([]); const [authRequests, setAuthRequests] = useState(null); const [cameraOn, setCameraOn] = useState(startSettings.camera === 'on'); const [mediaAllowed, setMediaAllowed] = useState(false); @@ -103,8 +107,8 @@ export default function Popup (): React.ReactElement { }, []); useEffect((): void => { - setAccountCtx(initAccountContext(accounts || [])); - }, [accounts]); + setAccountCtx(initAccountContext({ accounts: accounts || [], selectedAccounts, setSelectedAccounts })); + }, [accounts, selectedAccounts]); useEffect((): void => { requestMediaAccess(cameraOn) @@ -148,6 +152,7 @@ export default function Popup (): React.ReactElement { {wrapWithErrorBoundary(, 'restore-json')} {wrapWithErrorBoundary(, 'derived-address-locked')} {wrapWithErrorBoundary(, 'derive-address')} + {wrapWithErrorBoundary(, 'manage-url')} {wrapWithErrorBoundary(, 'phishing-page-redirect')} { +function Address ({ actions, address, children, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, showVisibilityAction = false, suri, toggleActions, type: givenType }: Props): React.ReactElement { const { t } = useTranslation(); const { accounts } = useContext(AccountContext); const settings = useContext(SettingsContext); @@ -268,7 +269,7 @@ function Address ({ actions, address, children, className, genesisHash, isExtern title={t('copy address')} /> - {actions && ( + {(actions || showVisibilityAction) && ( void; onClick?: () => void; } -function Checkbox ({ checked, className, label, onChange, onClick }: Props): React.ReactElement { +function Checkbox ({ checked, className, indeterminate, label, onChange, onClick }: Props): React.ReactElement { + const checkboxRef = React.useRef(null); + + useEffect(() => { + if (indeterminate && checkboxRef.current) { + checkboxRef.current.indeterminate = true; + } + }, [indeterminate]); + const _onChange = useCallback( (event: React.ChangeEvent) => onChange && onChange(event.target.checked), [onChange] @@ -32,9 +41,10 @@ function Checkbox ({ checked, className, label, onChange, onClick }: Props): Rea