Skip to content

Commit f69b5fe

Browse files
authored
Display timestamp of last usage for each Passkey device in PAsskey device selector (#2680)
* start replacing DeviceData with DeviceWithUsage * finish replacing DeviceData with DeviceWithUsage, display last_usage formatted in devices list if available * remove unnecessary console log * BigInt in types -> bigint * adjust formatting * rearrange and use relative time format * format * create time format helper utility and tests * format
1 parent f1a8698 commit f69b5fe

File tree

6 files changed

+130
-15
lines changed

6 files changed

+130
-15
lines changed

src/frontend/src/flows/manage/authenticatorsSection.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { warningIcon } from "$src/components/icons";
2+
import { formatLastUsage } from "$src/utils/time";
23
import { isNullish, nonNullish } from "@dfinity/utils";
34
import { TemplateResult, html } from "lit-html";
45
import { settingsDropdown } from "./settingsDropdown";
@@ -109,7 +110,7 @@ export const authenticatorsSection = ({
109110
};
110111

111112
export const authenticatorItem = ({
112-
authenticator: { alias, dupCount, warn, remove, rename },
113+
authenticator: { alias, last_usage, dupCount, warn, remove, rename },
113114
index,
114115
icon,
115116
}: {
@@ -125,21 +126,42 @@ export const authenticatorItem = ({
125126
settings.push({ action: "remove", caption: "Remove", fn: () => remove() });
126127
}
127128

129+
let lastUsageTimeStamp: Date | undefined;
130+
let lastUsageFormattedString: string | undefined;
131+
132+
if (last_usage.length > 0 && typeof last_usage[0] === "bigint") {
133+
lastUsageTimeStamp = new Date(Number(last_usage[0] / BigInt(1000000)));
134+
}
135+
136+
if (lastUsageTimeStamp) {
137+
lastUsageFormattedString = formatLastUsage(lastUsageTimeStamp);
138+
}
139+
128140
return html`
129141
<li class="c-action-list__item" data-device=${alias}>
130142
${isNullish(warn) ? undefined : itemWarning({ warn })}
131143
${isNullish(icon) ? undefined : html`${icon}`}
132-
<div class="c-action-list__label">
133-
${alias}
134-
${nonNullish(dupCount) && dupCount > 0
135-
? html`<i class="t-muted">&nbsp;(${dupCount})</i>`
136-
: undefined}
144+
<div class="c-action-list__label--stacked c-action-list__label">
145+
<div class="c-action-list__label c-action-list__label--spacer">
146+
${alias}
147+
${nonNullish(dupCount) && dupCount > 0
148+
? html`<i class="t-muted">&nbsp;(${dupCount})</i>`
149+
: undefined}
150+
<div class="c-action-list__label"></div>
151+
${settingsDropdown({
152+
alias,
153+
id: `authenticator-${index}`,
154+
settings,
155+
})}
156+
</div>
157+
<div>
158+
${nonNullish(lastUsageFormattedString)
159+
? html`<div class="t-muted">
160+
Last used: ${lastUsageFormattedString}
161+
</div>`
162+
: undefined}
163+
</div>
137164
</div>
138-
${settingsDropdown({
139-
alias,
140-
id: `authenticator-${index}`,
141-
settings,
142-
})}
143165
</li>
144166
`;
145167
};

src/frontend/src/flows/manage/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
DeviceData,
3+
DeviceWithUsage,
34
IdentityAnchorInfo,
45
} from "$generated/internet_identity_types";
56
import identityCardBackground from "$src/assets/identityCardBackground.png";
@@ -290,19 +291,20 @@ function isPinAuthenticated(
290291
export const displayManage = (
291292
userNumber: bigint,
292293
connection: AuthenticatedConnection,
293-
devices_: DeviceData[],
294+
devices_: DeviceWithUsage[],
294295
identityBackground: PreLoadImage
295296
): Promise<void | AuthenticatedConnection> => {
296297
// Fetch the dapps used in the teaser & explorer
297298
// (dapps are suffled to encourage discovery of new dapps)
298299
const dapps = shuffleArray(getDapps());
299300
return new Promise((resolve) => {
300-
const devices = devicesFromDeviceDatas({
301+
const devices = devicesFromDevicesWithUsage({
301302
devices: devices_,
302303
userNumber,
303304
connection,
304305
reload: resolve,
305306
});
307+
306308
if (devices.dupPhrase) {
307309
toast.error(
308310
"More than one recovery phrases are registered, which is unexpected. Only one will be shown."
@@ -435,13 +437,13 @@ export const readRecovery = ({
435437

436438
// Convert devices read from the canister into types that are easier to work with
437439
// and that better represent what we expect.
438-
export const devicesFromDeviceDatas = ({
440+
export const devicesFromDevicesWithUsage = ({
439441
devices: devices_,
440442
reload,
441443
connection,
442444
userNumber,
443445
}: {
444-
devices: DeviceData[];
446+
devices: DeviceWithUsage[];
445447
reload: (connection?: AuthenticatedConnection) => void;
446448
connection: AuthenticatedConnection;
447449
userNumber: bigint;
@@ -470,6 +472,7 @@ export const devicesFromDeviceDatas = ({
470472

471473
const authenticator = {
472474
alias: device.alias,
475+
last_usage: device.last_usage,
473476
warn: domainWarning(device),
474477
rename: () => renameDevice({ connection, device, reload }),
475478
remove: hasSingleDevice

src/frontend/src/flows/manage/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { TemplateResult } from "lit-html";
33
// A simple authenticator (non-recovery device)
44
export type Authenticator = {
55
alias: string;
6+
last_usage: [] | [bigint];
67
rename: () => void;
78
remove?: () => void;
89
warn?: TemplateResult;

src/frontend/src/styles/main.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2757,6 +2757,10 @@ a.c-action-list__item {
27572757
justify-content: center;
27582758
}
27592759

2760+
.c-action-list__label--spacer {
2761+
width: 100%;
2762+
}
2763+
27602764
.c-action-list__action {
27612765
cursor: pointer;
27622766
color: var(--rc-text);

src/frontend/src/utils/time.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2+
import { formatLastUsage } from "./time";
3+
4+
describe("formatLastUsage", () => {
5+
const NOW = new Date();
6+
7+
beforeEach(() => {
8+
vi.useFakeTimers();
9+
vi.setSystemTime(NOW);
10+
});
11+
12+
afterEach(() => {
13+
vi.useRealTimers();
14+
});
15+
16+
test("formats time within the last hour", () => {
17+
const timestamp = new Date(NOW.getTime() - 30 * 60 * 1000); // 30 minutes ago
18+
expect(formatLastUsage(timestamp)).toBe(
19+
`today at ${timestamp.toLocaleTimeString("en-US", {
20+
hour: "numeric",
21+
minute: "numeric",
22+
})}`
23+
);
24+
});
25+
26+
test("formats time from earlier today", () => {
27+
const timestamp = new Date(NOW.getTime() - 7 * 60 * 60 * 1000); // 7 hours ago
28+
expect(formatLastUsage(timestamp)).toBe(
29+
`today at ${timestamp.toLocaleTimeString("en-US", {
30+
hour: "numeric",
31+
minute: "numeric",
32+
})}`
33+
);
34+
});
35+
36+
test("formats time from yesterday", () => {
37+
const timestamp = new Date(NOW.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago
38+
expect(formatLastUsage(timestamp)).toBe("yesterday");
39+
});
40+
41+
test("formats time from several days ago", () => {
42+
const timestamp = new Date(NOW.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days ago
43+
expect(formatLastUsage(timestamp)).toBe("5 days ago");
44+
});
45+
46+
test("formats time from last month", () => {
47+
const timestamp = new Date(NOW.getTime() - 30 * 24 * 60 * 60 * 1000); // ~1 month ago
48+
expect(formatLastUsage(timestamp)).toBe("last month");
49+
});
50+
51+
test("formats time from 5 months ago", () => {
52+
const timestamp = new Date(NOW.getTime() - 5 * 30 * 24 * 60 * 60 * 1000); // ~1 month ago
53+
expect(formatLastUsage(timestamp)).toBe("5 months ago");
54+
});
55+
});

src/frontend/src/utils/time.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export const formatLastUsage = (timestamp: Date): string => {
2+
const now = new Date();
3+
const diffInMillis = timestamp.getTime() - now.getTime();
4+
const diffInDays = Math.round(diffInMillis / (1000 * 60 * 60 * 24));
5+
6+
// If more than 25 days, use months
7+
if (Math.abs(diffInDays) >= 25) {
8+
const diffInMonths = Math.round(diffInDays / 30);
9+
return new Intl.RelativeTimeFormat("en", {
10+
numeric: "auto",
11+
style: "long",
12+
}).format(diffInMonths, "month");
13+
}
14+
15+
const relativeTime = new Intl.RelativeTimeFormat("en", {
16+
numeric: "auto",
17+
style: "long",
18+
}).format(diffInDays, "day");
19+
20+
// If within last 24 hours, append the time
21+
if (Math.abs(diffInDays) < 1) {
22+
const timeString = new Intl.DateTimeFormat("en", {
23+
hour: "numeric",
24+
minute: "numeric",
25+
}).format(timestamp);
26+
return `${relativeTime} at ${timeString}`;
27+
}
28+
29+
return relativeTime;
30+
};

0 commit comments

Comments
 (0)