Skip to content

Commit

Permalink
Implement (un)link account on manage page.
Browse files Browse the repository at this point in the history
  • Loading branch information
sea-snake committed Dec 31, 2024
1 parent 4131faa commit d538cb1
Show file tree
Hide file tree
Showing 11 changed files with 495 additions and 21 deletions.
26 changes: 26 additions & 0 deletions src/frontend/src/components/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,3 +445,29 @@ export const cypherIcon = html`
/>
</svg>
`;

export const googleIcon = html`
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
>
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
`;
4 changes: 3 additions & 1 deletion src/frontend/src/featureFlags/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Feature flags with default values
const FEATURE_FLAGS_WITH_DEFAULTS = {
DOMAIN_COMPATIBILITY: false,
OPENID_AUTHENTICATION: false,
} as const satisfies Record<string, boolean>;

const LOCALSTORAGE_FEATURE_FLAGS_PREFIX = "ii-localstorage-feature-flags__";
Expand Down Expand Up @@ -63,4 +64,5 @@ const initializedFeatureFlags = Object.fromEntries(
window.__featureFlags = initializedFeatureFlags;

// Export initialized feature flags as named exports
export const { DOMAIN_COMPATIBILITY } = initializedFeatureFlags;
export const { DOMAIN_COMPATIBILITY, OPENID_AUTHENTICATION } =
initializedFeatureFlags;
75 changes: 70 additions & 5 deletions src/frontend/src/flows/manage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import { logoutSection } from "$src/components/logout";
import { mainWindow } from "$src/components/mainWindow";
import { toast } from "$src/components/toast";
import { ENABLE_PIN_QUERY_PARAM_KEY, LEGACY_II_URL } from "$src/config";
import { OPENID_AUTHENTICATION } from "$src/featureFlags";
import { addDevice } from "$src/flows/addDevice/manage/addDevice";
import { dappsExplorer } from "$src/flows/dappsExplorer";
import { KnownDapp, getDapps } from "$src/flows/dappsExplorer/dapps";
import { dappsHeader, dappsTeaser } from "$src/flows/dappsExplorer/teaser";
import { linkedAccountsSection } from "$src/flows/manage/linkedAccountsSection";
import {
TempKeyWarningAction,
tempKeyWarningBox,
Expand All @@ -29,13 +31,21 @@ import { setupKey, setupPhrase } from "$src/flows/recovery/setupRecovery";
import { I18n } from "$src/i18n";
import { AuthenticatedConnection, Connection } from "$src/utils/iiConnection";
import { TemplateElement, renderPage } from "$src/utils/lit-html";
import { OpenIDCredential } from "$src/utils/mockOpenID";
import {
GOOGLE_REQUEST_CONFIG,
createRequestJWT,
decodeJWT,
getMetadataString,
} from "$src/utils/openID";
import { PreLoadImage } from "$src/utils/preLoadImage";
import {
isProtected,
isRecoveryDevice,
isRecoveryPhrase,
} from "$src/utils/recoveryDevice";
import { OmitParams, shuffleArray, unreachable } from "$src/utils/utils";
import { ECDSAKeyIdentity } from "@dfinity/identity";
import { Principal } from "@dfinity/principal";
import { isNullish, nonNullish } from "@dfinity/utils";
import { TemplateResult, html } from "lit-html";
Expand Down Expand Up @@ -147,6 +157,9 @@ const displayManageTemplate = ({
onAddDevice,
addRecoveryPhrase,
addRecoveryKey,
credentials,
onLinkAccount,
onUnlinkAccount,
dapps,
exploreDapps,
identityBackground,
Expand All @@ -157,6 +170,9 @@ const displayManageTemplate = ({
onAddDevice: () => void;
addRecoveryPhrase: () => void;
addRecoveryKey: () => void;
credentials: OpenIDCredential[];
onLinkAccount: () => void;
onUnlinkAccount: (credential: OpenIDCredential) => void;
dapps: KnownDapp[];
exploreDapps: () => void;
identityBackground: PreLoadImage;
Expand All @@ -182,6 +198,14 @@ const displayManageTemplate = ({
onAddDevice,
warnNoPasskeys,
})}
${OPENID_AUTHENTICATION.isEnabled()
? linkedAccountsSection({
credentials,
onLinkAccount,
onUnlinkAccount,
hasOtherAuthMethods: authenticators.length > 0,
})
: ""}
${recoveryMethodsSection({ recoveries, addRecoveryPhrase, addRecoveryKey })}
<aside class="l-stack">
${dappsTeaser({
Expand Down Expand Up @@ -245,7 +269,7 @@ export const renderManage = async ({
// There's nowhere to go from here (i.e. all flows lead to/start from this page), so we
// loop forever
for (;;) {
let anchorInfo: IdentityAnchorInfo;
let anchorInfo: IdentityAnchorInfo & { credentials: OpenIDCredential[] };
try {
// Ignore the `commitMetadata` response, it's not critical for the application.
void connection.commitMetadata();
Expand All @@ -267,6 +291,7 @@ export const renderManage = async ({
userNumber,
connection,
anchorInfo.devices,
anchorInfo.credentials,
identityBackground
);
connection = newConnection ?? connection;
Expand All @@ -291,21 +316,31 @@ function isPinAuthenticated(
);
}

export const displayManage = (
export const displayManage = async (
userNumber: bigint,
connection: AuthenticatedConnection,
devices_: DeviceWithUsage[],
credentials: OpenIDCredential[],
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());

// Create method to initiate JWT request
const identity = await ECDSAKeyIdentity.generate();
const requestJWT = await createRequestJWT(GOOGLE_REQUEST_CONFIG, {
principal: identity.getPrincipal(),
mediation: "required",
});

return new Promise((resolve) => {
const devices = devicesFromDevicesWithUsage({
devices: devices_,
userNumber,
connection,
reload: resolve,
hasOtherAuthMethods: credentials.length > 0,
});

if (devices.dupPhrase) {
Expand Down Expand Up @@ -334,6 +369,30 @@ export const displayManage = (
resolve();
};

const onLinkAccount = async () => {
const { jwt, salt } = await requestJWT();
const { iss, sub } = decodeJWT(jwt);
if (
credentials.find(
(credential) => credential.iss === iss && credential.sub === sub
)
) {
toast.error("This account has already been linked");
return;
}
await connection.addJWT(jwt, new Uint8Array(salt));
resolve();
};
const onUnlinkAccount = async (credential: OpenIDCredential) => {
const name =
getMetadataString(credential.metadata, "name") ?? credential.sub;
if (!confirm(`Do you really want to unlink the account "${name}"?`)) {
return;
}
await connection.removeJWT(credential.iss, credential.sub);
resolve();
};

// Function to figure out what temp keys warning should be shown, if any.
const determineTempKeysWarning = (): TempKeyWarningAction | undefined => {
if (!isPinAuthenticated(devices_, connection)) {
Expand Down Expand Up @@ -369,6 +428,9 @@ export const displayManage = (
await setupKey({ connection });
resolve();
},
credentials,
onLinkAccount,
onUnlinkAccount,
dapps,
exploreDapps: async () => {
await dappsExplorer({ dapps });
Expand Down Expand Up @@ -445,11 +507,13 @@ export const devicesFromDevicesWithUsage = ({
reload,
connection,
userNumber,
hasOtherAuthMethods,
}: {
devices: DeviceWithUsage[];
reload: (connection?: AuthenticatedConnection) => void;
connection: AuthenticatedConnection;
userNumber: bigint;
hasOtherAuthMethods: boolean;
}): Devices & { dupPhrase: boolean; dupKey: boolean } => {
const hasSingleDevice = devices_.length <= 1;

Expand Down Expand Up @@ -478,9 +542,10 @@ export const devicesFromDevicesWithUsage = ({
last_usage: device.last_usage,
warn: domainWarning(device),
rename: () => renameDevice({ connection, device, reload }),
remove: hasSingleDevice
? undefined
: () => deleteDevice({ connection, device, reload }),
remove:
hasSingleDevice && !hasOtherAuthMethods
? undefined
: () => deleteDevice({ connection, device, reload }),
};

if ("browser_storage_key" in device.key_type) {
Expand Down
132 changes: 132 additions & 0 deletions src/frontend/src/flows/manage/linkedAccountsSection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { googleIcon } from "$src/components/icons";
import { OpenIDCredential } from "$src/utils/mockOpenID";
import { getMetadataString } from "$src/utils/openID";
import { formatLastUsage } from "$src/utils/time";
import { nonNullish } from "@dfinity/utils";
import { TemplateResult, html } from "lit-html";
import { settingsDropdown } from "./settingsDropdown";

// The maximum number of linked accounts we allow.
// The canister limits the _total_ number of linked accounts to 100,
// and we (the frontend) won't show a counter since this number is intentionally
// high to avoid showing a usage counter while still having an actual limit.
const MAX_CREDENTIALS = 100;

export const linkedAccountsSection = ({
credentials,
onLinkAccount,
onUnlinkAccount,
hasOtherAuthMethods,
}: {
credentials: OpenIDCredential[];
onLinkAccount: () => void;
onUnlinkAccount: (credential: OpenIDCredential) => void;
hasOtherAuthMethods: boolean;
}): TemplateResult => {
const unlinkAvailable = credentials.length > 1 || hasOtherAuthMethods;

return html` <aside
class="l-stack c-card c-card--narrow"
data-role="linked-accounts"
>
<h2 class="t-title">Linked Accounts</h2>
<p class="t-paragraph t-lead">
${credentials.length === 0
? "Link your online account to hold assets and securely sign into dapps."
: "Use your online accounts to hold assets and securely sign into dapps."}
</p>
<div class="c-action-list">
<ul>
${credentials.map((credential, index) =>
accountItem({
credential,
index,
unlink: onUnlinkAccount,
unlinkAvailable,
})
)}
</ul>
<div class="c-action-list__actions">
<button
?disabled=${credentials.length >= MAX_CREDENTIALS}
class="c-button c-button--primary c-tooltip c-tooltip--onDisabled c-tooltip--left"
@click="${() => onLinkAccount()}"
id="linkAdditionalAccount"
>
<span class="c-tooltip__message c-card c-card--tight"
>You can link up to ${MAX_CREDENTIALS} online accounts. You must
unlink an account before you can link another.</span
>
<span
>${credentials.length === 0
? "Link account"
: "Link additional account"}</span
>
</button>
</div>
</div>
</aside>`;
};

export const accountItem = ({
credential,
index,
unlink,
unlinkAvailable,
}: {
credential: OpenIDCredential;
index: number;
unlink: (credential: OpenIDCredential) => void;
unlinkAvailable: boolean;
}) => {
const settings = [
{
action: "unlink",
caption: "Unlink",
fn: () => unlink(credential),
},
];

const lastUsageTimeStamp = new Date(
Number(credential.last_usage_timestamp / BigInt(1000000))
);
const lastUsageFormattedString = formatLastUsage(lastUsageTimeStamp);
const name = getMetadataString(credential.metadata, "name");
const email = getMetadataString(credential.metadata, "email");
const picture = getMetadataString(credential.metadata, "picture");

return html`
<li class="c-action-list__item" data-account=${credential.sub}>
${
nonNullish(picture)
? html`<div class="c-action-list__avatar">
<img src="${picture}" alt="" aria-hidden="true" loading="lazy" />
<div class="c-action-list__avatar--badge">${googleIcon}</div>
</div>`
: ""
}
<div class="c-action-list__label--stacked c-action-list__label">
<div class="c-action-list__label c-action-list__label--spacer">
<div class="c-action-list__label">
<span class="c-tooltip" tabindex="0">
<span class="c-tooltip__message c-card c-card--tight t-nowrap">${email}</span
<span>${name}</span>
</span>
</div>
${
unlinkAvailable
? settingsDropdown({
alias: credential.sub,
id: `account-${index}`,
settings,
})
: ""
}
</div>
<div>
<div class="t-muted">Last used: ${lastUsageFormattedString}</div>
</div>
</div>
</li>
`;
};
Loading

0 comments on commit d538cb1

Please sign in to comment.