Skip to content

Commit

Permalink
Display timestamp of last usage for each Passkey device in PAsskey de…
Browse files Browse the repository at this point in the history
…vice 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
  • Loading branch information
LXIF authored Nov 1, 2024
1 parent f1a8698 commit f69b5fe
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 15 deletions.
44 changes: 33 additions & 11 deletions src/frontend/src/flows/manage/authenticatorsSection.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
}: {
Expand All @@ -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`
<li class="c-action-list__item" data-device=${alias}>
${isNullish(warn) ? undefined : itemWarning({ warn })}
${isNullish(icon) ? undefined : html`${icon}`}
<div class="c-action-list__label">
${alias}
${nonNullish(dupCount) && dupCount > 0
? html`<i class="t-muted">&nbsp;(${dupCount})</i>`
: undefined}
<div class="c-action-list__label--stacked c-action-list__label">
<div class="c-action-list__label c-action-list__label--spacer">
${alias}
${nonNullish(dupCount) && dupCount > 0
? html`<i class="t-muted">&nbsp;(${dupCount})</i>`
: undefined}
<div class="c-action-list__label"></div>
${settingsDropdown({
alias,
id: `authenticator-${index}`,
settings,
})}
</div>
<div>
${nonNullish(lastUsageFormattedString)
? html`<div class="t-muted">
Last used: ${lastUsageFormattedString}
</div>`
: undefined}
</div>
</div>
${settingsDropdown({
alias,
id: `authenticator-${index}`,
settings,
})}
</li>
`;
};
Expand Down
11 changes: 7 additions & 4 deletions src/frontend/src/flows/manage/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
DeviceData,
DeviceWithUsage,
IdentityAnchorInfo,
} from "$generated/internet_identity_types";
import identityCardBackground from "$src/assets/identityCardBackground.png";
Expand Down Expand Up @@ -290,19 +291,20 @@ function isPinAuthenticated(
export const displayManage = (
userNumber: bigint,
connection: AuthenticatedConnection,
devices_: DeviceData[],
devices_: DeviceWithUsage[],
identityBackground: PreLoadImage
): Promise<void | AuthenticatedConnection> => {
// 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."
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/flows/manage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/frontend/src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
55 changes: 55 additions & 0 deletions src/frontend/src/utils/time.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
30 changes: 30 additions & 0 deletions src/frontend/src/utils/time.ts
Original file line number Diff line number Diff line change
@@ -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;
};

0 comments on commit f69b5fe

Please sign in to comment.