Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
>
38 changes: 38 additions & 0 deletions src/frontend/src/lib/components/signer/Signer.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script lang="ts">
import { getContext } from 'svelte';
import { fade, type FadeParams } from 'svelte/transition';
import SignerAccounts from '$lib/components/signer/SignerAccounts.svelte';
import SignerCallCanister from '$lib/components/signer/SignerCallCanister.svelte';
import SignerConsentMessage from '$lib/components/signer/SignerConsentMessage.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} />

<SignerConsentMessage />

<SignerCallCanister />
{/if}
</SignerAccounts>
44 changes: 44 additions & 0 deletions src/frontend/src/lib/components/signer/SignerAccounts.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script lang="ts">
import { isNullish, nonNullish } from '@dfinity/utils';
import { getContext, type Snippet } from 'svelte';
import { authStore } from '$lib/stores/auth.store';
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([
...(nonNullish($authStore.identity)
? [{ owner: $authStore.identity.getPrincipal().toText() }]
: []),
{ owner: missionControlId.toText() }
]);

resetPrompt();
};

$effect(() => {
$payload;

onAccountsPrompt();
});
</script>

{@render children()}
37 changes: 37 additions & 0 deletions src/frontend/src/lib/components/signer/SignerCallCanister.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script lang="ts">
import { getContext } from 'svelte';
import { fade } from 'svelte/transition';
import SpinnerParagraph from '$lib/components/ui/SpinnerParagraph.svelte';
import { SIGNER_CONTEXT_KEY, type SignerContext } from '$lib/stores/signer.store';
import { toasts } from '$lib/stores/toasts.store';

const {
callCanisterPrompt: { payload }
} = getContext<SignerContext>(SIGNER_CONTEXT_KEY);

const onPayload = () => {
if ($payload?.status !== 'error') {
return;
}

// TODO: i18n
toasts.error({
text: '$i18n.signer.call_canister.error.cannot_call',
detail: $payload.details
});
};

$effect(() => {
$payload;

onPayload();
});
</script>

{#if $payload?.status === 'executing'}
<SpinnerParagraph>Executing call...</SpinnerParagraph>
{:else if $payload?.status === 'result'}
<p in:fade>Success ✅</p>
{:else if $payload?.status === 'error'}
<p in:fade>Error 🥲</p>
{/if}
146 changes: 146 additions & 0 deletions src/frontend/src/lib/components/signer/SignerConsentMessage.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
<script lang="ts">
import type {
ConsentMessageApproval,
ConsentMessagePromptPayload,

Check warning on line 4 in src/frontend/src/lib/components/signer/SignerConsentMessage.svelte

View workflow job for this annotation

GitHub Actions / lint

'ConsentMessagePromptPayload' is defined but never used. Allowed unused vars must match /^_/u
Rejection,
ResultConsentInfo
} from '@dfinity/oisy-wallet-signer';
import { isNullish, nonNullish } from '@dfinity/utils';
import { getContext } from 'svelte';
import { fade } from 'svelte/transition';
import SignerConsentMessageWarning from '$lib/components/signer/SignerConsentMessageWarning.svelte';
import SignerOrigin from '$lib/components/signer/SignerOrigin.svelte';
import JsonCode from '$lib/components/ui/JsonCode.svelte';
import SpinnerParagraph from '$lib/components/ui/SpinnerParagraph.svelte';
import { SIGNER_CONTEXT_KEY, type SignerContext } from '$lib/stores/signer.store';
import { toasts } from '$lib/stores/toasts.store';

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

let approve = $derived<ConsentMessageApproval | undefined>(
nonNullish($payload) && $payload.status === 'result' ? $payload.approve : undefined
);
let reject = $derived<Rejection | undefined>(
nonNullish($payload) && $payload.status === 'result' ? $payload.reject : undefined
);
let consentInfo = $derived<ResultConsentInfo | undefined>(
nonNullish($payload) && $payload.status === 'result' ? $payload.consentInfo : undefined
);

let loading = $state(false);
let displayMessage = $state<string | undefined>(undefined);

const onPayload = () => {
if ($payload?.status === 'loading') {
displayMessage = undefined;
loading = true;

// In case the relying party has not closed the window between the last call and requesting a new call, we reset the call canister prompt; otherwise, we might display both the previous result screen and the consent message.
// Note that the library handles the case where the relying party tries to submit another call while a call is still being processed. Therefore, we cannot close a prompt here that is not yet finished.
resetCallCanisterPrompt();

return;
}

loading = false;

if ($payload?.status === 'error') {
// TODO: i18n
toasts.error({
text: '$i18n.signer.consent_message.error.retrieve'
});
return;
}

const consentInfoMsg = nonNullish(consentInfo)
? 'Warn' in consentInfo
? consentInfo.Warn.consentInfo
: consentInfo.Ok
: undefined;

displayMessage =
nonNullish(consentInfoMsg) && 'GenericDisplayMessage' in consentInfoMsg.consent_message
? consentInfoMsg.consent_message.GenericDisplayMessage
: undefined;
};

$effect(() => {
$payload;

onPayload();
});

type Text = { title: string; content: string } | undefined;

// We try to split the content and title because we received a chunk of unstructured text from the canister. This works well for the ICP ledger, but we will likely need to iterate on it. There are a few tasks documented in the backlog.
const mapText = (markdown: string | undefined): Text => {
if (isNullish(markdown)) {
return undefined;
}

const [title, ...rest] = markdown.split('\n');

return {
title: title.replace(/^#+\s*/, '').trim(),
content: (rest ?? []).join('\n')
};
};

let text = $derived<Text>(mapText(displayMessage));

const onApprove = ($event: SubmitEvent) => {
$event.preventDefault();

if (isNullish(approve)) {
// TODO: i18n
toasts.error({
text: '$i18n.signer.consent_message.error.no_approve_callback'
});

resetPrompt();
return;
}

approve?.();
resetPrompt();
};

const onReject = () => {
if (isNullish(reject)) {
// TODO: i18n
toasts.error({
text: '$i18n.signer.consent_message.error.no_reject_callback'
});

resetPrompt();
return;
}

reject();
resetPrompt();
};
</script>

{#if loading}
<SpinnerParagraph>Loading consent message...</SpinnerParagraph>
{:else if nonNullish(text)}
{@const { title, content } = text}

<form in:fade onsubmit={onApprove} method="POST">
<p>{title}</p>

<SignerOrigin payload={$payload} />

<SignerConsentMessageWarning {consentInfo} />

<JsonCode json={content}></JsonCode>

<div class="toolbar">
<button type="button" onclick={onReject}>Reject</button>
<button type="submit">Approve</button>
</div>
</form>
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script lang="ts">
import type { ResultConsentInfo } from '@dfinity/oisy-wallet-signer';
import { nonNullish } from '@dfinity/utils';
import Warning from '$lib/components/ui/Warning.svelte';
import { i18n } from '$lib/stores/i18n.store';

Check warning on line 5 in src/frontend/src/lib/components/signer/SignerConsentMessageWarning.svelte

View workflow job for this annotation

GitHub Actions / lint

'i18n' is defined but never used. Allowed unused vars must match /^_/u

interface Props {
consentInfo: ResultConsentInfo | undefined;
}

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

// The ICRC-49 standard specifies that a user should be warned when a consent message is interpreted on the frontend side instead of being retrieved through a canister.
// See https://github.com/dfinity/wg-identity-authentication/blob/main/topics/icrc_49_call_canister.md#message-processing
let displayWarning = $derived(nonNullish(consentInfo) && 'Warn' in consentInfo);
</script>

{#if displayWarning}
<Warning>$i18n.signer.consent_message.warning.token_without_consent_message</Warning>
{/if}
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}
Loading
Loading