Compare commits
31 Commits
auth-v3.0.
...
photos-v0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16817eceac | ||
|
|
500e40035f | ||
|
|
366da2c328 | ||
|
|
203d46b2cf | ||
|
|
0e772fcfb7 | ||
|
|
bbd6745372 | ||
|
|
dd1e0a9b1d | ||
|
|
940231e38d | ||
|
|
4c8db02de5 | ||
|
|
8af5aadd1b | ||
|
|
205feab4c2 | ||
|
|
60ab2b4427 | ||
|
|
612329f584 | ||
|
|
a5f4a676a7 | ||
|
|
9608cfaa4e | ||
|
|
ddd4d3e16c | ||
|
|
df0d48af73 | ||
|
|
c82193cae6 | ||
|
|
2c0928bd02 | ||
|
|
8c8ffa9397 | ||
|
|
3689ecb6e7 | ||
|
|
ca080ad6b2 | ||
|
|
b2e56fc01e | ||
|
|
228dd90bce | ||
|
|
93380d05b4 | ||
|
|
4123197c6d | ||
|
|
cc3f398a78 | ||
|
|
dd0f7d3142 | ||
|
|
325c963b7a | ||
|
|
fbf29585eb | ||
|
|
8a2cc858ae |
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -622,13 +622,18 @@
|
||||
"passkey_add_failed": "",
|
||||
"passkey_login_failed": "Échec de la connexion via code d'accès",
|
||||
"passkey_login_invalid_url": "L’URL 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.",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "新建相册"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
11
web/packages/shared/components/ErrorComponents.tsx
Normal file
11
web/packages/shared/components/ErrorComponents.tsx
Normal 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"),
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user