feat: add cancel authorization request functionality and improve UI components (#1491)

This commit is contained in:
Francis O'Brien 2024-12-20 14:03:36 +00:00 committed by GitHub
parent 6f29ec7eb4
commit 52acbc830a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 235 additions and 83 deletions

View File

@ -524,6 +524,8 @@ export default class Extension {
return { list: remAuth }; return { list: remAuth };
} }
// Reject the authorization request and add the URL to the authorized list with no keys.
// The site will not prompt for re-authorization on future visits.
private rejectAuthRequest (id: string): void { private rejectAuthRequest (id: string): void {
const queued = this.#state.getAuthRequest(id); const queued = this.#state.getAuthRequest(id);
@ -534,6 +536,18 @@ export default class Extension {
reject(new Error('Rejected')); reject(new Error('Rejected'));
} }
// Cancel the authorization request and do not add the URL to the authorized list.
// The site will prompt for authorization on future visits.
private cancelAuthRequest (id: string): void {
const queued = this.#state.getAuthRequest(id);
assert(queued, 'Unable to find request');
const { reject } = queued;
reject(new Error('Cancelled'));
}
private updateCurrentTabs ({ urls }: RequestActiveTabsUrlUpdate) { private updateCurrentTabs ({ urls }: RequestActiveTabsUrlUpdate) {
this.#state.updateCurrentTabsUrl(urls); this.#state.updateCurrentTabsUrl(urls);
} }
@ -558,6 +572,9 @@ export default class Extension {
case 'pri(authorize.reject)': case 'pri(authorize.reject)':
return this.rejectAuthRequest(request as string); return this.rejectAuthRequest(request as string);
case 'pri(authorize.cancel)':
return this.cancelAuthRequest(request as string);
case 'pri(authorize.requests)': case 'pri(authorize.requests)':
return port && this.authorizeSubscribe(id, port); return port && this.authorizeSubscribe(id, port);

View File

@ -273,8 +273,14 @@ export default class State {
return { return {
// eslint-disable-next-line @typescript-eslint/no-misused-promises // eslint-disable-next-line @typescript-eslint/no-misused-promises
reject: async (error: Error): Promise<void> => { reject: async (error: Error): Promise<void> => {
await complete(); if (error.message === 'Cancelled') {
reject(error); delete this.#authRequests[id];
this.updateIconAuth(true);
reject(new Error('Connection request was cancelled by the user.'));
} else {
await complete();
reject(new Error('Connection request was rejected by the user.'));
}
}, },
// eslint-disable-next-line @typescript-eslint/no-misused-promises // eslint-disable-next-line @typescript-eslint/no-misused-promises
resolve: async ({ authorizedAccounts, result }: AuthResponse): Promise<void> => { resolve: async ({ authorizedAccounts, result }: AuthResponse): Promise<void> => {

View File

@ -97,6 +97,7 @@ export interface RequestSignatures {
'pri(authorize.requests)': [RequestAuthorizeSubscribe, boolean, AuthorizeRequest[]]; 'pri(authorize.requests)': [RequestAuthorizeSubscribe, boolean, AuthorizeRequest[]];
'pri(authorize.remove)': [string, ResponseAuthorizeList]; 'pri(authorize.remove)': [string, ResponseAuthorizeList];
'pri(authorize.reject)': [string, void]; 'pri(authorize.reject)': [string, void];
'pri(authorize.cancel)': [string, void];
'pri(authorize.update)': [RequestUpdateAuthorizedAccounts, void]; 'pri(authorize.update)': [RequestUpdateAuthorizedAccounts, void];
'pri(activeTabsUrl.update)': [RequestActiveTabsUrlUpdate, void]; 'pri(activeTabsUrl.update)': [RequestActiveTabsUrlUpdate, void];
'pri(connectedTabsUrl.get)': [null, ConnectedTabsUrlResponse]; 'pri(connectedTabsUrl.get)': [null, ConnectedTabsUrlResponse];

View File

@ -42,41 +42,54 @@ function AccountManagement ({ className }: Props): React.ReactElement<Props> {
); );
return ( return (
<> <div className={`${className}`}>
<Header <Header
showBackArrow showBackArrow
smallMargin={true} smallMargin={true}
text={t('Accounts connected to {{url}}', { replace: { url } })} text={t('Accounts connected to {{url}}', { replace: { url } })}
/> />
<div className={className}> <div className='content'>
<AccountSelection <AccountSelection
className='accountSelection' origin={url}
origin={origin}
showHidden={true} showHidden={true}
url={url} url={url}
withWarning={false} withWarning={false}
/> />
<Button <div className='footer'>
className='acceptButton' <Button
onClick={_onApprove} className='acceptButton'
> onClick={_onApprove}
{t('Connect {{total}} account(s)', { replace: { >
total: selectedAccounts.length {t('Connect {{total}} account(s)', { replace: {
} })} total: selectedAccounts.length
</Button> } })}
</Button>
</div>
</div> </div>
</> </div>
); );
} }
export default styled(AccountManagement)<Props>` export default styled(AccountManagement)<Props>`
.accountSelection{ display: flex;
.accountList{ flex-direction: column;
height: 390px; height: 100%;
} min-height: 550px;
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
} }
.footer {
background: var(--background);
flex-shrink: 0;
}
.acceptButton { .acceptButton {
width: 90%; width: 90%;
margin: 0.5rem auto 0; margin: 0.5rem auto;
} }
`; `;

View File

@ -3,11 +3,11 @@
import type { RequestAuthorizeTab } from '@polkadot/extension-base/background/types'; import type { RequestAuthorizeTab } from '@polkadot/extension-base/background/types';
import React, { useCallback, useContext, useEffect } from 'react'; import React, { useCallback, useContext, useEffect, useState } from 'react';
import { AccountContext, ActionBar, ActionContext, Button, Link } from '../../components/index.js'; import { AccountContext, ActionContext, Button } from '../../components/index.js';
import { useTranslation } from '../../hooks/index.js'; import { useTranslation } from '../../hooks/index.js';
import { approveAuthRequest, rejectAuthRequest } from '../../messaging.js'; import { approveAuthRequest, cancelAuthRequest, rejectAuthRequest } from '../../messaging.js';
import { AccountSelection } from '../../partials/index.js'; import { AccountSelection } from '../../partials/index.js';
import { styled } from '../../styled.js'; import { styled } from '../../styled.js';
import NoAccount from './NoAccount.js'; import NoAccount from './NoAccount.js';
@ -15,15 +15,15 @@ import NoAccount from './NoAccount.js';
interface Props { interface Props {
authId: string; authId: string;
className?: string; className?: string;
isFirst: boolean;
request: RequestAuthorizeTab; request: RequestAuthorizeTab;
url: string; url: string;
} }
function Request ({ authId, className, isFirst, request: { origin }, url }: Props): React.ReactElement<Props> { function Request ({ authId, className, request: { origin }, url }: Props): React.ReactElement<Props> {
const { accounts, selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext); const { accounts, selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext);
const { t } = useTranslation(); const { t } = useTranslation();
const onAction = useContext(ActionContext); const onAction = useContext(ActionContext);
const [dontAskAgain, setDontAskAgain] = useState(false);
useEffect(() => { useEffect(() => {
const defaultAccountSelection = accounts const defaultAccountSelection = accounts
@ -42,13 +42,22 @@ function Request ({ authId, className, isFirst, request: { origin }, url }: Prop
[authId, onAction, selectedAccounts] [authId, onAction, selectedAccounts]
); );
const _onClose = useCallback( const _onReject = useCallback(
(): void => { (): void => {
rejectAuthRequest(authId) const rejectFunction = dontAskAgain ? rejectAuthRequest : cancelAuthRequest;
rejectFunction(authId)
.then(() => onAction()) .then(() => onAction())
.catch((error: Error) => console.error(error)); .catch((error: Error) => console.error(error));
}, },
[authId, onAction] [authId, onAction, dontAskAgain]
);
const _onToggleDontAskAgain = useCallback(
(): void => {
setDontAskAgain((prev) => !prev);
},
[]
); );
if (!accounts.length) { if (!accounts.length) {
@ -61,42 +70,69 @@ function Request ({ authId, className, isFirst, request: { origin }, url }: Prop
origin={origin} origin={origin}
url={url} url={url}
/> />
{isFirst && ( <div className='footer'>
<Button <div className='buttonContainer'>
className='acceptButton' <Button
onClick={_onApprove} className='acceptButton'
> onClick={_onApprove}
{t('Connect {{total}} account(s)', { replace: { >
total: selectedAccounts.length {t('Connect {{total}} account(s)', { replace: {
} })} total: selectedAccounts.length
</Button> } })}
)} </Button>
<ActionBar className='rejectionButton'> <Button
<Link className='rejectButton'
className='closeLink' isDanger
isDanger onClick={_onReject}
onClick={_onClose} >
> {t('Reject')}
{t('Reject')} </Button>
</Link> </div>
</ActionBar> <div className='dontAskAgainContainer'>
<input
checked={dontAskAgain}
onChange={_onToggleDontAskAgain}
type='checkbox'
/>
<label>{t("Don't ask again")}</label>
</div>
</div>
</div> </div>
); );
} }
export default styled(Request)<Props>` export default styled(Request)<Props>`
.acceptButton { display: flex;
width: 90%; flex-direction: column;
margin: .5rem auto 0; flex: 1;
overflow-y: auto;
.footer {
padding: 1rem 1rem 0rem 1rem;
background: var(--background);
} }
.rejectionButton { .buttonContainer {
margin: 0 0 15px 0; display: flex;
text-decoration: underline; justify-content: space-between;
width: 100%;
margin-bottom: 0.5rem;
}
.closeLink { .acceptButton, .rejectButton {
margin: auto; width: 48%;
padding: 0; height: 40px;
}
.dontAskAgainContainer {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
input {
margin-right: 0.5rem;
} }
} }
`; `;

View File

@ -1,7 +1,7 @@
// Copyright 2019-2024 @polkadot/extension-ui authors & contributors // Copyright 2019-2024 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
import React, { useContext } from 'react'; import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { AuthorizeReqContext } from '../../components/index.js'; import { AuthorizeReqContext } from '../../components/index.js';
import { useTranslation } from '../../hooks/index.js'; import { useTranslation } from '../../hooks/index.js';
@ -16,35 +16,76 @@ interface Props {
function Authorize ({ className = '' }: Props): React.ReactElement { function Authorize ({ className = '' }: Props): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const requests = useContext(AuthorizeReqContext); const requests = useContext(AuthorizeReqContext);
const [currentIndex, setCurrentIndex] = useState(0);
const currentIndexRef = useRef(currentIndex);
useEffect(() => {
if (requests.length <= currentIndexRef.current) {
setCurrentIndex((prevIndex) => Math.max(prevIndex - 1, 0));
}
}, [requests.length]);
const handleNext = useCallback(() => {
setCurrentIndex((prevIndex) => {
const newIndex = Math.min(prevIndex + 1, requests.length - 1);
currentIndexRef.current = newIndex;
return newIndex;
});
}, [requests.length]);
const handlePrevious = useCallback(() => {
setCurrentIndex((prevIndex) => {
const newIndex = Math.max(prevIndex - 1, 0);
currentIndexRef.current = newIndex;
return newIndex;
});
}, []);
return ( return (
<> <div className={`${className}`}>
<div className={`${className} ${requests.length === 1 ? 'lastRequest' : ''}`}> <Header
<Header smallMargin={true}
smallMargin={true} text={t('Account connection request')}
text={t('Account connection request')} />
{requests.length > 1 && (
<div className='pagination'>
<button
className={currentIndex === 0 ? 'hidden' : ''}
onClick={handlePrevious}
>
{t('Previous')}
</button>
<span>{`${currentIndex + 1} / ${requests.length}`}</span>
<button
className={currentIndex === requests.length - 1 ? 'hidden' : ''}
onClick={handleNext}
>
{t('Next')}
</button>
</div>
)}
{requests.length > 0 && requests[currentIndex] && (
<Request
authId={requests[currentIndex].id}
className='request'
key={requests[currentIndex].id}
request={requests[currentIndex].request}
url={requests[currentIndex].url}
/> />
{requests.map(({ id, request, url }, index): React.ReactNode => ( )}
<Request </div>
authId={id}
className='request'
isFirst={index === 0}
key={id}
request={request}
url={url}
/>
))}
</div>
</>
); );
} }
export default styled(Authorize)<Props>` export default styled(Authorize)<Props>`
overflow-y: auto; display: flex;
flex-direction: column;
&.lastRequest { height: 100%;
overflow: hidden; overflow: auto;
}
&& { && {
padding: 0; padding: 0;
@ -52,5 +93,32 @@ export default styled(Authorize)<Props>`
.request { .request {
padding: 0 24px; padding: 0 24px;
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
min-height: 400px;
}
.pagination {
display: flex;
justify-content: space-between;
background: var(--background);
button {
background: none;
border: none;
color: var(--textColor);
cursor: pointer;
padding: 0.5rem 1rem;
&.hidden {
visibility: hidden;
}
}
span {
align-self: center;
}
} }
`; `;

View File

@ -224,6 +224,10 @@ export async function rejectAuthRequest (requestId: string): Promise<void> {
return sendMessage('pri(authorize.reject)', requestId); return sendMessage('pri(authorize.reject)', requestId);
} }
export async function cancelAuthRequest (requestId: string): Promise<void> {
return sendMessage('pri(authorize.cancel)', requestId);
}
export async function subscribeMetadataRequests (cb: (accounts: MetadataRequest[]) => void): Promise<boolean> { export async function subscribeMetadataRequests (cb: (accounts: MetadataRequest[]) => void): Promise<boolean> {
return sendMessage('pri(metadata.requests)', null, cb); return sendMessage('pri(metadata.requests)', null, cb);
} }

View File

@ -17,7 +17,7 @@ interface Props {
withWarning?: boolean; withWarning?: boolean;
} }
function AccounSelection ({ className, origin, showHidden = false, url, withWarning = true }: Props): React.ReactElement<Props> { function AccountSelection ({ className, origin, showHidden = false, url, withWarning = true }: Props): React.ReactElement<Props> {
const { t } = useTranslation(); const { t } = useTranslation();
const { accounts, hierarchy, selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext); const { accounts, hierarchy, selectedAccounts = [], setSelectedAccounts } = useContext(AccountContext);
const [isIndeterminate, setIsIndeterminate] = useState(false); const [isIndeterminate, setIsIndeterminate] = useState(false);
@ -90,10 +90,15 @@ function AccounSelection ({ className, origin, showHidden = false, url, withWarn
); );
} }
export default styled(AccounSelection)<Props>` export default styled(AccountSelection)<Props>`
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.accountList { .accountList {
overflow-y: auto; overflow-y: auto;
height: 270px; min-height: 250px;
} }
.tab-name, .tab-name,

View File

@ -179,5 +179,7 @@
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata. It will also allow the use of Ledger's Generic Polkadot App.": "", "This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata. It will also allow the use of Ledger's Generic Polkadot App.": "",
"No metadata found for this chain. You must upload the metadata to the extension in order to use Ledger.": "", "No metadata found for this chain. You must upload the metadata to the extension in order to use Ledger.": "",
"This network is not available, please report an issue to update the known chains": "", "This network is not available, please report an issue to update the known chains": "",
"Ledger App": "" "Ledger App": "",
"Don't ask again": "",
"Previous": ""
} }