Skip to content

Commit 4061750

Browse files
committed
Frontend passkey management
1 parent 0ebc6a2 commit 4061750

File tree

12 files changed

+1623
-1159
lines changed

12 files changed

+1623
-1159
lines changed

frontend/locales/en.json

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,24 @@
5555
"title": "Edit profile",
5656
"username_label": "Username"
5757
},
58+
"passkeys": {
59+
"add": "Add passkey",
60+
"challenge_invalid_error": "The request expired, please try again.",
61+
"created_at_message": "Created {{date}}",
62+
"delete_button_confirmation_modal": {
63+
"action": "Delete passkey",
64+
"body": "Delete this passkey?"
65+
},
66+
"delete_button_title": "Remove passkey",
67+
"exists_error": "This passkey already exists.",
68+
"last_used_message": "Last used {{date}}",
69+
"name_field_help": "Give your passkey a name you can identify later",
70+
"name_field_label": "Name",
71+
"name_invalid_error": "The entered name is invalid",
72+
"never_used_message": "Never used",
73+
"response_invalid_error": "The response from your passkey was invalid: {{error}}",
74+
"title": "Passkeys"
75+
},
5876
"password": {
5977
"change": "Change password",
6078
"change_disabled": "Password changes are disabled by the administrator.",
@@ -64,10 +82,7 @@
6482
"button": "Sign out of account",
6583
"dialog": "Sign out of this account?"
6684
},
67-
"title": "Your account",
68-
"passkeys": {
69-
"title": "Passkeys"
70-
}
85+
"title": "Your account"
7186
},
7287
"add_email_form": {
7388
"email_denied_error": "The entered email is not allowed by the server policy",

frontend/package-lock.json

Lines changed: 738 additions & 1149 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
"react-i18next": "^15.4.1",
3737
"swagger-ui-dist": "^5.20.1",
3838
"valibot": "^1.0.0-rc.4",
39-
"vaul": "^1.1.2"
39+
"vaul": "^1.1.2",
40+
"webauthn-polyfills": "^0.1.5"
4041
},
4142
"devDependencies": {
4243
"@biomejs/biome": "^1.9.4",
@@ -87,6 +88,8 @@
8788
"vitest": "^3.0.5"
8889
},
8990
"msw": {
90-
"workerDirectory": [".storybook/public"]
91+
"workerDirectory": [
92+
".storybook/public"
93+
]
9194
}
9295
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/* Copyright 2025 New Vector Ltd.
2+
*
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
* Please see LICENSE in the repository root for full details.
5+
*/
6+
7+
.user-passkey-delete-icon {
8+
color: var(--cpd-color-icon-critical-primary);
9+
}
10+
11+
button[disabled] .user-passkey-delete-icon {
12+
color: var(--cpd-color-icon-disabled);
13+
}
14+
15+
.passkey-modal-box {
16+
display: flex;
17+
align-items: center;
18+
gap: var(--cpd-space-4x);
19+
border: 1px solid var(--cpd-color-gray-400);
20+
padding: var(--cpd-space-3x);
21+
font: var(--cpd-font-body-md-semibold);
22+
23+
& > svg {
24+
color: var(--cpd-color-icon-secondary);
25+
background-color: var(--cpd-color-bg-subtle-secondary);
26+
padding: var(--cpd-space-2x);
27+
border-radius: var(--cpd-space-2x);
28+
inline-size: var(--cpd-space-10x);
29+
block-size: var(--cpd-space-10x);
30+
}
31+
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
import { useMutation, useQueryClient } from "@tanstack/react-query";
7+
import type { ReactNode } from "@tanstack/react-router";
8+
import IconDelete from "@vector-im/compound-design-tokens/assets/web/icons/delete";
9+
import {
10+
Button,
11+
EditInPlace,
12+
ErrorMessage,
13+
Form,
14+
IconButton,
15+
Tooltip,
16+
} from "@vector-im/compound-web";
17+
import { parseISO } from "date-fns";
18+
import { type ComponentProps, useState } from "react";
19+
import { Translation, useTranslation } from "react-i18next";
20+
import { type FragmentType, graphql, useFragment } from "../../gql";
21+
import { graphqlRequest } from "../../graphql";
22+
import { formatReadableDate } from "../DateTime";
23+
import { Close, Description, Dialog, Title } from "../Dialog";
24+
import styles from "./UserPasskey.module.css";
25+
26+
const FRAGMENT = graphql(/* GraphQL */ `
27+
fragment UserPasskey_passkey on UserPasskey {
28+
id
29+
name
30+
lastUsedAt
31+
createdAt
32+
}
33+
`);
34+
35+
const REMOVE_PASSKEY_MUTATION = graphql(/* GraphQL */ `
36+
mutation RemovePasskey($id: ID!) {
37+
removePasskey(input: { id: $id }) {
38+
status
39+
}
40+
}
41+
`);
42+
43+
const RENAME_PASSKEY_MUTATION = graphql(/* GraphQL */ `
44+
mutation RenamePasskey($id: ID!, $name: String!) {
45+
renamePasskey(input: { id: $id, name: $name }) {
46+
status
47+
}
48+
}
49+
`);
50+
51+
const DeleteButton: React.FC<{ disabled?: boolean; onClick?: () => void }> = ({
52+
disabled,
53+
onClick,
54+
}) => (
55+
<Translation>
56+
{(t): ReactNode => (
57+
<Tooltip label={t("frontend.account.passkeys.delete_button_title")}>
58+
<IconButton
59+
type="button"
60+
disabled={disabled}
61+
className="m-2"
62+
onClick={onClick}
63+
size="var(--cpd-space-8x)"
64+
>
65+
<IconDelete className={styles.userPasskeyDeleteIcon} />
66+
</IconButton>
67+
</Tooltip>
68+
)}
69+
</Translation>
70+
);
71+
72+
const DeleteButtonWithConfirmation: React.FC<
73+
ComponentProps<typeof DeleteButton> & { name: string }
74+
> = ({ name, onClick, ...rest }) => {
75+
const { t } = useTranslation();
76+
const onConfirm = (): void => {
77+
onClick?.();
78+
};
79+
80+
// NOOP function, otherwise we dont render a cancel button
81+
const onDeny = (): void => {};
82+
83+
return (
84+
<Dialog trigger={<DeleteButton {...rest} />}>
85+
<Title>
86+
{t("frontend.account.passkeys.delete_button_confirmation_modal.body")}
87+
</Title>
88+
<Description className={styles.passkeyModalBox}>
89+
<div>{name}</div>
90+
</Description>
91+
<div className="flex flex-col gap-4">
92+
<Close asChild>
93+
<Button
94+
kind="primary"
95+
destructive
96+
onClick={onConfirm}
97+
Icon={IconDelete}
98+
>
99+
{t(
100+
"frontend.account.passkeys.delete_button_confirmation_modal.action",
101+
)}
102+
</Button>
103+
</Close>
104+
<Close asChild>
105+
<Button kind="tertiary" onClick={onDeny}>
106+
{t("action.cancel")}
107+
</Button>
108+
</Close>
109+
</div>
110+
</Dialog>
111+
);
112+
};
113+
114+
const UserPasskey: React.FC<{
115+
passkey: FragmentType<typeof FRAGMENT>;
116+
onRemove: () => void;
117+
}> = ({ passkey, onRemove }) => {
118+
const { t } = useTranslation();
119+
const data = useFragment(FRAGMENT, passkey);
120+
const [value, setValue] = useState(data.name);
121+
const queryClient = useQueryClient();
122+
123+
const removePasskey = useMutation({
124+
mutationFn: (id: string) =>
125+
graphqlRequest({ query: REMOVE_PASSKEY_MUTATION, variables: { id } }),
126+
onSuccess: (_data) => {
127+
onRemove?.();
128+
queryClient.invalidateQueries({ queryKey: ["userPasskeys"] });
129+
},
130+
});
131+
const renamePasskey = useMutation({
132+
mutationFn: ({ id, name }: { id: string; name: string }) =>
133+
graphqlRequest({
134+
query: RENAME_PASSKEY_MUTATION,
135+
variables: { id, name },
136+
}),
137+
onSuccess: (data) => {
138+
if (data.renamePasskey.status !== "RENAMED") {
139+
return;
140+
}
141+
queryClient.invalidateQueries({ queryKey: ["userPasskeys"] });
142+
},
143+
});
144+
145+
const formattedLastUsed = data.lastUsedAt
146+
? formatReadableDate(parseISO(data.lastUsedAt), new Date())
147+
: "";
148+
const formattedCreated = formatReadableDate(
149+
parseISO(data.createdAt),
150+
new Date(),
151+
);
152+
const status = renamePasskey.data?.renamePasskey.status ?? null;
153+
154+
const onRemoveClick = (): void => {
155+
removePasskey.mutate(data.id);
156+
};
157+
158+
const onInput = (e: React.ChangeEvent<HTMLInputElement>) => {
159+
setValue(e.target.value);
160+
};
161+
const onCancel = () => {
162+
console.log("wee");
163+
setValue(data.name);
164+
};
165+
const handleSubmit = async (
166+
e: React.FormEvent<HTMLFormElement>,
167+
): Promise<void> => {
168+
e.preventDefault();
169+
170+
const formData = new FormData(e.currentTarget);
171+
const name = formData.get("input") as string;
172+
173+
await renamePasskey.mutateAsync({ id: data.id, name });
174+
};
175+
176+
return (
177+
<div className="flex flex-col gap-2">
178+
<div className="flex items-center gap-2">
179+
<EditInPlace
180+
onSave={handleSubmit}
181+
type="text"
182+
value={value}
183+
onInput={onInput}
184+
onCancel={onCancel}
185+
serverInvalid={!!status && status !== "RENAMED"}
186+
className="flex-1"
187+
label=""
188+
saveButtonLabel={t("action.save")}
189+
savingLabel={t("common.saving")}
190+
savedLabel={t("common.saved")}
191+
cancelButtonLabel={t("action.cancel")}
192+
>
193+
<ErrorMessage match="typeMismatch" forceMatch={status === "INVALID"}>
194+
{t("frontend.account.passkeys.name_invalid_error")}
195+
</ErrorMessage>
196+
</EditInPlace>
197+
198+
<DeleteButtonWithConfirmation
199+
name={data.name}
200+
disabled={removePasskey.isPending}
201+
onClick={onRemoveClick}
202+
/>
203+
</div>
204+
205+
<Form.Root>
206+
<Form.Field name="">
207+
<Form.HelpMessage>
208+
{data.lastUsedAt
209+
? t("frontend.account.passkeys.last_used_message", {
210+
date: formattedLastUsed,
211+
})
212+
: t("frontend.account.passkeys.never_used_message")}
213+
</Form.HelpMessage>
214+
<Form.HelpMessage>
215+
{t("frontend.account.passkeys.created_at_message", {
216+
date: formattedCreated,
217+
})}
218+
</Form.HelpMessage>
219+
</Form.Field>
220+
</Form.Root>
221+
</div>
222+
);
223+
};
224+
225+
export default UserPasskey;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only
4+
// Please see LICENSE in the repository root for full details.
5+
6+
export { default } from "./UserPasskey";

0 commit comments

Comments
 (0)