Skip to content

Commit

Permalink
Connected sites settings (#658)
Browse files Browse the repository at this point in the history
  • Loading branch information
turbocrime authored Mar 8, 2024
1 parent c6581a2 commit ea0f825
Show file tree
Hide file tree
Showing 23 changed files with 615 additions and 120 deletions.
67 changes: 46 additions & 21 deletions apps/extension/src/approve-origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { errorFromJson } from '@connectrpc/connect/protocol-connect';
import { localExtStorage } from '@penumbra-zone/storage';
import { OriginApproval, PopupType } from './message/popup';
import { popup } from './popup';
import Map from '@penumbra-zone/polyfills/Map.groupBy';
import { UserChoice } from '@penumbra-zone/types/src/user-choice';

export const originAlreadyApproved = async (url: string): Promise<boolean> => {
// parses the origin and returns a consistent format
const urlOrigin = new URL(url).origin;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const connectedSites = await localExtStorage.get('connectedSites');
return Boolean(connectedSites[urlOrigin]);
const knownSites = await localExtStorage.get('knownSites');
const existingRecord = knownSites.find(site => site.origin === urlOrigin);
return existingRecord?.choice === UserChoice.Approved;
};

export const approveOrigin = async ({
Expand All @@ -23,22 +25,45 @@ export const approveOrigin = async ({

// parses the origin and returns a consistent format
const urlOrigin = new URL(senderOrigin).origin;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const connectedSites = await localExtStorage.get('connectedSites');
if (typeof connectedSites[urlOrigin] === 'boolean') return Boolean(connectedSites[urlOrigin]);

const res = await popup<OriginApproval>({
type: PopupType.OriginApproval,
request: {
origin: urlOrigin,
favIconUrl: tab.favIconUrl,
title: tab.title,
},
});
if ('error' in res)
throw errorFromJson(res.error as JsonValue, undefined, ConnectError.from(res));

connectedSites[urlOrigin] = res.data.attitude;
void localExtStorage.set('connectedSites', connectedSites); // TODO: is there a race condition here?
return Boolean(connectedSites[urlOrigin]);
const knownSites = await localExtStorage.get('knownSites');

const siteRecords = Map.groupBy(knownSites, site => site.origin === urlOrigin);
const irrelevant = siteRecords.get(false) ?? []; // we need to handle these in order to write back to storage
const [existingRecord, ...extraRecords] = siteRecords.get(true) ?? [];

if (extraRecords.length) throw new Error('Multiple records for the same origin');

switch (existingRecord?.choice) {
case UserChoice.Approved:
return true;
case UserChoice.Ignored:
return false;
case UserChoice.Denied:
default: {
const res = await popup<OriginApproval>({
type: PopupType.OriginApproval,
request: {
origin: urlOrigin,
favIconUrl: tab.favIconUrl,
title: tab.title,
lastRequest: existingRecord?.date,
},
});

if ('error' in res)
throw errorFromJson(res.error as JsonValue, undefined, ConnectError.from(res));

// TODO: is there a race condition here?
// if something has written after our initial read, we'll clobber them
void localExtStorage.set('knownSites', [
{
...res.data,
date: Date.now(),
},
...irrelevant,
]);

return Boolean(res.data.choice === UserChoice.Approved);
}
}
};
2 changes: 1 addition & 1 deletion apps/extension/src/approve-transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@ export const approveTransaction = async (
if (!authorizeRequest.equals(resAuthorizeRequest) || !transactionView.equals(resTransactionView))
throw new Error('Invalid response from popup');

return res.data.attitude;
return res.data.choice;
};
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ const postRequest = () => {
const requestResponseHandler = (msg: MessageEvent<unknown>) => {
if (msg.origin === window.origin && isPraxRequestResponseMessageEvent(msg)) {
// @ts-expect-error - ts can't understand the injected string
const attitude = msg.data[PRAX] as Prax.ApprovedConnection | Prax.DeniedConnection;
if (attitude === Prax.ApprovedConnection) requestPromise.resolve();
if (attitude === Prax.DeniedConnection) requestPromise.reject();
const choice = msg.data[PRAX] as Prax.ApprovedConnection | Prax.DeniedConnection;
if (choice === Prax.ApprovedConnection) requestPromise.resolve();
if (choice === Prax.DeniedConnection) requestPromise.reject();
window.removeEventListener('message', requestResponseHandler);
}
};
Expand Down
5 changes: 4 additions & 1 deletion apps/extension/src/entry/popup-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import { useStore } from '../state';
import { originApprovalSelector } from '../state/origin-approval';
import { txApprovalSelector } from '../state/tx-approval';

import { errorToJson } from '@connectrpc/connect/protocol-connect';
import { ConnectError } from '@connectrpc/connect';

import '@penumbra-zone/ui/styles/globals.css';

chrome.runtime.onMessage.addListener(
Expand All @@ -23,7 +26,7 @@ chrome.runtime.onMessage.addListener(
} catch (e) {
responder({
type: req.type,
error: String(e),
error: errorToJson(ConnectError.from(e), undefined),
});
}
return true; // instruct chrome runtime to wait for a response
Expand Down
7 changes: 4 additions & 3 deletions apps/extension/src/message/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
InternalResponse,
} from '@penumbra-zone/types/src/internal-msg/shared';
import type { Jsonified } from '@penumbra-zone/types/src/jsonified';
import { UserChoice } from '@penumbra-zone/types/src/user-choice';

export enum PopupType {
TxApproval = 'TxApproval',
Expand All @@ -18,8 +19,8 @@ export type PopupResponse<T extends PopupMessage = PopupMessage> = InternalRespo

export type OriginApproval = InternalMessage<
PopupType.OriginApproval,
{ origin: string; favIconUrl?: string; title?: string },
{ origin: string; attitude: boolean }
{ origin: string; favIconUrl?: string; title?: string; lastRequest?: number },
{ origin: string; choice: UserChoice }
>;

export type TxApproval = InternalMessage<
Expand All @@ -31,7 +32,7 @@ export type TxApproval = InternalMessage<
{
authorizeRequest: Jsonified<AuthorizeRequest>;
transactionView: Jsonified<TransactionView>;
attitude: boolean;
choice: UserChoice;
}
>;

Expand Down
31 changes: 28 additions & 3 deletions apps/extension/src/routes/popup/approval/approve-deny.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,48 @@ import { useCountdown } from 'usehooks-ts';
export const ApproveDeny = ({
approve,
deny,
ignore,
variants,
wait = 0,
}: {
approve: () => void;
deny: () => void;
ignore?: () => void;
variants?: Parameters<typeof Button>[0]['variant'][];
wait?: number;
}) => {
const [count, { startCountdown }] = useCountdown({ countStart: wait });
useEffect(startCountdown, [startCountdown]);

return (
<div className='fixed inset-x-0 bottom-0 flex flex-col gap-4 bg-black px-6 py-4 shadow-lg'>
<Button size='lg' variant='default' onClick={approve} disabled={!!count}>
<div className='fixed inset-x-0 bottom-0 flex flex-row flex-wrap justify-center gap-4 bg-black p-4 shadow-lg'>
<Button
variant={variants?.[0] ?? 'default'}
className='w-full'
size='lg'
onClick={approve}
disabled={!!count}
>
Approve {count !== 0 && `(${count})`}
</Button>
<Button size='lg' variant='destructive' onClick={deny}>
<Button
className='min-w-[50%] grow hover:bg-destructive/90'
size='lg'
variant={variants?.[1] ?? 'destructive'}
onClick={deny}
>
Deny
</Button>
{ignore && (
<Button
className='w-1/3 hover:bg-destructive/90'
size='lg'
variant={variants?.[2] ?? 'secondary'}
onClick={ignore}
>
Ignore Site
</Button>
)}
</div>
);
};
106 changes: 71 additions & 35 deletions apps/extension/src/routes/popup/approval/origin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,33 @@ import { originApprovalSelector } from '../../../state/origin-approval';
import { ApproveDeny } from './approve-deny';
import { LinkGradientIcon } from '../../../icons';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { UserChoice } from '@penumbra-zone/types/src/user-choice';
import { DisplayOriginURL } from '../../../shared/components/display-origin-url';
import { cn } from '@penumbra-zone/ui/lib/utils';

export const OriginApproval = () => {
const { requestOrigin, favIconUrl, title, setAttitude, sendResponse } =
const { requestOrigin, favIconUrl, title, lastRequest, setChoice, sendResponse } =
useStore(originApprovalSelector);

const approve = () => {
setAttitude(true);
setChoice(UserChoice.Approved);
sendResponse();
window.close();
};

const deny = () => {
setAttitude(false);
setChoice(UserChoice.Denied);
sendResponse();
window.close();
};

if (!requestOrigin) return null;
const ignore = () => {
setChoice(UserChoice.Ignored);
sendResponse();
window.close();
};

const originUrl = new URL(requestOrigin);
if (originUrl.origin !== requestOrigin) throw new Error('Invalid origin');
const { protocol, hostname, port } = originUrl;
if (!requestOrigin) return null;

return (
<FadeTransition>
Expand All @@ -36,41 +41,72 @@ export const OriginApproval = () => {
<div className='mx-auto size-20'>
<LinkGradientIcon />
</div>
<div className='flex flex-1 flex-col items-start justify-between px-[30px] pb-[30px]'>
<div className='flex w-full flex-col gap-4'>
<div className='flex flex-col gap-2'>
<div className='flex h-11 w-full items-center rounded-lg border bg-background px-3 py-2 text-muted-foreground'>
{favIconUrl ? <img src={favIconUrl} alt='icon' className='h-11' /> : null}
<div className='p-2 font-headline text-base font-semibold'>
{title ? title : <div className='text-muted-foreground'>{'<no title>'}</div>}
<div className='w-full px-[30px]'>
<div className='flex flex-col gap-2'>
<div
className={cn(
'rounded-[1em]',
'border-[1px]',
'border-transparent',
'p-2',
'[background:linear-gradient(var(--charcoal),var(--charcoal))_padding-box,_linear-gradient(to_bottom_left,rgb(139,228,217),rgb(255,144,47))_border-box]',
)}
>
<div className='flex flex-col items-center gap-2'>
<div className='flex h-11 max-w-full items-center rounded-lg bg-black p-2 text-muted-foreground [z-index:30]'>
{!!favIconUrl && (
<div
className={cn(
'-ml-3',
'relative',
'rounded-full',
'border-[1px]',
'border-transparent',
'[background:linear-gradient(var(--charcoal),var(--charcoal))_padding-box,_linear-gradient(to_top_right,rgb(139,228,217),rgb(255,144,47))_border-box]',
)}
>
<img
src={favIconUrl}
alt='requesting website icon'
className='size-20 min-w-20 rounded-full'
/>
</div>
)}
<div className='-ml-3 w-full truncate p-2 pl-6 font-headline text-lg'>
{title ? (
<span className='text-primary-foreground'>{title}</span>
) : (
<span className='text-muted-foreground underline decoration-dotted decoration-2 underline-offset-4'>
no title
</span>
)}
</div>
</div>
</div>
<div className='flex h-11 w-full items-center rounded-lg border bg-background px-3 py-2 text-muted-foreground'>
<div className='p-2 font-mono'>
<span className='tracking-tighter'>
{protocol}
{'//'}
</span>
<span className='text-white'>{hostname}</span>
{port ? (
<span className='tracking-tighter'>
{':'}
<span className='text-white'>{port}</span>
</span>
) : null}
<div className='z-30 flex h-11 w-full items-center overflow-x-scroll rounded-lg bg-background p-2 text-muted-foreground [scrollbar-color:red_red]'>
<div className='mx-auto items-center p-2 text-center leading-[0.8em] [background-clip:content-box] [background-image:repeating-linear-gradient(45deg,red,black_15px)] first-line:[background-color:black]'>
<DisplayOriginURL url={new URL(requestOrigin)} />
</div>
</div>
</div>
<div className='mt-3 flex flex-col gap-3'>
<p className='text-muted-foreground'>This host wants to connect to your wallet.</p>
<p className='flex items-center gap-2 text-rust'>
<ExclamationTriangleIcon />
Approval will allow this host to see your balance and transaction history.
</p>
</div>
<div className='mt-3 flex flex-col gap-3'>
<div className='text-center text-muted-foreground'>
This host wants to connect to your wallet.
</div>
<div className='flex items-center gap-2 text-rust'>
<ExclamationTriangleIcon />
Approval will allow this host to see your balance and transaction history.
</div>
</div>
</div>
</div>
<ApproveDeny approve={approve} deny={deny} wait={3} />
<ApproveDeny
variants={['gradient']}
approve={approve}
deny={deny}
ignore={lastRequest && ignore}
wait={3}
/>
</div>
</FadeTransition>
);
Expand Down
11 changes: 4 additions & 7 deletions apps/extension/src/routes/popup/approval/transaction/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,10 @@ import { AuthorizeRequest } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumb
import { useTransactionViewSwitcher } from './use-transaction-view-switcher';
import { ViewTabs } from './view-tabs';
import { ApproveDeny } from '../approve-deny';
import { UserChoice } from '@penumbra-zone/types/src/user-choice';

export const TransactionApproval = () => {
const {
authorizeRequest: authReqString,
setAttitude,
sendResponse,
} = useStore(txApprovalSelector);
const { authorizeRequest: authReqString, setChoice, sendResponse } = useStore(txApprovalSelector);

const { selectedTransactionView, selectedTransactionViewName, setSelectedTransactionViewName } =
useTransactionViewSwitcher();
Expand All @@ -23,13 +20,13 @@ export const TransactionApproval = () => {
if (!authorizeRequest.plan || !selectedTransactionView) return null;

const approve = () => {
setAttitude(true);
setChoice(UserChoice.Approved);
sendResponse();
window.close();
};

const deny = () => {
setAttitude(false);
setChoice(UserChoice.Denied);
sendResponse();
window.close();
};
Expand Down
5 changes: 5 additions & 0 deletions apps/extension/src/routes/popup/settings/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Settings } from './settings';
import { SettingsAdvanced } from './settings-advanced';
import { SettingsAutoLock } from './settings-auto-lock';
import { SettingsClearCache } from './settings-clear-cache';
import { SettingsConnectedSites } from './settings-connected-sites';
import { SettingsFullViewingKey } from './settings-full-viewing-key';
import { SettingsPassphrase } from './settings-passphrase';
import { SettingsRPC } from './settings-rpc';
Expand Down Expand Up @@ -34,6 +35,10 @@ export const settingsRoutes = [
path: PopupPath.SETTINGS_CLEAR_CACHE,
element: <SettingsClearCache />,
},
{
path: PopupPath.SETTINGS_CONNECTED_SITES,
element: <SettingsConnectedSites />,
},
{
path: PopupPath.SETTINGS_RECOVERY_PASSPHRASE,
element: <SettingsPassphrase />,
Expand Down
Loading

0 comments on commit ea0f825

Please sign in to comment.