Skip to content

Commit 571b6ad

Browse files
authored
Rename passkey (#3189)
* Rename passkey * Rename new component and pass always a string as text * Rename also when "alias" not in metadata * Do not allow to set name to empty string * Copilot suggestions * Refactor to agreed UX * CR changes * Improve code * Bot findings * Prevent default submit event * CR changes * CR changes
1 parent f653071 commit 571b6ad

File tree

6 files changed

+183
-14
lines changed

6 files changed

+183
-14
lines changed

src/frontend/src/lib/components/ui/AccessMethod.svelte

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import PlaceHolder from "./PlaceHolder.svelte";
1010
import Ellipsis from "../utils/Ellipsis.svelte";
1111
import PulsatingCircleIcon from "../icons/PulsatingCircleIcon.svelte";
12+
import { getAuthnMethodAlias } from "$lib/utils/webAuthn";
1213
1314
let {
1415
accessMethod,
@@ -20,15 +21,6 @@
2021
isCurrent?: boolean;
2122
} = $props();
2223
23-
const getAuthnMethodAlias = (authnMethod: AuthnMethodData) => {
24-
const metadataAlias = authnMethod.metadata.find(
25-
([key, _val]) => key === "alias",
26-
)?.[1]!;
27-
if (metadataAlias && "String" in metadataAlias) {
28-
return metadataAlias.String;
29-
}
30-
};
31-
3224
const getOpenIdCredentialName = (credential: OpenIdCredential | null) => {
3325
if (!credential) return null;
3426
const metadataName = credential.metadata.find(

src/frontend/src/lib/components/utils/error.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { toaster } from "$lib/components/utils/toaster";
33
import { isCanisterError } from "$lib/utils/utils";
44
import type {
55
AuthnMethodConfirmationError,
6+
AuthnMethodMetadataReplaceError,
67
CheckCaptchaError,
78
CreateAccountError,
89
IdRegFinishError,
@@ -37,6 +38,7 @@ export const handleError = (error: unknown) => {
3738
| OpenIdCredentialAddError
3839
| OpenIdCredentialRemoveError
3940
| AuthnMethodConfirmationError
41+
| AuthnMethodMetadataReplaceError
4042
>(error)
4143
) {
4244
switch (error.type) {
@@ -118,6 +120,16 @@ export const handleError = (error: unknown) => {
118120
});
119121
console.error(error);
120122
break;
123+
case "AuthnMethodNotFound":
124+
toaster.error({
125+
title: "Authentication method not found",
126+
});
127+
break;
128+
case "InvalidMetadata":
129+
toaster.error({
130+
title: `Invalid metadata. ${error.value(error.type)}`,
131+
});
132+
break;
121133
case "NameTooLong":
122134
case "Unauthorized":
123135
case "NoAuthnMethodToConfirm":

src/frontend/src/lib/components/views/AccessMethodsPanel.svelte

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
import Button from "$lib/components/ui/Button.svelte";
33
import Panel from "$lib/components/ui/Panel.svelte";
4-
import { Link2OffIcon, PlusIcon, Trash2Icon } from "@lucide/svelte";
4+
import { EditIcon, Link2OffIcon, PlusIcon, Trash2Icon } from "@lucide/svelte";
55
import GoogleIcon from "$lib/components/icons/GoogleIcon.svelte";
66
import identityInfo from "$lib/stores/identity-info.state.svelte";
77
import AccessMethod from "$lib/components/ui/AccessMethod.svelte";
@@ -15,14 +15,15 @@
1515
OpenIdCredential,
1616
} from "$lib/generated/internet_identity_types";
1717
import RemovePasskeyDialog from "$lib/components/views/RemovePasskeyDialog.svelte";
18+
import RenamePasskeyDialog from "$lib/components/views/RenamePasskeyDialog.svelte";
1819
import { nonNullish } from "@dfinity/utils";
1920
import { handleError } from "$lib/components/utils/error";
2021
import {
2122
getLastUsedAccessMethod,
2223
isWebAuthnMetaData,
2324
} from "$lib/utils/accessMethods";
24-
import { authnMethodEqual } from "$lib/utils/webAuthn";
2525
import { AddAccessMethodWizard } from "$lib/components/wizards/addAccessMethod";
26+
import { authnMethodEqual, getAuthnMethodAlias } from "$lib/utils/webAuthn";
2627
2728
const MAX_PASSKEYS = 8;
2829
@@ -92,6 +93,14 @@
9293
handleError(error);
9394
}
9495
};
96+
97+
const handleRenamePasskey = async (newName: string) => {
98+
try {
99+
await identityInfo.renamePasskey(newName);
100+
} catch (error) {
101+
handleError(error);
102+
}
103+
};
95104
</script>
96105

97106
<Panel>
@@ -135,7 +144,14 @@
135144
isWebAuthnMetaData(lastUsedAccessMethod) &&
136145
authnMethodEqual(authnMethod, lastUsedAccessMethod)}
137146
/>
138-
<div class="flex items-center justify-center px-4">
147+
<div class="flex items-center justify-end gap-2 px-4">
148+
<Button
149+
onclick={() => (identityInfo.renamableAuthnMethod = authnMethod)}
150+
variant="tertiary"
151+
iconOnly
152+
>
153+
<EditIcon size="1.25rem" />
154+
</Button>
139155
{#if isRemoveAccessMethodVisible}
140156
<Button
141157
onclick={() => (identityInfo.removableAuthnMethod = authnMethod)}
@@ -166,7 +182,7 @@
166182
lastUsedAccessMethod.sub === credential.sub}
167183
/>
168184

169-
<div class="flex items-center justify-center px-4">
185+
<div class="flex items-center justify-end px-4">
170186
{#if isRemoveAccessMethodVisible}
171187
<Button
172188
onclick={() =>
@@ -200,6 +216,14 @@
200216
/>
201217
{/if}
202218

219+
{#if identityInfo.renamableAuthnMethod}
220+
<RenamePasskeyDialog
221+
currentName={getAuthnMethodAlias(identityInfo.renamableAuthnMethod)}
222+
onRename={handleRenamePasskey}
223+
onClose={() => (identityInfo.renamableAuthnMethod = null)}
224+
/>
225+
{/if}
226+
203227
{#if isAddAccessMethodWizardOpen}
204228
{#if $ADD_ACCESS_METHOD}
205229
<AddAccessMethodWizard
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<script lang="ts">
2+
import FeaturedIcon from "$lib/components/ui/FeaturedIcon.svelte";
3+
import Button from "$lib/components/ui/Button.svelte";
4+
import Input from "$lib/components/ui/Input.svelte";
5+
import { EditIcon } from "@lucide/svelte";
6+
import Dialog from "$lib/components/ui/Dialog.svelte";
7+
import type { AuthnMethodData } from "$lib/generated/internet_identity_types";
8+
9+
interface Props {
10+
currentName: string;
11+
onRename: (newName: string) => Promise<void>;
12+
onClose: () => void;
13+
}
14+
15+
const { currentName, onRename, onClose }: Props = $props();
16+
17+
let inputValue = $state(currentName);
18+
let isLoading = $state(false);
19+
let inputElement = $state<HTMLInputElement>();
20+
21+
const handleSave = async (e: SubmitEvent) => {
22+
e.preventDefault();
23+
// Button is disabled so this shouldn't happen.
24+
if (inputValue.trim() === "") {
25+
return;
26+
}
27+
28+
isLoading = true;
29+
30+
try {
31+
await onRename(inputValue.trim());
32+
onClose();
33+
} finally {
34+
isLoading = false;
35+
}
36+
};
37+
38+
// Focus the input when the dialog opens and auto-select the text
39+
$effect(() => {
40+
if (inputElement) {
41+
inputElement.select();
42+
}
43+
});
44+
</script>
45+
46+
<Dialog onClose={isLoading ? undefined : onClose}>
47+
<FeaturedIcon variant="info" size="lg" class="mb-3">
48+
<EditIcon size="1.5rem" />
49+
</FeaturedIcon>
50+
<h1 class="text-text-primary mb-3 text-2xl font-medium">Rename passkey</h1>
51+
<p class="text-text-tertiary mb-6 font-medium">
52+
Give your passkey a memorable name to help you identify it.
53+
</p>
54+
55+
<form onsubmit={handleSave} class="flex flex-col gap-6">
56+
<Input
57+
bind:element={inputElement}
58+
bind:value={inputValue}
59+
type="text"
60+
disabled={isLoading}
61+
class="w-full"
62+
autofocus
63+
/>
64+
<div class="flex flex-col gap-3">
65+
<Button
66+
type="submit"
67+
variant="primary"
68+
disabled={isLoading || inputValue.trim() === ""}
69+
>
70+
{isLoading ? "Saving..." : "Save"}
71+
</Button>
72+
<Button onclick={onClose} variant="tertiary" disabled={isLoading}>
73+
Cancel
74+
</Button>
75+
</div>
76+
</form>
77+
</Dialog>

src/frontend/src/lib/stores/identity-info.state.svelte.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { get } from "svelte/store";
22
import {
33
AuthnMethodData,
44
AuthnMethodRegistrationInfo,
5+
MetadataMapV2,
56
OpenIdCredential,
67
} from "$lib/generated/internet_identity_types";
78
import { authenticatedStore } from "./authentication.store";
@@ -19,7 +20,7 @@ import {
1920
lastUsedIdentityStore,
2021
} from "./last-used-identities.store";
2122
import { bufEquals } from "@dfinity/agent";
22-
import { authnMethodEqual } from "$lib/utils/webAuthn";
23+
import { authnMethodEqual, authnMethodToPublicKey } from "$lib/utils/webAuthn";
2324

2425
const fetchIdentityInfo = async () => {
2526
const authenticated = get(authenticatedStore);
@@ -43,6 +44,7 @@ class IdentityInfo {
4344
openIdCredentials = $state<OpenIdCredential[]>([]);
4445
removableOpenIdCredential = $state<OpenIdCredential | null>(null);
4546
removableAuthnMethod = $state<AuthnMethodData | null>(null);
47+
renamableAuthnMethod = $state<AuthnMethodData | null>(null);
4648

4749
totalAccessMethods = $derived<number>(
4850
this.authnMethods.length + this.openIdCredentials.length,
@@ -205,6 +207,44 @@ class IdentityInfo {
205207
}
206208
}
207209

210+
async renamePasskey(newName: string): Promise<void> {
211+
if (!this.renamableAuthnMethod)
212+
throw Error("Must first select a credential to be renamed!");
213+
214+
const authnMethod = this.renamableAuthnMethod;
215+
let renamed = false;
216+
const newMetadata: MetadataMapV2 = authnMethod.metadata.map(
217+
([key, value]) => {
218+
if (key === "alias") {
219+
renamed = true;
220+
return [key, { String: newName }];
221+
}
222+
return [key, value];
223+
},
224+
);
225+
if (!renamed) {
226+
newMetadata.push(["alias", { String: newName }]);
227+
}
228+
const { actor, identityNumber } = get(authenticatedStore);
229+
await actor
230+
.authn_method_metadata_replace(
231+
identityNumber,
232+
authnMethodToPublicKey(authnMethod),
233+
newMetadata,
234+
)
235+
.then(throwCanisterError);
236+
// Update authnMethods locally for faster feedback
237+
this.authnMethods = this.authnMethods.map((value) =>
238+
authnMethodEqual(value, authnMethod)
239+
? {
240+
...value,
241+
metadata: newMetadata,
242+
}
243+
: value,
244+
);
245+
await this.fetch();
246+
}
247+
208248
logout = () => {
209249
// TODO: When we keep a session open we'll need to clean that session.
210250
// For now we just reload the page to make sure all the states are cleared

src/frontend/src/lib/utils/webAuthn.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,27 @@ export const authnMethodEqual = (
9393
}
9494
return false;
9595
};
96+
97+
export const authnMethodToPublicKey = (
98+
authnMethod: AuthnMethodData,
99+
): Uint8Array => {
100+
if ("WebAuthn" in authnMethod.authn_method) {
101+
return new Uint8Array(authnMethod.authn_method.WebAuthn.pubkey);
102+
}
103+
if ("PubKey" in authnMethod.authn_method) {
104+
return new Uint8Array(authnMethod.authn_method.PubKey.pubkey);
105+
}
106+
throw new Error(
107+
`Unknown authentication method: ${JSON.stringify(authnMethod.authn_method)}`,
108+
);
109+
};
110+
111+
export const getAuthnMethodAlias = (authnMethod: AuthnMethodData): string => {
112+
const metadataAlias = authnMethod.metadata.find(
113+
([key, _val]) => key === "alias",
114+
)?.[1];
115+
if (metadataAlias && "String" in metadataAlias) {
116+
return metadataAlias.String;
117+
}
118+
return "";
119+
};

0 commit comments

Comments
 (0)