Compare commits

...

31 Commits

Author SHA1 Message Date
Neeraj Gupta
16817eceac [photos] Update bundle name from ente Photos -> Ente Photos (#2141)
## Description

## Tests
2024-06-14 16:02:15 +05:30
Neeraj Gupta
500e40035f [photos] Update bundle name from ente Photos -> Ente Photos 2024-06-14 15:59:44 +05:30
Neeraj Gupta
366da2c328 [photos] Bump version v0.9.0 (#2140)
## Description

## Tests
2024-06-14 15:59:21 +05:30
Neeraj Gupta
203d46b2cf [photos] Bump version v0.9.0 2024-06-14 15:56:15 +05:30
Manav Rathi
0e772fcfb7 [desktop] Fix duplicate file uploads when initializing a folder watch (#2138)
This didn't happen always, it was a race condition dependending on when
the `this.eventQueue = []` in `syncWithDisk` happened.
2024-06-14 15:25:58 +05:30
Manav Rathi
bbd6745372 Add CHANGELOG entries 2024-06-14 15:18:57 +05:30
Manav Rathi
dd1e0a9b1d Fix duplicate file uploads when initializing a folder watch
This didn't happen always, it was a race condition dependending on when the
`this.eventQueue = []` in `syncWithDisk` happened.
2024-06-14 15:11:36 +05:30
Neeraj Gupta
940231e38d [mob][auth] Fix handling of passkey when email verification is turned on (#2137)
## Description

## Tests
2024-06-14 14:41:39 +05:30
Neeraj Gupta
4c8db02de5 [auth] Bump version to v3.0.12 2024-06-14 14:39:51 +05:30
Neeraj Gupta
8af5aadd1b [mob] Bump photos version to v0.8.139 2024-06-14 14:39:25 +05:30
Neeraj Gupta
205feab4c2 [mob][auth] Fix passkey authn flow when emailVerification is enabled 2024-06-14 14:38:44 +05:30
Manav Rathi
60ab2b4427 [web] New translations (#2128)
New translations from
[Crowdin](https://crowdin.com/project/ente-photos-web)
2024-06-14 14:37:35 +05:30
Crowdin Bot
612329f584 New Crowdin translations by GitHub Action 2024-06-14 09:06:18 +00:00
Manav Rathi
a5f4a676a7 [web] Passkeys misc loose ends (#2136) 2024-06-14 14:35:32 +05:30
Manav Rathi
9608cfaa4e Don't show retry button if trying to use an already claimed session 2024-06-14 14:30:10 +05:30
Manav Rathi
ddd4d3e16c "Scripts may only close windows that were opened by them" 2024-06-14 14:04:42 +05:30
Manav Rathi
df0d48af73 [desktop] Add a check status button to the passkey waiting page (#2132) 2024-06-14 13:55:54 +05:30
Manav Rathi
c82193cae6 Enable passkeys for everyone 2024-06-14 13:51:21 +05:30
Manav Rathi
2c0928bd02 Change to photos favicon
he accounts favicon does not show on a white background (second image is the
hover state showing that the icon is actually there). For now, changing it to
the photos favicon, until we have an app neutral favicon.
2024-06-14 13:49:45 +05:30
Manav Rathi
8c8ffa9397 Add a hint to retry on other devices 2024-06-14 13:42:30 +05:30
Manav Rathi
3689ecb6e7 Add a message 2024-06-14 13:26:05 +05:30
Manav Rathi
ca080ad6b2 Split the flow 2024-06-14 13:07:00 +05:30
Manav Rathi
b2e56fc01e Lint fix 2024-06-14 12:23:09 +05:30
Manav Rathi
228dd90bce Make the retry code (almost) exactly the same as it was before
in an attempt at superstition (since rationality doesn't seem to work with
Safari).
2024-06-14 12:11:43 +05:30
Manav Rathi
93380d05b4 Add TODO 2024-06-14 12:04:34 +05:30
Manav Rathi
4123197c6d Use 2024-06-14 11:46:55 +05:30
Manav Rathi
cc3f398a78 Happy path 2024-06-14 11:41:50 +05:30
Manav Rathi
dd0f7d3142 Handle errors 2024-06-14 11:17:51 +05:30
Manav Rathi
325c963b7a Mix 2024-06-14 11:03:13 +05:30
Manav Rathi
fbf29585eb UI 2024-06-14 10:51:58 +05:30
Manav Rathi
8a2cc858ae API method 2024-06-14 10:10:09 +05:30
41 changed files with 633 additions and 169 deletions

View File

@@ -297,31 +297,41 @@ class UserService {
await dialog.show();
try {
final userPassword = _config.getVolatilePassword();
if (userPassword == null) throw Exception("volatile password is null");
await _saveConfiguration(response);
Widget page;
if (_config.getEncryptedToken() != null) {
await _config.decryptSecretsAndGetKeyEncKey(
userPassword,
_config.getKeyAttributes()!,
if (userPassword == null) {
await dialog.hide();
// ignore: unawaited_futures
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return const PasswordReentryPage();
},
),
(route) => route.isFirst,
);
page = const HomePage();
} else {
throw Exception("unexpected response during passkey verification");
}
await dialog.hide();
Widget page;
if (_config.getEncryptedToken() != null) {
await _config.decryptSecretsAndGetKeyEncKey(
userPassword,
_config.getKeyAttributes()!,
);
page = const HomePage();
} else {
throw Exception("unexpected response during passkey verification");
}
await dialog.hide();
// ignore: unawaited_futures
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return page;
},
),
(route) => route.isFirst,
);
// ignore: unawaited_futures
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return page;
},
),
(route) => route.isFirst,
);
}
} catch (e) {
_logger.severe(e);
await dialog.hide();
@@ -351,9 +361,12 @@ class UserService {
await dialog.hide();
if (response.statusCode == 200) {
Widget page;
final String passkeySessionID = response.data["passkeySessionID"];
final String twoFASessionID = response.data["twoFactorSessionID"];
if (twoFASessionID.isNotEmpty) {
page = TwoFactorAuthenticationPage(twoFASessionID);
} else if (passkeySessionID.isNotEmpty) {
page = PasskeyPage(passkeySessionID);
} else {
await _saveConfiguration(response);
if (Configuration.instance.getEncryptedToken() != null) {

View File

@@ -1,6 +1,6 @@
name: ente_auth
description: ente two-factor authenticator
version: 3.0.11+311
version: 3.0.12+312
publish_to: none
environment:

View File

@@ -2,10 +2,12 @@
## v1.7.1 (Unreleased)
- Support for passkeys as a second factor authentication mechanism.
- Remember the window size across app restarts.
- Revert changes to the Linux icon.
- Fix an issue where deleted items in watched folders would not move to
- Fix an issue causing deleted items in watched folders to not move to
uncategorized.
- Fix duplicate file uploads when initializing a folder watch (sometimes).
## v1.7.0

View File

@@ -23,6 +23,12 @@ export const createWatcher = (mainWindow: BrowserWindow) => {
const folderPaths = folderWatches().map((watch) => watch.folderPath);
const watcher = chokidar.watch(folderPaths, {
// Don't emit "add" events for matching paths when instantiating the
// watch (we do a full disk scan on launch on our own, and also getting
// the same events from the watcher causes duplicates).
ignoreInitial: true,
// Ask the watcher to wait for a the file size to stabilize before
// telling us about a new file. By default, it waits for 2 seconds.
awaitWriteFinish: true,
});

View File

@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ente Photos</string>
<string>Ente Photos</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>

View File

@@ -340,21 +340,31 @@ class UserService {
await dialog.show();
try {
final userPassword = Configuration.instance.getVolatilePassword();
if (userPassword == null) throw Exception("volatile password is null");
await _saveConfiguration(response);
if (Configuration.instance.getEncryptedToken() != null) {
await Configuration.instance.decryptSecretsAndGetKeyEncKey(
userPassword,
Configuration.instance.getKeyAttributes()!,
if (userPassword == null) {
await dialog.hide();
// ignore: unawaited_futures
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (BuildContext context) {
return const PasswordReentryPage();
},
),
(route) => route.isFirst,
);
} else {
throw Exception("unexpected response during passkey verification");
if (Configuration.instance.getEncryptedToken() != null) {
await Configuration.instance.decryptSecretsAndGetKeyEncKey(
userPassword,
Configuration.instance.getKeyAttributes()!,
);
} else {
throw Exception("unexpected response during passkey verification");
}
await dialog.hide();
Navigator.of(context).popUntil((route) => route.isFirst);
Bus.instance.fire(AccountConfiguredEvent());
}
await dialog.hide();
Navigator.of(context).popUntil((route) => route.isFirst);
Bus.instance.fire(AccountConfiguredEvent());
} catch (e) {
_logger.severe(e);
await dialog.hide();
@@ -384,10 +394,14 @@ class UserService {
await dialog.hide();
if (response.statusCode == 200) {
Widget page;
final String passkeySessionID = response.data["passkeySessionID"];
final String twoFASessionID = response.data["twoFactorSessionID"];
if (twoFASessionID.isNotEmpty) {
await setTwoFactor(value: true);
page = TwoFactorAuthenticationPage(twoFASessionID);
} else if (passkeySessionID.isNotEmpty) {
page = PasskeyPage(passkeySessionID);
} else {
await _saveConfiguration(response);
if (Configuration.instance.getEncryptedToken() != null) {

View File

@@ -12,7 +12,7 @@ description: ente photos application
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.8.138+658
version: 0.9.0+700
publish_to: none
environment:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -6,17 +6,20 @@ import { VerticallyCentered } from "@ente/shared/components/Container";
import EnteButton from "@ente/shared/components/EnteButton";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import InfoIcon from "@mui/icons-material/Info";
import KeyIcon from "@mui/icons-material/Key";
import { Paper, Typography, styled } from "@mui/material";
import { t } from "i18next";
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import {
beginPasskeyAuthentication,
finishPasskeyAuthentication,
isWebAuthnSupported,
isWhitelistedRedirect,
passkeyAuthenticationSuccessRedirectURL,
passkeySessionAlreadyClaimedErrorMessage,
redirectToPasskeyRecoverPage,
signChallenge,
type BeginPasskeyAuthenticationResponse,
} from "services/passkey";
const Page = () => {
@@ -29,14 +32,39 @@ const Page = () => {
| "loading" /* Can happen multiple times in the flow */
| "webAuthnNotSupported" /* Unrecoverable error */
| "unknownRedirect" /* Unrecoverable error */
| "unrecoverableFailure" /* Unrocevorable error - generic */
| "failed" /* Recoverable error */
| "sessionAlreadyClaimed" /* Unrecoverable error */
| "unrecoverableFailure" /* Unrecoverable error - generic */
| "failedDuringSignChallenge" /* Recoverable error in signChallenge */
| "failed" /* Recoverable error otherwise */
| "needUserFocus" /* See docs for `Continuation` */
| "waitingForUser" /* ...to authenticate with their passkey */
| "redirectingWeb" /* Redirect back to the requesting app (HTTP) */
| "redirectingApp"; /* Other redirects (mobile / desktop redirect) */
const [status, setStatus] = useState<Status>("loading");
/**
* Safari keeps on saying "NotAllowedError: The document is not focused"
* even though it just opened the page and brought it to the front.
*
* Because of their incompetence, we need to break our entire flow into two
* parts, and stash away a lot of state when we're in the "needUserFocus"
* state.
*/
interface Continuation {
redirectURL: URL;
clientPackage: string;
passkeySessionID: string;
beginResponse: BeginPasskeyAuthenticationResponse;
}
const [continuation, setContinuation] = useState<
Continuation | undefined
>();
// Safari throws sometimes
// (no reason, just to show their incompetence). The retry doesn't seem to
// help mostly, but cargo cult anyway.
// The URL we're redirecting to on success.
//
// This will only be set when status is "redirecting*".
@@ -44,8 +72,8 @@ const Page = () => {
URL | undefined
>();
/** (re)start the authentication flow */
const authenticate = async () => {
/** Phase 1 of {@link authenticate}. */
const authenticateBegin = useCallback(async () => {
if (!isWebAuthnSupported()) {
setStatus("webAuthnNotSupported");
return;
@@ -86,21 +114,64 @@ const Page = () => {
return;
}
let authorizationResponse: TwoFactorAuthorizationResponse;
let beginResponse: BeginPasskeyAuthenticationResponse;
try {
const { ceremonySessionID, options } =
await beginPasskeyAuthentication(passkeySessionID);
beginResponse = await beginPasskeyAuthentication(passkeySessionID);
} catch (e) {
log.error("Failed to begin passkey authentication", e);
setStatus(
e instanceof Error &&
e.message == passkeySessionAlreadyClaimedErrorMessage
? "sessionAlreadyClaimed"
: "failed",
);
return;
}
setStatus("waitingForUser");
return {
redirectURL,
passkeySessionID,
clientPackage,
beginResponse,
};
}, []);
const credential = await signChallenge(options.publicKey);
/**
* Phase 2 of {@link authenticate}, separated by a potential user
* interaction.
*/
const authenticateContinue = useCallback(async (cont: Continuation) => {
const { redirectURL, passkeySessionID, clientPackage, beginResponse } =
cont;
const { ceremonySessionID, options } = beginResponse;
setStatus("waitingForUser");
let credential: Credential | undefined;
try {
credential = await signChallenge(options.publicKey);
if (!credential) {
setStatus("failed");
setStatus("failedDuringSignChallenge");
return;
}
} catch (e) {
log.error("Failed to get credentials", e);
if (
e instanceof Error &&
e.name == "NotAllowedError" &&
e.message == "The document is not focused."
) {
setStatus("needUserFocus");
} else {
setStatus("failedDuringSignChallenge");
}
return;
}
setStatus("loading");
setStatus("loading");
let authorizationResponse: TwoFactorAuthorizationResponse;
try {
authorizationResponse = await finishPasskeyAuthentication({
passkeySessionID,
ceremonySessionID,
@@ -108,7 +179,7 @@ const Page = () => {
credential,
});
} catch (e) {
log.error("Passkey authentication failed", e);
log.error("Failed to finish passkey authentication", e);
setStatus("failed");
return;
}
@@ -122,16 +193,27 @@ const Page = () => {
authorizationResponse,
),
);
};
}, []);
/** (re)start the authentication flow */
const authenticate = useCallback(async () => {
const cont = await authenticateBegin();
if (cont) {
setContinuation(cont);
await authenticateContinue(cont);
}
}, [authenticateBegin, authenticateContinue]);
useEffect(() => {
void authenticate();
}, []);
}, [authenticate]);
useEffect(() => {
if (successRedirectURL) redirectToURL(successRedirectURL);
}, [successRedirectURL]);
const handleVerify = () => void authenticateContinue(ensure(continuation));
const handleRetry = () => void authenticate();
const handleRecover = (() => {
@@ -156,10 +238,19 @@ const Page = () => {
loading: <Loading />,
unknownRedirect: <UnknownRedirect />,
webAuthnNotSupported: <WebAuthnNotSupported />,
sessionAlreadyClaimed: <SessionAlreadyClaimed />,
unrecoverableFailure: <UnrecoverableFailure />,
failedDuringSignChallenge: (
<RetriableFailed
duringSignChallenge
onRetry={handleRetry}
onRecover={handleRecover}
/>
),
failed: (
<RetriableFailed onRetry={handleRetry} onRecover={handleRecover} />
),
needUserFocus: <Verify onVerify={handleVerify} />,
waitingForUser: <WaitingForUser />,
redirectingWeb: <RedirectingWeb />,
redirectingApp: <RedirectingApp onRetry={handleRedirectAgain} />,
@@ -194,6 +285,26 @@ const WebAuthnNotSupported: React.FC = () => {
return <Failed message={t("passkeys_not_supported")} />;
};
const SessionAlreadyClaimed: React.FC = () => {
return (
<Content>
<SessionAlreadyClaimed_>
<InfoIcon color="secondary" />
<Typography>
{t("passkey_login_already_claimed_session")}
</Typography>
</SessionAlreadyClaimed_>
</Content>
);
};
const SessionAlreadyClaimed_ = styled("div")`
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
`;
const UnrecoverableFailure: React.FC = () => {
return <Failed message={t("passkey_login_generic_error")} />;
};
@@ -237,7 +348,47 @@ const ContentPaper = styled(Paper)`
gap: 1rem;
`;
interface VerifyProps {
/** Called when the user presses the "Verify" button. */
onVerify: () => void;
}
/**
* Gain focus for the current page by requesting the user to explicitly click a
* button. For more details, see the documentation for `Continuation`.
*/
const Verify: React.FC<VerifyProps> = ({ onVerify }) => {
return (
<Content>
<KeyIcon color="secondary" fontSize="large" />
<Typography variant="h3">{t("passkey")}</Typography>
<Typography color="text.muted">
{t("passkey_verify_description")}
</Typography>
<ButtonStack>
<EnteButton
onClick={onVerify}
fullWidth
color="accent"
type="button"
variant="contained"
>
{t("VERIFY")}
</EnteButton>
</ButtonStack>
</Content>
);
};
interface RetriableFailedProps {
/**
* Set this attribute to indicate that this failure occurred during the
* actual passkey verification (`navigator.credentials.get`).
*
* We customize the error message for such cases to give a hint to the user
* that they can try on their other devices too.
*/
duringSignChallenge?: boolean;
/** Callback invoked when the user presses the try again button. */
onRetry: () => void;
/**
@@ -251,6 +402,7 @@ interface RetriableFailedProps {
}
const RetriableFailed: React.FC<RetriableFailedProps> = ({
duringSignChallenge,
onRetry,
onRecover,
}) => {
@@ -259,7 +411,9 @@ const RetriableFailed: React.FC<RetriableFailedProps> = ({
<InfoIcon color="secondary" fontSize="large" />
<Typography variant="h3">{t("passkey_login_failed")}</Typography>
<Typography color="text.muted">
{t("passkey_login_generic_error")}
{duringSignChallenge
? t("passkey_login_credential_hint")
: t("passkey_login_generic_error")}
</Typography>
<ButtonStack>
<EnteButton
@@ -339,8 +493,6 @@ interface RedirectingAppProps {
}
const RedirectingApp: React.FC<RedirectingAppProps> = ({ onRetry }) => {
const handleClose = window.close;
return (
<Content>
<InfoIcon color="accent" fontSize="large" />
@@ -352,15 +504,6 @@ const RedirectingApp: React.FC<RedirectingAppProps> = ({ onRetry }) => {
{t("redirect_close_instructions")}
</Typography>
<ButtonStack>
<EnteButton
onClick={handleClose}
fullWidth
color="secondary"
type="button"
variant="contained"
>
{t("CLOSE")}
</EnteButton>
<EnteButton
onClick={onRetry}
fullWidth

View File

@@ -1,9 +1,7 @@
import { isDevBuild } from "@/next/env";
import log from "@/next/log";
import { clientPackageName } from "@/next/types/app";
import { TwoFactorAuthorizationResponse } from "@/next/types/credentials";
import { ensure } from "@/utils/ensure";
import { wait } from "@/utils/promise";
import { nullToUndefined } from "@/utils/transform";
import {
fromB64URLSafeNoPadding,
@@ -370,6 +368,13 @@ export interface BeginPasskeyAuthenticationResponse {
};
}
/**
* The passkey session which we are trying to start an authentication ceremony
* for has already finished elsewhere.
*/
export const passkeySessionAlreadyClaimedErrorMessage =
"Passkey session already claimed";
/**
* Create a authentication ceremony session and return a challenge and a list of
* public key credentials that can be used to attest that challenge.
@@ -381,6 +386,9 @@ export interface BeginPasskeyAuthenticationResponse {
*
* @param passkeySessionID A session created by the requesting app that can be
* used to initiate a passkey authentication ceremony on the accounts app.
*
* @throws In addition to arbitrary errors, it throws errors with the message
* {@link passkeySessionAlreadyClaimedErrorMessage}.
*/
export const beginPasskeyAuthentication = async (
passkeySessionID: string,
@@ -390,7 +398,11 @@ export const beginPasskeyAuthentication = async (
method: "POST",
body: JSON.stringify({ sessionID: passkeySessionID }),
});
if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
if (!res.ok) {
if (res.status == 409)
throw new Error(passkeySessionAlreadyClaimedErrorMessage);
throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
}
// See: [Note: Converting binary data in WebAuthn API payloads]
@@ -424,30 +436,7 @@ export const beginPasskeyAuthentication = async (
*/
export const signChallenge = async (
publicKey: PublicKeyCredentialRequestOptions,
) => {
const go = async () => await navigator.credentials.get({ publicKey });
try {
return await go();
} catch (e) {
// Safari throws "NotAllowedError: The document is not focused" for the
// first request sometimes (no reason, just to show their incompetence).
// "NotAllowedError" is also the error name that is thrown when the user
// explicitly cancels, so we can't even filter it out by name and also
// to do a message match.
if (
e instanceof Error &&
e.name == "NotAllowedError" &&
e.message == "The document is not focused."
) {
log.warn("Working around Safari bug by retrying after failure", e);
await wait(2000);
return await go();
} else {
throw e;
}
}
};
) => nullToUndefined(await navigator.credentials.get({ publicKey }));
interface FinishPasskeyAuthenticationOptions {
passkeySessionID: string;

View File

@@ -3,9 +3,9 @@ import {
HorizontalFlex,
VerticallyCentered,
} from "@ente/shared/components/Container";
import type { DialogBoxAttributesV2 } from "@ente/shared/components/DialogBoxV2/types";
import { EnteLogo } from "@ente/shared/components/EnteLogo";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
import { sessionExpiredDialogAttributes } from "@ente/shared/components/LoginComponents";
import NavbarBase from "@ente/shared/components/Navbar/base";
import OverflowMenu from "@ente/shared/components/OverflowMenu/menu";
import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option";
@@ -140,19 +140,6 @@ const Page: React.FC = () => {
export default Page;
const sessionExpiredDialogAttributes = (
action: () => void,
): DialogBoxAttributesV2 => ({
title: t("SESSION_EXPIRED"),
content: t("SESSION_EXPIRED_MESSAGE"),
nonClosable: true,
proceed: {
text: t("LOGIN"),
action,
variant: "accent",
},
});
const AuthNavbar: React.FC = () => {
const { isMobile, logout } = ensure(useContext(AppContext));

View File

@@ -528,13 +528,11 @@ const UtilitySection: React.FC<UtilitySectionProps> = ({ closeSidebar }) => {
label={t("TWO_FACTOR")}
/>
{isInternalUserViaEmailCheck() && (
<EnteMenuItem
variant="secondary"
onClick={redirectToAccountsPage}
label={t("passkeys")}
/>
)}
<EnteMenuItem
variant="secondary"
onClick={redirectToAccountsPage}
label={t("passkeys")}
/>
<EnteMenuItem
variant="secondary"

View File

@@ -67,10 +67,11 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
const [keyAttributes, setKeyAttributes] = useState<KeyAttributes>();
const [user, setUser] = useState<User>();
const [passkeyVerificationData, setPasskeyVerificationData] = useState<
[string, string] | undefined
{ passkeySessionID: string; url: string } | undefined
>();
const router = useRouter();
useEffect(() => {
const main = async () => {
const user: User = getData(LS_KEYS.USER);
@@ -180,8 +181,8 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
appName,
passkeySessionID,
);
setPasskeyVerificationData([passkeySessionID, url]);
openPasskeyVerificationURL(passkeySessionID, url);
setPasskeyVerificationData({ passkeySessionID, url });
openPasskeyVerificationURL({ passkeySessionID, url });
throw Error(CustomError.TWO_FACTOR_ENABLED);
} else if (twoFactorSessionID) {
const sessionKeyAttributes =
@@ -295,11 +296,11 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
return (
<VerifyingPasskey
email={user?.email}
passkeySessionID={passkeyVerificationData?.passkeySessionID}
onRetry={() =>
openPasskeyVerificationURL(...passkeyVerificationData)
openPasskeyVerificationURL(passkeyVerificationData)
}
onRecover={() => router.push("/passkeys/recover")}
onLogout={logout}
appContext={appContext}
/>
);
}

View File

@@ -90,6 +90,10 @@ const saveCredentialsAndNavigateTo = async (
// - The plaintext "token" will be passed during fresh signups, where we
// don't yet have keys to encrypt it, the account itself is being created
// as we go through this flow.
// TODO(MR): Conceptually this cannot happen. During a _real_ fresh signup
// we'll never enter the passkey verification flow. Remove this code after
// making sure that it doesn't get triggered in cases where an existing
// user goes through the new user flow.
//
// - The encrypted `encryptedToken` will be present otherwise (i.e. if the
// user is signing into an existing account).

View File

@@ -42,7 +42,7 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
const [email, setEmail] = useState("");
const [resend, setResend] = useState(0);
const [passkeyVerificationData, setPasskeyVerificationData] = useState<
[string, string] | undefined
{ passkeySessionID: string; url: string } | undefined
>();
const router = useRouter();
@@ -98,8 +98,8 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
appName,
passkeySessionID,
);
setPasskeyVerificationData([passkeySessionID, url]);
openPasskeyVerificationURL(passkeySessionID, url);
setPasskeyVerificationData({ passkeySessionID, url });
openPasskeyVerificationURL({ passkeySessionID, url });
} else if (twoFactorSessionID) {
setData(LS_KEYS.USER, {
email,
@@ -193,11 +193,11 @@ const Page: React.FC<PageProps> = ({ appContext }) => {
return (
<VerifyingPasskey
email={email}
passkeySessionID={passkeyVerificationData?.passkeySessionID}
onRetry={() =>
openPasskeyVerificationURL(...passkeyVerificationData)
openPasskeyVerificationURL(passkeyVerificationData)
}
onRecover={() => router.push("/passkeys/recover")}
onLogout={logout}
appContext={appContext}
/>
);
}

View File

@@ -1,6 +1,9 @@
import { clientPackageHeaderIfPresent } from "@/next/http";
import log from "@/next/log";
import type { AppName } from "@/next/types/app";
import { clientPackageName } from "@/next/types/app";
import { TwoFactorAuthorizationResponse } from "@/next/types/credentials";
import { ensure } from "@/utils/ensure";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { getRecoveryKey } from "@ente/shared/crypto/helpers";
import {
@@ -10,6 +13,8 @@ import {
import { CustomError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { accountsAppURL, apiOrigin } from "@ente/shared/network/api";
import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore";
import { LS_KEYS, getData, setData } from "@ente/shared/storage/localStorage";
import { getToken } from "@ente/shared/storage/localStorage/helpers";
/**
@@ -46,16 +51,23 @@ export const passkeyVerificationRedirectURL = (
return `${accountsAppURL()}/passkeys/verify?${params.toString()}`;
};
interface OpenPasskeyVerificationURLOptions {
/**
* The passkeySessionID for which we are redirecting.
*
* This is compared to the saved session id in the browser's session storage
* to allow us to ignore redirects to the passkey flow finish page except
* the ones for this specific session we're awaiting.
*/
passkeySessionID: string;
/** The URL to redirect to or open in the system browser. */
url: string;
}
/**
* Open or redirect to a passkey verification URL previously constructed using
* {@link passkeyVerificationRedirectURL}.
*
* @param passkeySessionID The passkeySessionID for which we are redirecting.
* This is saved to session storage to allow us to ignore subsequent redirects
* to the passkey flow finish page except the ones for this specific session.
*
* @param url The URL to redirect to or open in the system browser.
*
* [Note: Passkey verification in the desktop app]
*
* Our desktop app bundles the web app and serves it over a custom protocol.
@@ -75,10 +87,10 @@ export const passkeyVerificationRedirectURL = (
* authentication happens at accounts.ente.io, and on success there is
* redirected back to the desktop app.
*/
export const openPasskeyVerificationURL = (
passkeySessionID: string,
url: string,
) => {
export const openPasskeyVerificationURL = ({
passkeySessionID,
url,
}: OpenPasskeyVerificationURLOptions) => {
sessionStorage.setItem("inflightPasskeySessionID", passkeySessionID);
if (globalThis.electron) window.open(url);
@@ -192,3 +204,78 @@ const getAccountsToken = async () => {
);
return resp.data["accountsToken"];
};
/**
* The passkey session whose status we are trying to check has already expired.
* The user should attempt to login again.
*/
export const passkeySessionExpiredErrorMessage = "Passkey session has expired";
/**
* Check if the user has already authenticated using their passkey for the given
* session.
*
* This is useful in case the automatic redirect back from accounts.ente.io to
* the desktop app does not work for some reason. In such cases, the user can
* press the "Check status" button: we'll make an API call to see if the
* authentication has already completed, and if so, get the same "response"
* object we'd have gotten as a query parameter in a redirect in
* {@link saveCredentialsAndNavigateTo} on the "/passkeys/finish" page.
*
* @param sessionID The passkey session whose session we wish to check the
* status of.
*
* @returns A {@link TwoFactorAuthorizationResponse} if the passkey
* authentication has completed, and `undefined` otherwise.
*
* @throws In addition to arbitrary errors, it throws errors with the message
* {@link passkeySessionExpiredErrorMessage}.
*/
export const checkPasskeyVerificationStatus = async (
sessionID: string,
): Promise<TwoFactorAuthorizationResponse | undefined> => {
const url = `${apiOrigin()}/users/two-factor/passkeys/get-token`;
const params = new URLSearchParams({ sessionID });
const res = await fetch(`${url}?${params.toString()}`, {
headers: clientPackageHeaderIfPresent(),
});
if (!res.ok) {
if (res.status == 404 || res.status == 410)
throw new Error(passkeySessionExpiredErrorMessage);
if (res.status == 400) return undefined; /* verification pending */
throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
}
return TwoFactorAuthorizationResponse.parse(await res.json());
};
/**
* Extract credentials from a successful passkey verification response and save
* them to local storage for use by subsequent steps (or normal functioning) of
* the app.
*
* @param response The result of a successful
* {@link checkPasskeyVerificationStatus}.
*
* @returns the slug that we should navigate to now.
*/
export const saveCredentialsAndNavigateTo = (
response: TwoFactorAuthorizationResponse,
) => {
// This method somewhat duplicates `saveCredentialsAndNavigateTo` in the
// /passkeys/finish page.
const { id, encryptedToken, keyAttributes } = response;
setData(LS_KEYS.USER, {
...getData(LS_KEYS.USER),
encryptedToken,
id,
});
setData(LS_KEYS.KEY_ATTRIBUTES, ensure(keyAttributes));
// TODO(MR): Remove the cast.
const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL) as
| string
| undefined;
InMemoryStore.delete(MS_KEYS.REDIRECT_URL);
return redirectURL ?? "/credentials";
};

View File

@@ -48,3 +48,13 @@ export const authenticatedRequestHeaders = (): Record<string, string> => {
if (_clientPackage) headers["X-Client-Package"] = _clientPackage;
return headers;
};
/**
* Return a headers object with "X-Client-Package" header if we have the client
* package value available to us from local storage.
*/
export const clientPackageHeaderIfPresent = (): Record<string, string> => {
const headers: Record<string, string> = {};
if (_clientPackage) headers["X-Client-Package"] = _clientPackage;
return headers;
};

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -26,7 +26,7 @@
"SENT": "Gesendet!",
"password": "Passwort",
"link_password_description": "Passwort zum Entsperren des Albums eingeben",
"unlock": "",
"unlock": "Freischalten",
"SET_PASSPHRASE": "Passwort setzen",
"VERIFY_PASSPHRASE": "Einloggen",
"INCORRECT_PASSPHRASE": "Falsches Passwort",
@@ -85,7 +85,7 @@
"NEXT": "Weitere (→)",
"title_photos": "Ente Fotos",
"title_auth": "Ente Auth",
"title_accounts": "",
"title_accounts": "Ente Konten",
"UPLOAD_FIRST_PHOTO": "Lade dein erstes Foto hoch",
"IMPORT_YOUR_FOLDERS": "Importiere deiner Ordner",
"UPLOAD_DROPZONE_MESSAGE": "Loslassen, um Dateien zu sichern",
@@ -566,7 +566,7 @@
"VIDEO": "Video",
"LIVE_PHOTO": "Live-Foto",
"editor": {
"crop": ""
"crop": "Zuschneiden"
},
"CONVERT": "Konvertieren",
"CONFIRM_EDITOR_CLOSE_MESSAGE": "Editor wirklich schließen?",
@@ -610,8 +610,8 @@
"APPLY_CROP": "Zuschnitt anwenden",
"PHOTO_EDIT_REQUIRED_TO_SAVE": "Es muss mindestens eine Transformation oder Farbanpassung vorgenommen werden, bevor gespeichert werden kann.",
"passkeys": "Passkeys",
"passkey_fetch_failed": "",
"manage_passkey": "",
"passkey_fetch_failed": "Ihre Passkeys konnten nicht abgerufen werden.",
"manage_passkey": "Passkey verwalten",
"delete_passkey": "Passkey löschen",
"delete_passkey_confirmation": "Bist du sicher, dass du diesen Passkey löschen willst? Dieser Vorgang ist nicht umkehrbar.",
"rename_passkey": "Passkey umbenennen",
@@ -619,19 +619,24 @@
"enter_passkey_name": "Passkey-Namen eingeben",
"passkeys_description": "Passkeys sind ein moderner und sicherer zweiter Faktor für dein Ente-Konto. Sie nutzen die biometrische Authentifizierung des Geräts für Komfort und Sicherheit.",
"CREATED_AT": "Erstellt am",
"passkey_add_failed": "",
"passkey_add_failed": "Ein Passkey konnte nicht hinzugefügt werden",
"passkey_login_failed": "Passkey-Anmeldung fehlgeschlagen",
"passkey_login_invalid_url": "Die Anmelde-URL ist ungültig.",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "Ein Fehler trat auf beim Anmelden mit dem Passkey auf.",
"passkeys_not_supported": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "Passkeys werden in diesem Browser nicht unterstützt",
"try_again": "Erneut versuchen",
"check_status": "Status überprüfen",
"passkey_login_instructions": "Folge den Schritten in deinem Browser, um mit dem Anmelden fortzufahren.",
"passkey_login": "Mit Passkey anmelden",
"passkey": "",
"waiting_for_verification": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",
"passkey": "Passkey",
"passkey_verify_description": "",
"waiting_for_verification": "Warte auf Bestätigung...",
"verification_still_pending": "Verifizierung steht noch aus",
"passkey_verified": "Passwort verifiziert",
"redirecting_back_to_app": "Sie werden zurück zur App weitergeleitet...",
"redirect_close_instructions": "Sie werden zurück zur App weitergeleitet.",
"redirect_again": "",
"autogenerated_first_album_name": "Mein erstes Album",
"autogenerated_default_album_name": "Neues Album"

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "Could not add passkey",
"passkey_login_failed": "Passkey login failed",
"passkey_login_invalid_url": "The login URL is invalid.",
"passkey_login_already_claimed_session": "This session has already been verified.",
"passkey_login_generic_error": "An error occurred while logging in with passkey.",
"passkey_login_credential_hint": "If your passkeys are on a different device, you can open this page on that device to verify.",
"passkeys_not_supported": "Passkeys are not supported in this browser",
"try_again": "Try again",
"check_status": "Check status",
"passkey_login_instructions": "Follow the steps from your browser to continue logging in.",
"passkey_login": "Login with passkey",
"passkey": "Passkey",
"passkey_verify_description": "Verify your passkey to login into your account.",
"waiting_for_verification": "Waiting for verification...",
"verification_still_pending": "Verification is still pending",
"passkey_verified": "Passkey verified",
"redirecting_back_to_app": "Redirecting you back to the app...",
"redirect_close_instructions": "You can close this window after the app opens.",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "Inténtelo de nuevo",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "Échec de la connexion via code d'accès",
"passkey_login_invalid_url": "LURL de connexion est invalide.",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "Une erreur s'est produite lors de la connexion avec le code d'accès.",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "Réessayer",
"check_status": "",
"passkey_login_instructions": "Suivez les étapes de votre navigateur pour poursuivre la connexion.",
"passkey_login": "Se connecter avec le code d'accès",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "Vérification du code d'accès",
"redirecting_back_to_app": "Redirection vers l'application...",
"redirect_close_instructions": "Vous pouvez fermer cette fenêtre après l'ouverture de l'application.",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "Passkey login mislukt",
"passkey_login_invalid_url": "De inlog-URL is ongeldig.",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "Er is een fout opgetreden tijdens het inloggen met een passkey.",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "Probeer opnieuw",
"check_status": "",
"passkey_login_instructions": "Volg de stappen van je browser om door te gaan met inloggen.",
"passkey_login": "Inloggen met passkey",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "Não foi possível adicionar chave de acesso",
"passkey_login_failed": "Falha ao iniciar sessão com a chave de acesso",
"passkey_login_invalid_url": "URL de login inválida.",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "Ocorreu um erro ao entrar com a chave de acesso.",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "As chaves de acesso não são suportadas neste navegador",
"try_again": "Tente novamente",
"check_status": "",
"passkey_login_instructions": "Siga os passos do seu navegador para continuar acessando.",
"passkey_login": "Entrar com a chave de acesso",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "Chave de acesso verificada",
"redirecting_back_to_app": "Redirecionando você de volta para o aplicativo...",
"redirect_close_instructions": "Você pode fechar esta janela após a aplicação ser aberta.",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "Не удалось войти с помощью пароля",
"passkey_login_invalid_url": "Неверный URL-адрес для входа в систему.",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "При входе в систему с помощью пароля произошла ошибка.",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "Пробовать снова",
"check_status": "",
"passkey_login_instructions": "Следуйте инструкциям в вашем браузере, чтобы продолжить вход в систему.",
"passkey_login": "Войдите в систему с помощью пароля",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "Försök igen",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,13 +622,18 @@
"passkey_add_failed": "",
"passkey_login_failed": "",
"passkey_login_invalid_url": "",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "",
"try_again": "",
"check_status": "",
"passkey_login_instructions": "",
"passkey_login": "",
"passkey": "",
"passkey_verify_description": "",
"waiting_for_verification": "",
"verification_still_pending": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",

View File

@@ -622,17 +622,22 @@
"passkey_add_failed": "无法添加通行密钥",
"passkey_login_failed": "通行密钥登录失败",
"passkey_login_invalid_url": "该登录 URL 无效",
"passkey_login_already_claimed_session": "",
"passkey_login_generic_error": "使用通行密钥登录时出错。",
"passkey_login_credential_hint": "",
"passkeys_not_supported": "此浏览器不支持通行密钥",
"try_again": "重试",
"check_status": "",
"passkey_login_instructions": "按照浏览器中提示的步骤继续登录。",
"passkey_login": "使用通行密钥来登录",
"passkey": "",
"waiting_for_verification": "",
"passkey_verified": "",
"redirecting_back_to_app": "",
"redirect_close_instructions": "",
"redirect_again": "",
"passkey": "通行密钥",
"passkey_verify_description": "",
"waiting_for_verification": "正在等待验证...",
"verification_still_pending": "",
"passkey_verified": "已验证通行密钥",
"redirecting_back_to_app": "正在重定向至您的应用...",
"redirect_close_instructions": "在应用程序打开后您可以关闭此窗口。",
"redirect_again": "再次重定向",
"autogenerated_first_album_name": "我的第一个相册",
"autogenerated_default_album_name": "新建相册"
}

View File

@@ -9,7 +9,11 @@ export const KeyAttributes = z.object({}).passthrough();
*/
export const TwoFactorAuthorizationResponse = z.object({
id: z.number(),
/** TODO: keyAttributes is guaranteed to be returned by museum, update the
* types to reflect that. */
keyAttributes: KeyAttributes.nullish().transform(nullToUndefined),
/** TODO: encryptedToken is guaranteed to be returned by museum, update the
* types to reflect that. */
encryptedToken: z.string().nullish().transform(nullToUndefined),
});

View File

@@ -0,0 +1,11 @@
import { t } from "i18next";
import type { DialogBoxAttributesV2 } from "./DialogBoxV2/types";
/**
* {@link DialogBoxAttributesV2} for showing a generic error.
*/
export const genericErrorAttributes = (): DialogBoxAttributesV2 => ({
title: t("ERROR"),
close: { variant: "critical" },
content: t("UNKNOWN_ERROR"),
});

View File

@@ -1,9 +1,20 @@
import { isDevBuild } from "@/next/env";
import log from "@/next/log";
import type { BaseAppContextT } from "@/next/types/app";
import {
checkPasskeyVerificationStatus,
passkeySessionExpiredErrorMessage,
saveCredentialsAndNavigateTo,
} from "@ente/accounts/services/passkey";
import EnteButton from "@ente/shared/components/EnteButton";
import { apiOrigin } from "@ente/shared/network/api";
import { Typography, styled } from "@mui/material";
import { CircularProgress, Typography, styled } from "@mui/material";
import { t } from "i18next";
import { useRouter } from "next/router";
import React, { useState } from "react";
import { VerticallyCentered } from "./Container";
import type { DialogBoxAttributesV2 } from "./DialogBoxV2/types";
import { genericErrorAttributes } from "./ErrorComponents";
import FormPaper from "./Form/FormPaper";
import FormPaperFooter from "./Form/FormPaper/Footer";
import LinkButton from "./LinkButton";
@@ -52,35 +63,86 @@ const ConnectionDetails_ = styled("div")`
`;
interface VerifyingPasskeyProps {
/** The email of the user whose passkey we're verifying */
/** ID of the current passkey verification session. */
passkeySessionID: string;
/** The email of the user whose passkey we're verifying. */
email: string | undefined;
/** Called when the user wants to redirect again. */
onRetry: () => void;
/** Called when the user presses the "Recover account" button. */
onRecover: () => void;
/** Called when the user presses the "Change email" button. */
onLogout: () => void;
/**
* The appContext.
*
* Needs to be explicitly passed since this component is used in a package
* where the pages are not wrapped in the provider.
*/
appContext: BaseAppContextT;
}
export const VerifyingPasskey: React.FC<VerifyingPasskeyProps> = ({
passkeySessionID,
email,
onRetry,
onRecover,
onLogout,
appContext,
}) => {
const { logout, setDialogBoxAttributesV2 } = appContext;
type VerificationStatus = "waiting" | "checking" | "pending";
const [verificationStatus, setVerificationStatus] =
useState<VerificationStatus>("waiting");
const router = useRouter();
const handleRetry = () => {
setVerificationStatus("waiting");
onRetry();
};
const handleCheckStatus = async () => {
setVerificationStatus("checking");
try {
const response =
await checkPasskeyVerificationStatus(passkeySessionID);
if (!response) setVerificationStatus("pending");
else router.push(saveCredentialsAndNavigateTo(response));
} catch (e) {
log.error("Passkey verification status check failed", e);
setDialogBoxAttributesV2(
e instanceof Error &&
e.message == passkeySessionExpiredErrorMessage
? sessionExpiredDialogAttributes(logout)
: genericErrorAttributes(),
);
setVerificationStatus("waiting");
}
};
const handleRecover = () => {
router.push("/passkeys/recover");
};
return (
<VerticallyCentered>
<FormPaper style={{ minWidth: "320px" }}>
<PasskeyHeader>{email ?? ""}</PasskeyHeader>
<VerifyingPasskeyMiddle>
<Typography color="text.muted">
{t("waiting_for_verification")}
</Typography>
<VerifyingPasskeyStatus>
{verificationStatus == "checking" ? (
<Typography>
<CircularProgress color="accent" size="1.5em" />
</Typography>
) : (
<Typography color="text.muted">
{verificationStatus == "waiting"
? t("waiting_for_verification")
: t("verification_still_pending")}
</Typography>
)}
</VerifyingPasskeyStatus>
<ButtonStack>
<EnteButton
onClick={onRetry}
onClick={handleRetry}
fullWidth
color="secondary"
type="button"
@@ -88,23 +150,22 @@ export const VerifyingPasskey: React.FC<VerifyingPasskeyProps> = ({
{t("try_again")}
</EnteButton>
{/* TODO-PK: Uncomment once the API is ready
<EnteButton
onClick={() => {}}
onClick={handleCheckStatus}
fullWidth
color="accent"
type="button"
>
{"Check status"}
</EnteButton> */}
{t("check_status")}
</EnteButton>
</ButtonStack>
</VerifyingPasskeyMiddle>
<FormPaperFooter style={{ justifyContent: "space-between" }}>
<LinkButton onClick={onRecover}>
<LinkButton onClick={handleRecover}>
{t("RECOVER_ACCOUNT")}
</LinkButton>
<LinkButton onClick={onLogout}>
<LinkButton onClick={logout}>
{t("CHANGE_EMAIL")}
</LinkButton>
</FormPaperFooter>
@@ -121,7 +182,13 @@ const VerifyingPasskeyMiddle = styled("div")`
padding-block: 1rem;
gap: 4rem;
`;
const VerifyingPasskeyStatus = styled("div")`
text-align: center;
/* Size of the CircularProgress (+ some margin) so that there is no layout
shift when it is shown */
min-height: 2em;
`;
const ButtonStack = styled("div")`
@@ -129,3 +196,26 @@ const ButtonStack = styled("div")`
flex-direction: column;
gap: 1rem;
`;
/**
* {@link DialogBoxAttributesV2} for showing the error when the user's session
* has expired.
*
* It asks them to login again. There is one button, which allows them to
* logout.
*
* @param onLogin Called when the user presses the "Login" button on the error
* dialog.
*/
export const sessionExpiredDialogAttributes = (
onLogin: () => void,
): DialogBoxAttributesV2 => ({
title: t("SESSION_EXPIRED"),
content: t("SESSION_EXPIRED_MESSAGE"),
nonClosable: true,
proceed: {
text: t("LOGIN"),
action: onLogin,
variant: "accent",
},
});