Skip to content

feat: signer features for Juno Wallet #1378

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 21 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
"@dfinity/identity": "^2.3.0",
"@dfinity/ledger-icp": "^2.6.8",
"@dfinity/ledger-icrc": "^2.7.3",
"@dfinity/oisy-wallet-signer": "^0.1.6",
"@dfinity/oisy-wallet-signer": "^0.1.7",
"@dfinity/principal": "^2.3.0",
"@dfinity/utils": "^2.10.0",
"@dfinity/zod-schemas": "^0.0.2",
Expand Down
26 changes: 26 additions & 0 deletions src/frontend/src/lib/components/icons/IconShield.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!-- source: https://fonts.google.com/icons?selected=Material+Symbols+Outlined:shield:FILL@0;wght@400;GRAD@0;opsz@24&icon.size=24&icon.color=%23000000&icon.query=shield&icon.set=Material+Symbols&icon.style=Outlined -->
<script lang="ts">
interface Props {
size?: string;
}

let { size = '24px' }: Props = $props();
</script>

<svg
xmlns="http://www.w3.org/2000/svg"
height={size}
width={size}
viewBox="0 0 24 24"
fill="currentColor"
><g><rect fill="none" height="24" width="24" /></g><g
><g
><path
d="M6,6.39v4.7c0,4,2.55,7.7,6,8.83c3.45-1.13,6-4.82,6-8.83v-4.7l-6-2.25L6,6.39 z"
style="fill: var(--color-background);"
/><path
d="M12,2L4,5v6.09c0,5.05,3.41,9.76,8,10.91c4.59-1.15,8-5.86,8-10.91V5L12,2z M18,11.09c0,4-2.55,7.7-6,8.83 c-3.45-1.13-6-4.82-6-8.83v-4.7l6-2.25l6,2.25V11.09z"
/></g
></g
></svg
>
32 changes: 32 additions & 0 deletions src/frontend/src/lib/components/signer/Signer.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script lang="ts">
import { getContext } from 'svelte';
import { fade, type FadeParams } from 'svelte/transition';
import SignerAccounts from '$lib/components/signer/SignerAccounts.svelte';
import SignerIdle from '$lib/components/signer/SignerIdle.svelte';
import SignerPermissions from '$lib/components/signer/SignerPermissions.svelte';
import { SIGNER_CONTEXT_KEY, type SignerContext } from '$lib/stores/signer.store';
import type { MissionControlId } from '$lib/types/mission-control';

interface Props {
missionControlId: MissionControlId;
}

let { missionControlId }: Props = $props();

const { idle } = getContext<SignerContext>(SIGNER_CONTEXT_KEY);

// We use specific fade parameters for the idle state due to the asynchronous communication between the relying party and the wallet.
// Because the idle state might be displayed when a client starts communication with the wallet, we add a small delay to prevent a minor glitch where the idle animation is briefly shown before the actual action is rendered.
// Technically, from a specification standpoint, we don't have a way to fully prevent this.
const fadeParams: FadeParams = { delay: 150, duration: 250 };
</script>

<SignerAccounts {missionControlId}>
{#if $idle}
<div in:fade={fadeParams}>
<SignerIdle />
</div>
{:else}
<SignerPermissions {missionControlId} />
{/if}
</SignerAccounts>
38 changes: 38 additions & 0 deletions src/frontend/src/lib/components/signer/SignerAccounts.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script lang="ts">
import { isNullish } from '@dfinity/utils';
import { getContext, type Snippet } from 'svelte';
import { SIGNER_CONTEXT_KEY, type SignerContext } from '$lib/stores/signer.store';
import type { MissionControlId } from '$lib/types/mission-control';

interface Props {
missionControlId: MissionControlId;
children: Snippet;
}

let { missionControlId, children }: Props = $props();

const {
accountsPrompt: { payload, reset: resetPrompt }
} = getContext<SignerContext>(SIGNER_CONTEXT_KEY);

const onAccountsPrompt = () => {
if (isNullish($payload)) {
// Payload has been reset. Nothing to do.
return;
}

const { approve } = $payload;

approve([{ owner: missionControlId.toText() }]);

resetPrompt();
};

$effect(() => {
$payload;

onAccountsPrompt();
});
</script>

{@render children()}
6 changes: 6 additions & 0 deletions src/frontend/src/lib/components/signer/SignerIdle.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<script lang="ts">
import SpinnerParagraph from '$lib/components/ui/SpinnerParagraph.svelte';
import { i18n } from '$lib/stores/i18n.store';
</script>

<SpinnerParagraph>{$i18n.signer.idle_waiting}</SpinnerParagraph>
44 changes: 44 additions & 0 deletions src/frontend/src/lib/components/signer/SignerOrigin.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script lang="ts">
import type { Origin, PayloadOrigin } from '@dfinity/oisy-wallet-signer';
import { isNullish, nonNullish } from '@dfinity/utils';
import ExternalLink from '$lib/components/ui/ExternalLink.svelte';
import { i18n } from '$lib/stores/i18n.store';
import type { Option } from '$lib/types/utils';

interface Props {
payload: Option<PayloadOrigin>;
}

let { payload }: Props = $props();

let origin = $derived(payload?.origin);

const mapHost = (origin: Origin | undefined): Option<string> => {
if (isNullish(origin)) {
return undefined;
}

try {
// If set we are actually sure that the $payload.origin is a valid URL, thanks to the library but, for the state of the art, we still catch potential errors here too.
const { host } = new URL(origin);
return host;
} catch {
return null;
}
};

// Null being used if mapping the origin does not work - i.e. invalid origin. Probably an edge case.

let host = $derived(mapHost(origin));
</script>

{#if nonNullish(origin)}
<p>
{$i18n.signer.origin_request_from}
{#if nonNullish(host)}<span
><ExternalLink ariaLabel={$i18n.signer.origin_link_to_dapp} href={origin}
>{host}</ExternalLink
></span
>{:else}<span>{$i18n.signer.origin_invalid_origin}</span>{/if}
</p>
{/if}
132 changes: 132 additions & 0 deletions src/frontend/src/lib/components/signer/SignerPermissions.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<script lang="ts">
import { ICRC25_PERMISSION_GRANTED, type IcrcScopedMethod } from '@dfinity/oisy-wallet-signer';
import { isNullish, nonNullish } from '@dfinity/utils';
import { type Component, getContext } from 'svelte';
import { fade } from 'svelte/transition';
import IconShield from '$lib/components/icons/IconShield.svelte';
import IconWallet from '$lib/components/icons/IconWallet.svelte';
import SignerOrigin from '$lib/components/signer/SignerOrigin.svelte';
import Html from '$lib/components/ui/Html.svelte';
import { i18n } from '$lib/stores/i18n.store';
import { SIGNER_CONTEXT_KEY, type SignerContext } from '$lib/stores/signer.store';
import { toasts } from '$lib/stores/toasts.store';
import type { MissionControlId } from '$lib/types/mission-control';
import { shortenWithMiddleEllipsis } from '$lib/utils/format.utils';

interface Props {
missionControlId: MissionControlId;
}

let { missionControlId }: Props = $props();

const {
permissionsPrompt: { payload, reset: resetPrompt }
} = getContext<SignerContext>(SIGNER_CONTEXT_KEY);

let scopes = $derived($payload?.requestedScopes ?? []);

let confirm = $derived($payload?.confirm);

/**
* During the initial UX review of OISY, it was decided that permissions should not be permanently denied when "Rejected," but instead should be ignored.
* This means that if the user selects "Reject," the permission will be requested again the next time a similar action is performed.
* This approach is particularly useful since, for the time being, there is no way for the user to manage their permissions in the Oisy UI.
*/
const ignorePermissions = () => {
if (isNullish(confirm)) {
toasts.error({
text: $i18n.signer.permissions_no_confirm_callback
});
return;
}

confirm([]);

resetPrompt();
};

const approvePermissions = () => {
if (isNullish(confirm)) {
toasts.error({
text: $i18n.signer.permissions_no_confirm_callback
});
return;
}

confirm(scopes.map((scope) => ({ ...scope, state: ICRC25_PERMISSION_GRANTED })));

resetPrompt();
};

const onReject = () => ignorePermissions();

const onApprove = () => approvePermissions();

let listItems: Record<IcrcScopedMethod, { icon: Component; label: string }> = $derived({
icrc27_accounts: {
icon: IconWallet,
label: `${$i18n.signer.permissions_icrc27_accounts} <strong>${shortenWithMiddleEllipsis(missionControlId.toText())}</strong>`
},
icrc49_call_canister: {
icon: IconShield,
label: $i18n.signer.permissions_icrc49_call_canister
}
});
</script>

{#if nonNullish($payload)}
<form in:fade onsubmit={onApprove} method="POST">
<SignerOrigin payload={$payload} />

<div class="warning">
<p class="request">{$i18n.signer.permissions_requested_permissions}</p>

<ul>
{#each scopes as { scope: { method } } (method)}
{@const { icon: Icon, label } = listItems[method]}

<li>
<Icon size="24" />
<Html text={label} />
</li>
{/each}
</ul>
</div>

<div class="toolbar">
<button type="button" onclick={onReject}>Reject</button>
<button type="submit">Approve</button>
</div>
</form>
{/if}

<style lang="scss">
@use '../../styles/mixins/info';

.warning {
@include info.warning;

flex-direction: column;
align-items: flex-start;
gap: 0;
}

.request {
font-weight: var(--font-weight-bold);
}

ul {
margin: 0;
padding: 0;
list-style: none;
}

li {
display: flex;
align-items: center;
gap: var(--padding);
margin: 0 0 var(--padding-2x);

--color-background: white;
}
</style>
2 changes: 1 addition & 1 deletion src/frontend/src/lib/directives/intersection.directives.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const INTERSECTION_THRESHOLD = 0.8;
const INTERSECTION_ROOT_MARGIN = '-100px 0px';
const INTERSECTION_ROOT_MARGIN = '-150px 0px';

export interface IntersectingDetail {
intersecting: boolean;
Expand Down
13 changes: 13 additions & 0 deletions src/frontend/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -794,5 +794,18 @@
"resources_description": "View a collection of sample code, applications, and microservices build with Juno",
"changelog": "Releases",
"changelog_description": "See the last updates and improvements."
},
"signer": {
"title": "Juno Wallet",
"access_your_wallet": "Access your wallet to securely connect a dApp and start using your assets.",
"permissions_no_confirm_callback": "No callback to confirm the permissions, which is unexpected. Close the wallet and try again.",
"permissions_icrc27_accounts": "View your wallet address",
"permissions_icrc49_call_canister": "Request approval for transactions",
"permissions_requested_permissions": "Requested permissions",
"permissions_your_wallet_address": "Your wallet address",
"origin_request_from": "Request from:",
"origin_invalid_origin": "Invalid origin️!!",
"origin_link_to_dapp": "Link to the dApp requesting permissions",
"idle_waiting": "Waiting for the dApp interaction..."
}
}
Loading
Loading