diff --git a/src/frontend/src/flows/manage/authenticatorsSection.ts b/src/frontend/src/flows/manage/authenticatorsSection.ts
index 7e43fd8523..c88519cfbb 100644
--- a/src/frontend/src/flows/manage/authenticatorsSection.ts
+++ b/src/frontend/src/flows/manage/authenticatorsSection.ts
@@ -1,4 +1,5 @@
import { warningIcon } from "$src/components/icons";
+import { formatLastUsage } from "$src/utils/time";
import { isNullish, nonNullish } from "@dfinity/utils";
import { TemplateResult, html } from "lit-html";
import { settingsDropdown } from "./settingsDropdown";
@@ -109,7 +110,7 @@ export const authenticatorsSection = ({
};
export const authenticatorItem = ({
- authenticator: { alias, dupCount, warn, remove, rename },
+ authenticator: { alias, last_usage, dupCount, warn, remove, rename },
index,
icon,
}: {
@@ -125,21 +126,42 @@ export const authenticatorItem = ({
settings.push({ action: "remove", caption: "Remove", fn: () => remove() });
}
+ let lastUsageTimeStamp: Date | undefined;
+ let lastUsageFormattedString: string | undefined;
+
+ if (last_usage.length > 0 && typeof last_usage[0] === "bigint") {
+ lastUsageTimeStamp = new Date(Number(last_usage[0] / BigInt(1000000)));
+ }
+
+ if (lastUsageTimeStamp) {
+ lastUsageFormattedString = formatLastUsage(lastUsageTimeStamp);
+ }
+
return html`
${isNullish(warn) ? undefined : itemWarning({ warn })}
${isNullish(icon) ? undefined : html`${icon}`}
-
- ${alias}
- ${nonNullish(dupCount) && dupCount > 0
- ? html`
(${dupCount})`
- : undefined}
+
+
+ ${alias}
+ ${nonNullish(dupCount) && dupCount > 0
+ ? html`
(${dupCount})`
+ : undefined}
+
+ ${settingsDropdown({
+ alias,
+ id: `authenticator-${index}`,
+ settings,
+ })}
+
+
+ ${nonNullish(lastUsageFormattedString)
+ ? html`
+ Last used: ${lastUsageFormattedString}
+
`
+ : undefined}
+
- ${settingsDropdown({
- alias,
- id: `authenticator-${index}`,
- settings,
- })}
`;
};
diff --git a/src/frontend/src/flows/manage/index.ts b/src/frontend/src/flows/manage/index.ts
index 8d51f4d9ea..03e5ecaf4c 100644
--- a/src/frontend/src/flows/manage/index.ts
+++ b/src/frontend/src/flows/manage/index.ts
@@ -1,5 +1,6 @@
import {
DeviceData,
+ DeviceWithUsage,
IdentityAnchorInfo,
} from "$generated/internet_identity_types";
import identityCardBackground from "$src/assets/identityCardBackground.png";
@@ -290,19 +291,20 @@ function isPinAuthenticated(
export const displayManage = (
userNumber: bigint,
connection: AuthenticatedConnection,
- devices_: DeviceData[],
+ devices_: DeviceWithUsage[],
identityBackground: PreLoadImage
): Promise
=> {
// Fetch the dapps used in the teaser & explorer
// (dapps are suffled to encourage discovery of new dapps)
const dapps = shuffleArray(getDapps());
return new Promise((resolve) => {
- const devices = devicesFromDeviceDatas({
+ const devices = devicesFromDevicesWithUsage({
devices: devices_,
userNumber,
connection,
reload: resolve,
});
+
if (devices.dupPhrase) {
toast.error(
"More than one recovery phrases are registered, which is unexpected. Only one will be shown."
@@ -435,13 +437,13 @@ export const readRecovery = ({
// Convert devices read from the canister into types that are easier to work with
// and that better represent what we expect.
-export const devicesFromDeviceDatas = ({
+export const devicesFromDevicesWithUsage = ({
devices: devices_,
reload,
connection,
userNumber,
}: {
- devices: DeviceData[];
+ devices: DeviceWithUsage[];
reload: (connection?: AuthenticatedConnection) => void;
connection: AuthenticatedConnection;
userNumber: bigint;
@@ -470,6 +472,7 @@ export const devicesFromDeviceDatas = ({
const authenticator = {
alias: device.alias,
+ last_usage: device.last_usage,
warn: domainWarning(device),
rename: () => renameDevice({ connection, device, reload }),
remove: hasSingleDevice
diff --git a/src/frontend/src/flows/manage/types.ts b/src/frontend/src/flows/manage/types.ts
index de42117240..468415999f 100644
--- a/src/frontend/src/flows/manage/types.ts
+++ b/src/frontend/src/flows/manage/types.ts
@@ -3,6 +3,7 @@ import { TemplateResult } from "lit-html";
// A simple authenticator (non-recovery device)
export type Authenticator = {
alias: string;
+ last_usage: [] | [bigint];
rename: () => void;
remove?: () => void;
warn?: TemplateResult;
diff --git a/src/frontend/src/styles/main.css b/src/frontend/src/styles/main.css
index 1debc99392..ab8aa7edb8 100644
--- a/src/frontend/src/styles/main.css
+++ b/src/frontend/src/styles/main.css
@@ -2757,6 +2757,10 @@ a.c-action-list__item {
justify-content: center;
}
+.c-action-list__label--spacer {
+ width: 100%;
+}
+
.c-action-list__action {
cursor: pointer;
color: var(--rc-text);
diff --git a/src/frontend/src/utils/time.test.ts b/src/frontend/src/utils/time.test.ts
new file mode 100644
index 0000000000..9b02a84748
--- /dev/null
+++ b/src/frontend/src/utils/time.test.ts
@@ -0,0 +1,55 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { formatLastUsage } from "./time";
+
+describe("formatLastUsage", () => {
+ const NOW = new Date();
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ test("formats time within the last hour", () => {
+ const timestamp = new Date(NOW.getTime() - 30 * 60 * 1000); // 30 minutes ago
+ expect(formatLastUsage(timestamp)).toBe(
+ `today at ${timestamp.toLocaleTimeString("en-US", {
+ hour: "numeric",
+ minute: "numeric",
+ })}`
+ );
+ });
+
+ test("formats time from earlier today", () => {
+ const timestamp = new Date(NOW.getTime() - 7 * 60 * 60 * 1000); // 7 hours ago
+ expect(formatLastUsage(timestamp)).toBe(
+ `today at ${timestamp.toLocaleTimeString("en-US", {
+ hour: "numeric",
+ minute: "numeric",
+ })}`
+ );
+ });
+
+ test("formats time from yesterday", () => {
+ const timestamp = new Date(NOW.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago
+ expect(formatLastUsage(timestamp)).toBe("yesterday");
+ });
+
+ test("formats time from several days ago", () => {
+ const timestamp = new Date(NOW.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days ago
+ expect(formatLastUsage(timestamp)).toBe("5 days ago");
+ });
+
+ test("formats time from last month", () => {
+ const timestamp = new Date(NOW.getTime() - 30 * 24 * 60 * 60 * 1000); // ~1 month ago
+ expect(formatLastUsage(timestamp)).toBe("last month");
+ });
+
+ test("formats time from 5 months ago", () => {
+ const timestamp = new Date(NOW.getTime() - 5 * 30 * 24 * 60 * 60 * 1000); // ~1 month ago
+ expect(formatLastUsage(timestamp)).toBe("5 months ago");
+ });
+});
diff --git a/src/frontend/src/utils/time.ts b/src/frontend/src/utils/time.ts
new file mode 100644
index 0000000000..dbafd75af0
--- /dev/null
+++ b/src/frontend/src/utils/time.ts
@@ -0,0 +1,30 @@
+export const formatLastUsage = (timestamp: Date): string => {
+ const now = new Date();
+ const diffInMillis = timestamp.getTime() - now.getTime();
+ const diffInDays = Math.round(diffInMillis / (1000 * 60 * 60 * 24));
+
+ // If more than 25 days, use months
+ if (Math.abs(diffInDays) >= 25) {
+ const diffInMonths = Math.round(diffInDays / 30);
+ return new Intl.RelativeTimeFormat("en", {
+ numeric: "auto",
+ style: "long",
+ }).format(diffInMonths, "month");
+ }
+
+ const relativeTime = new Intl.RelativeTimeFormat("en", {
+ numeric: "auto",
+ style: "long",
+ }).format(diffInDays, "day");
+
+ // If within last 24 hours, append the time
+ if (Math.abs(diffInDays) < 1) {
+ const timeString = new Intl.DateTimeFormat("en", {
+ hour: "numeric",
+ minute: "numeric",
+ }).format(timestamp);
+ return `${relativeTime} at ${timeString}`;
+ }
+
+ return relativeTime;
+};