diff --git a/.github/workflows/mobile-lint.yml b/.github/workflows/mobile-lint.yml index 493185b6b..3a43924a3 100644 --- a/.github/workflows/mobile-lint.yml +++ b/.github/workflows/mobile-lint.yml @@ -9,7 +9,7 @@ on: - ".github/workflows/mobile-lint.yml" env: - FLUTTER_VERSION: "3.19.5" + FLUTTER_VERSION: "3.22.0" jobs: lint: diff --git a/desktop/src/main/stream.ts b/desktop/src/main/stream.ts index 1c8223c87..c11fb1121 100644 --- a/desktop/src/main/stream.ts +++ b/desktop/src/main/stream.ts @@ -106,7 +106,7 @@ const handleRead = async (path: string) => { res.headers.set("Content-Length", `${fileSize}`); // Add the file's last modified time (as epoch milliseconds). - const mtimeMs = stat.mtimeMs; + const mtimeMs = stat.mtime.getTime(); res.headers.set("X-Last-Modified-Ms", `${mtimeMs}`); } return res; @@ -132,6 +132,13 @@ const handleReadZip = async (zipPath: string, entryName: string) => { // Close the zip handle when the underlying stream closes. stream.on("end", () => void zip.close()); + // While it is documented that entry.time is the modification time, + // the units are not mentioned. By seeing the source code, we can + // verify that it is indeed epoch milliseconds. See `parseZipTime` + // in the node-stream-zip source, + // https://github.com/antelle/node-stream-zip/blob/master/node_stream_zip.js + const modifiedMs = entry.time; + return new Response(webReadableStream, { headers: { // We don't know the exact type, but it doesn't really matter, just @@ -139,12 +146,7 @@ const handleReadZip = async (zipPath: string, entryName: string) => { // doesn't tinker with it thinking of it as text. "Content-Type": "application/octet-stream", "Content-Length": `${entry.size}`, - // While it is documented that entry.time is the modification time, - // the units are not mentioned. By seeing the source code, we can - // verify that it is indeed epoch milliseconds. See `parseZipTime` - // in the node-stream-zip source, - // https://github.com/antelle/node-stream-zip/blob/master/node_stream_zip.js - "X-Last-Modified-Ms": `${entry.time}`, + "X-Last-Modified-Ms": `${modifiedMs}`, }, }); }; diff --git a/mobile/README.md b/mobile/README.md index fc17f6b26..6d86ad534 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -46,7 +46,7 @@ You can alternatively install the build from PlayStore or F-Droid. ## 🧑‍💻 Building from source -1. [Install Flutter v3.19.3](https://flutter.dev/docs/get-started/install). +1. [Install Flutter v3.22.0](https://flutter.dev/docs/get-started/install). 2. Pull in all submodules with `git submodule update --init --recursive` diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 558a27910..9f74d552a 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -427,7 +427,7 @@ SPEC CHECKSUMS: home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57 image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43 in_app_purchase_storekit: 0e4b3c2e43ba1e1281f4f46dd71b0593ce529892 - integration_test: 13825b8a9334a850581300559b8839134b124670 + integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 local_auth_darwin: c7e464000a6a89e952235699e32b329457608d98 local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9 diff --git a/mobile/lib/l10n/intl_pt.arb b/mobile/lib/l10n/intl_pt.arb index f47dd89e9..10117b426 100644 --- a/mobile/lib/l10n/intl_pt.arb +++ b/mobile/lib/l10n/intl_pt.arb @@ -987,7 +987,7 @@ "fileTypesAndNames": "Tipos de arquivo e nomes", "location": "Local", "moments": "Momentos", - "searchFaceEmptySection": "Encontre todas as fotos de uma pessoa", + "searchFaceEmptySection": "Pessoas serão exibidas aqui uma vez que a indexação é feita", "searchDatesEmptySection": "Pesquisar por data, mês ou ano", "searchLocationEmptySection": "Fotos de grupo que estão sendo tiradas em algum raio da foto", "searchPeopleEmptySection": "Convide pessoas e você verá todas as fotos compartilhadas por elas aqui", @@ -1042,7 +1042,7 @@ "@storageUsageInfo": { "description": "Example: 1.2 GB of 2 GB used or 100 GB or 2TB used" }, - "freeStorageSpace": "{freeAmount} {storageUnit} grátis", + "freeStorageSpace": "{freeAmount} {storageUnit} livre", "appVersion": "Versão: {versionValue}", "verifyIDLabel": "Verificar", "fileInfoAddDescHint": "Adicionar descrição...", @@ -1171,6 +1171,7 @@ } }, "faces": "Rostos", + "people": "Pessoas", "contents": "Conteúdos", "addNew": "Adicionar novo", "@addNew": { @@ -1196,14 +1197,14 @@ "verifyPasskey": "Verificar chave de acesso", "playOnTv": "Reproduzir álbum na TV", "pair": "Parear", - "autoPair": "Pareamento automático", - "pairWithPin": "Parear com PIN", "deviceNotFound": "Dispositivo não encontrado", "castInstruction": "Visite cast.ente.io no dispositivo que você deseja parear.\n\ndigite o código abaixo para reproduzir o álbum em sua TV.", "deviceCodeHint": "Insira o código", "joinDiscord": "Junte-se ao Discord", "locations": "Locais", "descriptions": "Descrições", + "addAName": "Adicione um nome", + "findPeopleByName": "Encontre pessoas rapidamente por nome", "addViewers": "{count, plural, zero {Adicionar visualizador} one {Adicionar visualizador} other {Adicionar Visualizadores}}", "addCollaborators": "{count, plural, zero {Adicionar colaborador} one {Adicionar coloborador} other {Adicionar colaboradores}}", "longPressAnEmailToVerifyEndToEndEncryption": "Pressione e segure um e-mail para verificar a criptografia de ponta a ponta.", @@ -1216,6 +1217,8 @@ "customEndpoint": "Conectado a {endpoint}", "createCollaborativeLink": "Criar link colaborativo", "search": "Pesquisar", + "enterPersonName": "Inserir nome da pessoa", + "removePersonLabel": "Remover etiqueta da pessoa", "autoPairDesc": "O pareamento automático funciona apenas com dispositivos que suportam o Chromecast.", "manualPairDesc": "Parear com o PIN funciona com qualquer tela que você deseja ver o seu álbum ativado.", "connectToDevice": "Conectar ao dispositivo", @@ -1227,8 +1230,10 @@ "castIPMismatchTitle": "Falha ao transmitir álbum", "castIPMismatchBody": "Certifique-se de estar na mesma rede que a TV.", "pairingComplete": "Pareamento concluído", - "faceRecognition": "Face recognition", - "faceRecognitionIndexingDescription": "Please note that this will result in a higher bandwidth and battery usage until all items are indexed.", - "foundFaces": "Found faces", - "clusteringProgress": "Clustering progress" + "autoPair": "Pareamento automático", + "pairWithPin": "Parear com PIN", + "faceRecognition": "Reconhecimento facial", + "faceRecognitionIndexingDescription": "Por favor, note que isso resultará em uma largura de banda maior e uso de bateria até que todos os itens sejam indexados.", + "foundFaces": "Rostos encontrados", + "clusteringProgress": "Progresso de agrupamento" } \ No newline at end of file diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 1d1082bfd..8b71025e9 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: "direct main" description: name: animated_list_plus - sha256: fe66f9c300d715254727fbdf050487844d17b013fec344fa28081d29bddbdf1a + sha256: fb3d7f1fbaf5af84907f3c739236bacda8bf32cbe1f118dd51510752883ff50c url: "https://pub.dev" source: hosted - version: "0.4.5" + version: "0.5.2" animated_stack_widget: dependency: transitive description: @@ -971,10 +971,10 @@ packages: dependency: "direct main" description: name: home_widget - sha256: "29565bfee4b32eaf9e7e8b998d504618b779a74b2b1ac62dd4dac7468e66f1a3" + sha256: "2a0fdd6267ff975bd07bedf74686bd5577200f504f5de36527ac1b56bdbe68e3" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.6.0" html: dependency: transitive description: @@ -1152,26 +1152,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" like_button: dependency: "direct main" description: @@ -1368,10 +1368,10 @@ packages: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" mgrs_dart: dependency: transitive description: @@ -2144,10 +2144,10 @@ packages: dependency: "direct main" description: name: styled_text - sha256: f72928d1ebe8cb149e3b34a689cb1ddca696b808187cf40ac3a0bd183dff379c + sha256: fd624172cf629751b4f171dd0ecf9acf02a06df3f8a81bb56c0caa4f1df706c3 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "8.1.0" sync_http: dependency: transitive description: @@ -2160,18 +2160,18 @@ packages: dependency: "direct main" description: name: syncfusion_flutter_core - sha256: "9be1bb9bbdb42823439a18da71484f1964c14dbe1c255ab1b931932b12fa96e8" + sha256: "63108a33f9b0d89f7b6b56cce908b8e519fe433dbbe0efcf41ad3e8bb2081bd9" url: "https://pub.dev" source: hosted - version: "19.4.56" + version: "25.2.5" syncfusion_flutter_sliders: dependency: "direct main" description: name: syncfusion_flutter_sliders - sha256: "1f6a63ccab4180b544074b9264a20f01ee80b553de154192fe1d7b434089d3c2" + sha256: f27310bedc0e96e84054f0a70ac593d1a3c38397c158c5226ba86027ad77b2c1 url: "https://pub.dev" source: hosted - version: "19.4.56" + version: "25.2.5" synchronized: dependency: "direct main" description: @@ -2192,26 +2192,26 @@ packages: dependency: "direct dev" description: name: test - sha256: a1f7595805820fcc05e5c52e3a231aedd0b72972cb333e8c738a8b1239448b6f + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" url: "https://pub.dev" source: hosted - version: "1.24.9" + version: "1.25.2" test_api: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" test_core: dependency: transitive description: name: test_core - sha256: a757b14fc47507060a162cc2530d9a4a2f92f5100a952c7443b5cad5ef5b106a + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" url: "https://pub.dev" source: hosted - version: "0.5.9" + version: "0.6.0" timezone: dependency: transitive description: @@ -2441,10 +2441,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" volume_controller: dependency: transitive description: @@ -2591,4 +2591,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.3.0 <4.0.0" - flutter: ">=3.19.0" + flutter: ">=3.20.0-1.2.pre" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f864ef705..538898cf9 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -21,7 +21,7 @@ environment: dependencies: adaptive_theme: ^3.1.0 animate_do: ^2.0.0 - animated_list_plus: ^0.4.5 + animated_list_plus: ^0.5.2 archive: ^3.1.2 background_fetch: ^1.2.1 battery_info: ^1.1.1 @@ -93,13 +93,13 @@ dependencies: fluttertoast: ^8.0.6 freezed_annotation: ^2.4.1 google_nav_bar: ^5.0.5 - home_widget: ^0.5.0 + home_widget: ^0.6.0 html_unescape: ^2.0.0 http: ^1.1.0 image: ^4.0.17 image_editor: ^1.3.0 in_app_purchase: ^3.0.7 - intl: ^0.18.0 + intl: ^0.19.0 json_annotation: ^4.8.0 latlong2: ^0.9.0 like_button: ^2.0.5 @@ -152,9 +152,9 @@ dependencies: sqlite3_flutter_libs: ^0.5.20 sqlite_async: ^0.6.1 step_progress_indicator: ^1.0.2 - styled_text: ^7.0.0 - syncfusion_flutter_core: ^19.2.49 - syncfusion_flutter_sliders: ^19.2.49 + styled_text: ^8.1.0 + syncfusion_flutter_core: ^25.2.5 + syncfusion_flutter_sliders: ^25.2.5 synchronized: ^3.1.0 tuple: ^2.0.0 uni_links: ^0.5.1 @@ -177,6 +177,7 @@ dependency_overrides: # Remove this after removing dependency from flutter_sodium. # Newer flutter packages depends on ffi > 2.0.0 while flutter_sodium depends on ffi < 2.0.0 ffi: 2.1.0 + intl: 0.18.1 video_player: git: url: https://github.com/ente-io/packages.git diff --git a/web/apps/auth/src/components/AuthFooter.tsx b/web/apps/auth/src/components/AuthFooter.tsx deleted file mode 100644 index 029103125..000000000 --- a/web/apps/auth/src/components/AuthFooter.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Button } from "@mui/material"; -import { t } from "i18next"; - -export const AuthFooter = () => { - return ( -
-

{t("AUTH_DOWNLOAD_MOBILE_APP")}

- - - -
- ); -}; diff --git a/web/apps/auth/src/components/Navbar.tsx b/web/apps/auth/src/components/Navbar.tsx deleted file mode 100644 index 87614d643..000000000 --- a/web/apps/auth/src/components/Navbar.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { HorizontalFlex } from "@ente/shared/components/Container"; -import { EnteLogo } from "@ente/shared/components/EnteLogo"; -import NavbarBase from "@ente/shared/components/Navbar/base"; -import OverflowMenu from "@ente/shared/components/OverflowMenu/menu"; -import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option"; -import LogoutOutlined from "@mui/icons-material/LogoutOutlined"; -import MoreHoriz from "@mui/icons-material/MoreHoriz"; -import { t } from "i18next"; -import { AppContext } from "pages/_app"; -import React from "react"; - -export default function AuthNavbar() { - const { isMobile, logout } = React.useContext(AppContext); - return ( - - - - - - } - > - } - onClick={logout} - > - {t("LOGOUT")} - - - - - ); -} diff --git a/web/apps/auth/src/components/OTPDisplay.tsx b/web/apps/auth/src/components/OTPDisplay.tsx deleted file mode 100644 index 38de665aa..000000000 --- a/web/apps/auth/src/components/OTPDisplay.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { ButtonBase, Snackbar } from "@mui/material"; -import { t } from "i18next"; -import { HOTP, TOTP } from "otpauth"; -import { useEffect, useState } from "react"; -import { Code } from "types/code"; -import TimerProgress from "./TimerProgress"; - -const TOTPDisplay = ({ issuer, account, code, nextCode, period }) => { - return ( -
- -
-
-

- {issuer} -

-

- {account} -

-

- {code} -

-
-
-
-

- {t("AUTH_NEXT")} -

-

- {nextCode} -

-
-
-
- ); -}; - -function BadCodeInfo({ codeInfo, codeErr }) { - const [showRawData, setShowRawData] = useState(false); - - return ( -
-
{codeInfo.title}
-
{codeErr}
-
- {showRawData ? ( -
setShowRawData(false)}> - {codeInfo.rawData ?? "no raw data"} -
- ) : ( -
setShowRawData(true)}>Show rawData
- )} -
-
- ); -} - -interface OTPDisplayProps { - codeInfo: Code; -} - -const OTPDisplay = (props: OTPDisplayProps) => { - const { codeInfo } = props; - const [code, setCode] = useState(""); - const [nextCode, setNextCode] = useState(""); - const [codeErr, setCodeErr] = useState(""); - const [hasCopied, setHasCopied] = useState(false); - - const generateCodes = () => { - try { - const currentTime = new Date().getTime(); - if (codeInfo.type.toLowerCase() === "totp") { - const totp = new TOTP({ - secret: codeInfo.secret, - algorithm: codeInfo.algorithm ?? Code.defaultAlgo, - period: codeInfo.period ?? Code.defaultPeriod, - digits: codeInfo.digits ?? Code.defaultDigits, - }); - setCode(totp.generate()); - setNextCode( - totp.generate({ - timestamp: currentTime + codeInfo.period * 1000, - }), - ); - } else if (codeInfo.type.toLowerCase() === "hotp") { - const hotp = new HOTP({ - secret: codeInfo.secret, - counter: 0, - algorithm: codeInfo.algorithm, - }); - setCode(hotp.generate()); - setNextCode(hotp.generate({ counter: 1 })); - } - } catch (err) { - setCodeErr(err.message); - } - }; - - const copyCode = () => { - navigator.clipboard.writeText(code); - setHasCopied(true); - setTimeout(() => { - setHasCopied(false); - }, 2000); - }; - - useEffect(() => { - // this is to set the initial code and nextCode on component mount - generateCodes(); - const codeType = codeInfo.type; - const codePeriodInMs = codeInfo.period * 1000; - const timeToNextCode = - codePeriodInMs - (new Date().getTime() % codePeriodInMs); - const intervalId = null; - // wait until we are at the start of the next code period, - // and then start the interval loop - setTimeout(() => { - // we need to call generateCodes() once before the interval loop - // to set the initial code and nextCode - generateCodes(); - codeType.toLowerCase() === "totp" || - codeType.toLowerCase() === "hotp" - ? setInterval(() => { - generateCodes(); - }, codePeriodInMs) - : null; - }, timeToNextCode); - - return () => { - if (intervalId) clearInterval(intervalId); - }; - }, [codeInfo]); - - return ( -
- {codeErr === "" ? ( - { - copyCode(); - }} - > - - - - ) : ( - - )} -
- ); -}; - -export default OTPDisplay; diff --git a/web/apps/auth/src/components/TimerProgress.tsx b/web/apps/auth/src/components/TimerProgress.tsx deleted file mode 100644 index d1f3726f6..000000000 --- a/web/apps/auth/src/components/TimerProgress.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useEffect, useState } from "react"; - -const TimerProgress = ({ period }) => { - const [progress, setProgress] = useState(0); - const [ticker, setTicker] = useState(null); - const microSecondsInPeriod = period * 1000000; - - const startTicker = () => { - const ticker = setInterval(() => { - updateTimeRemaining(); - }, 10); - setTicker(ticker); - }; - - const updateTimeRemaining = () => { - const timeRemaining = - microSecondsInPeriod - - ((new Date().getTime() * 1000) % microSecondsInPeriod); - setProgress(timeRemaining / microSecondsInPeriod); - }; - - useEffect(() => { - startTicker(); - return () => clearInterval(ticker); - }, []); - - const color = progress > 0.4 ? "green" : "orange"; - - return ( -
- ); -}; - -export default TimerProgress; diff --git a/web/apps/auth/src/pages/auth.tsx b/web/apps/auth/src/pages/auth.tsx new file mode 100644 index 000000000..e628050ea --- /dev/null +++ b/web/apps/auth/src/pages/auth.tsx @@ -0,0 +1,449 @@ +import { + HorizontalFlex, + VerticallyCentered, +} from "@ente/shared/components/Container"; +import { EnteLogo } from "@ente/shared/components/EnteLogo"; +import EnteSpinner from "@ente/shared/components/EnteSpinner"; +import NavbarBase from "@ente/shared/components/Navbar/base"; +import OverflowMenu from "@ente/shared/components/OverflowMenu/menu"; +import { OverflowMenuOption } from "@ente/shared/components/OverflowMenu/option"; +import { AUTH_PAGES as PAGES } from "@ente/shared/constants/pages"; +import { CustomError } from "@ente/shared/error"; +import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore"; +import LogoutOutlined from "@mui/icons-material/LogoutOutlined"; +import MoreHoriz from "@mui/icons-material/MoreHoriz"; +import { Button, ButtonBase, Snackbar, TextField } from "@mui/material"; +import { t } from "i18next"; +import { useRouter } from "next/router"; +import { HOTP, TOTP } from "otpauth"; +import { AppContext } from "pages/_app"; +import React, { useContext, useEffect, useState } from "react"; +import { Code } from "services/code"; +import { getAuthCodes } from "services/remote"; + +const AuthenticatorCodesPage = () => { + const appContext = useContext(AppContext); + const router = useRouter(); + const [codes, setCodes] = useState([]); + const [hasFetched, setHasFetched] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + + useEffect(() => { + const fetchCodes = async () => { + try { + const res = await getAuthCodes(); + setCodes(res); + } catch (err) { + if (err.message === CustomError.KEY_MISSING) { + InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.AUTH); + router.push(PAGES.ROOT); + } else { + // do not log errors + } + } + setHasFetched(true); + }; + fetchCodes(); + appContext.showNavBar(false); + }, []); + + const filteredCodes = codes.filter( + (secret) => + (secret.issuer ?? "") + .toLowerCase() + .includes(searchTerm.toLowerCase()) || + (secret.account ?? "") + .toLowerCase() + .includes(searchTerm.toLowerCase()), + ); + + if (!hasFetched) { + return ( + <> + + + + + ); + } + + return ( + <> + +
+
+ {filteredCodes.length === 0 && searchTerm.length === 0 ? ( + <> + ) : ( + setSearchTerm(e.target.value)} + variant="filled" + style={{ width: "350px" }} + value={searchTerm} + autoFocus + /> + )} + +
+
+ {filteredCodes.length === 0 ? ( +
+ {searchTerm.length !== 0 ? ( +

{t("NO_RESULTS")}

+ ) : ( +
+ )} +
+ ) : ( + filteredCodes.map((code) => ( + + )) + )} +
+
+ +
+
+ + ); +}; + +export default AuthenticatorCodesPage; + +const AuthNavbar: React.FC = () => { + const { isMobile, logout } = useContext(AppContext); + + return ( + + + + + + } + > + } + onClick={logout} + > + {t("LOGOUT")} + + + + + ); +}; + +interface CodeDisplay { + codeInfo: Code; +} + +const CodeDisplay: React.FC = ({ codeInfo }) => { + const [otp, setOTP] = useState(""); + const [nextOTP, setNextOTP] = useState(""); + const [codeErr, setCodeErr] = useState(""); + const [hasCopied, setHasCopied] = useState(false); + + const generateCodes = () => { + try { + const currentTime = new Date().getTime(); + if (codeInfo.type === "totp") { + const totp = new TOTP({ + secret: codeInfo.secret, + algorithm: codeInfo.algorithm, + period: codeInfo.period, + digits: codeInfo.digits, + }); + setOTP(totp.generate()); + setNextOTP( + totp.generate({ + timestamp: currentTime + codeInfo.period * 1000, + }), + ); + } else if (codeInfo.type === "hotp") { + const hotp = new HOTP({ + secret: codeInfo.secret, + counter: 0, + algorithm: codeInfo.algorithm, + }); + setOTP(hotp.generate()); + setNextOTP(hotp.generate({ counter: 1 })); + } + } catch (err) { + setCodeErr(err.message); + } + }; + + const copyCode = () => { + navigator.clipboard.writeText(otp); + setHasCopied(true); + setTimeout(() => { + setHasCopied(false); + }, 2000); + }; + + useEffect(() => { + // this is to set the initial code and nextCode on component mount + generateCodes(); + const codeType = codeInfo.type; + const codePeriodInMs = codeInfo.period * 1000; + const timeToNextCode = + codePeriodInMs - (new Date().getTime() % codePeriodInMs); + const intervalId = null; + // wait until we are at the start of the next code period, + // and then start the interval loop + setTimeout(() => { + // we need to call generateCodes() once before the interval loop + // to set the initial code and nextCode + generateCodes(); + codeType.toLowerCase() === "totp" || + codeType.toLowerCase() === "hotp" + ? setInterval(() => { + generateCodes(); + }, codePeriodInMs) + : null; + }, timeToNextCode); + + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [codeInfo]); + + return ( +
+ {codeErr === "" ? ( + { + copyCode(); + }} + > + + + + ) : ( + + )} +
+ ); +}; + +interface OTPDisplayProps { + code: Code; + otp: string; + nextOTP: string; +} + +const OTPDisplay: React.FC = ({ code, otp, nextOTP }) => { + return ( +
+ +
+
+

+ {code.issuer} +

+

+ {code.account} +

+

+ {otp} +

+
+
+
+

+ {t("AUTH_NEXT")} +

+

+ {nextOTP} +

+
+
+
+ ); +}; + +interface TimerProgressProps { + period: number; +} + +const TimerProgress: React.FC = ({ period }) => { + const [progress, setProgress] = useState(0); + const microSecondsInPeriod = period * 1000000; + + useEffect(() => { + const updateTimeRemaining = () => { + const timeRemaining = + microSecondsInPeriod - + ((new Date().getTime() * 1000) % microSecondsInPeriod); + setProgress(timeRemaining / microSecondsInPeriod); + }; + + const ticker = setInterval(() => { + updateTimeRemaining(); + }, 10); + + return () => clearInterval(ticker); + }, []); + + const color = progress > 0.4 ? "green" : "orange"; + + return ( +
+ ); +}; + +function BadCodeInfo({ codeInfo, codeErr }) { + const [showRawData, setShowRawData] = useState(false); + + return ( +
+
{codeInfo.title}
+
{codeErr}
+
+ {showRawData ? ( +
setShowRawData(false)}> + {codeInfo.uriString ?? "(no raw data)"} +
+ ) : ( +
setShowRawData(true)}>Show rawData
+ )} +
+
+ ); +} + +const AuthFooter: React.FC = () => { + return ( +
+

{t("AUTH_DOWNLOAD_MOBILE_APP")}

+ + + +
+ ); +}; diff --git a/web/apps/auth/src/pages/auth/index.tsx b/web/apps/auth/src/pages/auth/index.tsx deleted file mode 100644 index 55dc33ce6..000000000 --- a/web/apps/auth/src/pages/auth/index.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { VerticallyCentered } from "@ente/shared/components/Container"; -import EnteSpinner from "@ente/shared/components/EnteSpinner"; -import { AUTH_PAGES as PAGES } from "@ente/shared/constants/pages"; -import { CustomError } from "@ente/shared/error"; -import InMemoryStore, { MS_KEYS } from "@ente/shared/storage/InMemoryStore"; -import { TextField } from "@mui/material"; -import { AuthFooter } from "components/AuthFooter"; -import AuthNavbar from "components/Navbar"; -import OTPDisplay from "components/OTPDisplay"; -import { t } from "i18next"; -import { useRouter } from "next/router"; -import { AppContext } from "pages/_app"; -import { useContext, useEffect, useState } from "react"; -import { getAuthCodes } from "services"; - -const AuthenticatorCodesPage = () => { - const appContext = useContext(AppContext); - const router = useRouter(); - const [codes, setCodes] = useState([]); - const [hasFetched, setHasFetched] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - - useEffect(() => { - const fetchCodes = async () => { - try { - const res = await getAuthCodes(); - setCodes(res); - } catch (err) { - if (err.message === CustomError.KEY_MISSING) { - InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.AUTH); - router.push(PAGES.ROOT); - } else { - // do not log errors - } - } - setHasFetched(true); - }; - fetchCodes(); - appContext.showNavBar(false); - }, []); - - const filteredCodes = codes.filter( - (secret) => - (secret.issuer ?? "") - .toLowerCase() - .includes(searchTerm.toLowerCase()) || - (secret.account ?? "") - .toLowerCase() - .includes(searchTerm.toLowerCase()), - ); - - if (!hasFetched) { - return ( - <> - - - - - ); - } - - return ( - <> - -
-
- {filteredCodes.length === 0 && searchTerm.length === 0 ? ( - <> - ) : ( - setSearchTerm(e.target.value)} - variant="filled" - style={{ width: "350px" }} - value={searchTerm} - autoFocus - /> - )} - -
-
- {filteredCodes.length === 0 ? ( -
- {searchTerm.length !== 0 ? ( -

{t("NO_RESULTS")}

- ) : ( -
- )} -
- ) : ( - filteredCodes.map((code) => ( - - )) - )} -
-
- -
-
- - ); -}; - -export default AuthenticatorCodesPage; diff --git a/web/apps/auth/src/pages/change-email/index.tsx b/web/apps/auth/src/pages/change-email.tsx similarity index 100% rename from web/apps/auth/src/pages/change-email/index.tsx rename to web/apps/auth/src/pages/change-email.tsx diff --git a/web/apps/auth/src/pages/change-password/index.tsx b/web/apps/auth/src/pages/change-password.tsx similarity index 100% rename from web/apps/auth/src/pages/change-password/index.tsx rename to web/apps/auth/src/pages/change-password.tsx diff --git a/web/apps/auth/src/pages/credentials/index.tsx b/web/apps/auth/src/pages/credentials.tsx similarity index 100% rename from web/apps/auth/src/pages/credentials/index.tsx rename to web/apps/auth/src/pages/credentials.tsx diff --git a/web/apps/auth/src/pages/generate/index.tsx b/web/apps/auth/src/pages/generate.tsx similarity index 100% rename from web/apps/auth/src/pages/generate/index.tsx rename to web/apps/auth/src/pages/generate.tsx diff --git a/web/apps/auth/src/pages/login/index.tsx b/web/apps/auth/src/pages/login.tsx similarity index 100% rename from web/apps/auth/src/pages/login/index.tsx rename to web/apps/auth/src/pages/login.tsx diff --git a/web/apps/auth/src/pages/passkeys/finish/index.tsx b/web/apps/auth/src/pages/passkeys/finish.tsx similarity index 100% rename from web/apps/auth/src/pages/passkeys/finish/index.tsx rename to web/apps/auth/src/pages/passkeys/finish.tsx diff --git a/web/apps/auth/src/pages/recover/index.tsx b/web/apps/auth/src/pages/recover.tsx similarity index 100% rename from web/apps/auth/src/pages/recover/index.tsx rename to web/apps/auth/src/pages/recover.tsx diff --git a/web/apps/auth/src/pages/signup/index.tsx b/web/apps/auth/src/pages/signup.tsx similarity index 100% rename from web/apps/auth/src/pages/signup/index.tsx rename to web/apps/auth/src/pages/signup.tsx diff --git a/web/apps/auth/src/pages/two-factor/recover/index.tsx b/web/apps/auth/src/pages/two-factor/recover.tsx similarity index 100% rename from web/apps/auth/src/pages/two-factor/recover/index.tsx rename to web/apps/auth/src/pages/two-factor/recover.tsx diff --git a/web/apps/auth/src/pages/two-factor/setup/index.tsx b/web/apps/auth/src/pages/two-factor/setup.tsx similarity index 100% rename from web/apps/auth/src/pages/two-factor/setup/index.tsx rename to web/apps/auth/src/pages/two-factor/setup.tsx diff --git a/web/apps/auth/src/pages/two-factor/verify/index.tsx b/web/apps/auth/src/pages/two-factor/verify.tsx similarity index 89% rename from web/apps/auth/src/pages/two-factor/verify/index.tsx rename to web/apps/auth/src/pages/two-factor/verify.tsx index 2243a4354..85eb7ff1b 100644 --- a/web/apps/auth/src/pages/two-factor/verify/index.tsx +++ b/web/apps/auth/src/pages/two-factor/verify.tsx @@ -1,7 +1,7 @@ import TwoFactorVerifyPage from "@ente/accounts/pages/two-factor/verify"; import { APPS } from "@ente/shared/apps/constants"; import { useContext } from "react"; -import { AppContext } from "../../_app"; +import { AppContext } from "../_app"; export default function TwoFactorVerify() { const appContext = useContext(AppContext); diff --git a/web/apps/auth/src/pages/verify/index.tsx b/web/apps/auth/src/pages/verify.tsx similarity index 100% rename from web/apps/auth/src/pages/verify/index.tsx rename to web/apps/auth/src/pages/verify.tsx diff --git a/web/apps/auth/src/services/code.ts b/web/apps/auth/src/services/code.ts new file mode 100644 index 000000000..ca9ba1642 --- /dev/null +++ b/web/apps/auth/src/services/code.ts @@ -0,0 +1,154 @@ +import { URI } from "vscode-uri"; + +/** + * A parsed representation of an xOTP code URI. + * + * This is all the data we need to drive a OTP generator. + */ +export interface Code { + /** The uniquue id for the corresponding auth entity. */ + id?: String; + /** The type of the code. */ + type: "totp" | "hotp"; + /** The user's account or email for which this code is used. */ + account: string; + /** The name of the entity that issued this code. */ + issuer: string; + /** Number of digits in the code. */ + digits: number; + /** + * The time period (in seconds) for which a single OTP generated from this + * code remains valid. + */ + period: number; + /** The secret that is used to drive the OTP generator. */ + secret: string; + /** The (hashing) algorithim used by the OTP generator. */ + algorithm: "sha1" | "sha256" | "sha512"; + /** The original string from which this code was generated. */ + uriString?: string; +} + +/** + * Convert a OTP code URI into its parse representation, a {@link Code}. + * + * @param id A unique ID of this code within the auth app. + * + * @param uriString A string specifying how to generate a TOTP/HOTP/Steam OTP + * code. These strings are of the form: + * + * - (TOTP) + * otpauth://totp/account:user@example.org?algorithm=SHA1&digits=6&issuer=issuer&period=30&secret=ALPHANUM + */ +export const codeFromURIString = (id: string, uriString: string): Code => { + let santizedRawData = uriString + .replace(/\+/g, "%2B") + .replace(/:/g, "%3A") + .replaceAll("\r", ""); + if (santizedRawData.startsWith('"')) { + santizedRawData = santizedRawData.substring(1); + } + if (santizedRawData.endsWith('"')) { + santizedRawData = santizedRawData.substring( + 0, + santizedRawData.length - 1, + ); + } + + const uriParams = {}; + const searchParamsString = + decodeURIComponent(santizedRawData).split("?")[1]; + searchParamsString.split("&").forEach((pair) => { + const [key, value] = pair.split("="); + uriParams[key] = value; + }); + + const uri = URI.parse(santizedRawData); + let uriPath = decodeURIComponent(uri.path); + if (uriPath.startsWith("/otpauth://") || uriPath.startsWith("otpauth://")) { + uriPath = uriPath.split("otpauth://")[1]; + } else if (uriPath.startsWith("otpauth%3A//")) { + uriPath = uriPath.split("otpauth%3A//")[1]; + } + + return { + id, + type: _getType(uriPath), + account: _getAccount(uriPath), + issuer: _getIssuer(uriPath, uriParams), + digits: parseDigits(uriParams), + period: parsePeriod(uriParams), + secret: getSanitizedSecret(uriParams), + algorithm: parseAlgorithm(uriParams), + uriString, + }; +}; + +const _getAccount = (uriPath: string): string => { + try { + const path = decodeURIComponent(uriPath); + if (path.includes(":")) { + return path.split(":")[1]; + } else if (path.includes("/")) { + return path.split("/")[1]; + } + } catch (e) { + return ""; + } +}; + +const _getIssuer = (uriPath: string, uriParams: { get?: any }): string => { + try { + if (uriParams["issuer"] !== undefined) { + let issuer = uriParams["issuer"]; + // This is to handle bug in the ente auth app + if (issuer.endsWith("period")) { + issuer = issuer.substring(0, issuer.length - 6); + } + return issuer; + } + let path = decodeURIComponent(uriPath); + if (path.startsWith("totp/") || path.startsWith("hotp/")) { + path = path.substring(5); + } + if (path.includes(":")) { + return path.split(":")[0]; + } else if (path.includes("-")) { + return path.split("-")[0]; + } + return path; + } catch (e) { + return ""; + } +}; + +const parseDigits = (uriParams): number => + parseInt(uriParams["digits"] ?? "", 10) || 6; + +const parsePeriod = (uriParams): number => + parseInt(uriParams["period"] ?? "", 10) || 30; + +const parseAlgorithm = (uriParams): Code["algorithm"] => { + switch (uriParams["algorithm"]?.toLowerCase()) { + case "sha256": + return "sha256"; + case "sha512": + return "sha512"; + default: + return "sha1"; + } +}; + +const _getType = (uriPath: string): Code["type"] => { + const oauthType = uriPath.split("/")[0].substring(0); + if (oauthType.toLowerCase() === "totp") { + return "totp"; + } else if (oauthType.toLowerCase() === "hotp") { + return "hotp"; + } + throw new Error(`Unsupported format with host ${oauthType}`); +}; + +const getSanitizedSecret = (uriParams): string => { + return uriParams["secret"].replace(/ /g, "").toUpperCase(); +}; diff --git a/web/apps/auth/src/services/index.ts b/web/apps/auth/src/services/remote.ts similarity index 90% rename from web/apps/auth/src/services/index.ts rename to web/apps/auth/src/services/remote.ts index 5fd032215..07b15d7d7 100644 --- a/web/apps/auth/src/services/index.ts +++ b/web/apps/auth/src/services/remote.ts @@ -6,10 +6,10 @@ import { getEndpoint } from "@ente/shared/network/api"; import { getToken } from "@ente/shared/storage/localStorage/helpers"; import { getActualKey } from "@ente/shared/user"; import { HttpStatusCode } from "axios"; -import { AuthEntity, AuthKey } from "types/api"; -import { Code } from "types/code"; +import { codeFromURIString, type Code } from "services/code"; const ENDPOINT = getEndpoint(); + export const getAuthCodes = async (): Promise => { const masterKey = await getActualKey(); try { @@ -33,7 +33,7 @@ export const getAuthCodes = async (): Promise => { entity.header, authenticatorKey, ); - return Code.fromRawData(entity.id, decryptedCode); + return codeFromURIString(entity.id, decryptedCode); } catch (e) { log.error(`failed to parse codeId = ${entity.id}`); return null; @@ -65,6 +65,20 @@ export const getAuthCodes = async (): Promise => { } }; +interface AuthEntity { + id: string; + encryptedData: string | null; + header: string | null; + isDeleted: boolean; + createdAt: number; + updatedAt: number; +} + +interface AuthKey { + encryptedKey: string; + header: string; +} + export const getAuthKey = async (): Promise => { try { const resp = await HTTPService.get( diff --git a/web/apps/auth/src/types/api.ts b/web/apps/auth/src/types/api.ts deleted file mode 100644 index 569df8185..000000000 --- a/web/apps/auth/src/types/api.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface AuthEntity { - id: string; - encryptedData: string | null; - header: string | null; - isDeleted: boolean; - createdAt: number; - updatedAt: number; -} - -export interface AuthKey { - encryptedKey: string; - header: string; -} diff --git a/web/apps/auth/src/types/code.ts b/web/apps/auth/src/types/code.ts deleted file mode 100644 index d61a2dcd6..000000000 --- a/web/apps/auth/src/types/code.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { URI } from "vscode-uri"; - -type Type = "totp" | "TOTP" | "hotp" | "HOTP"; - -type AlgorithmType = - | "sha1" - | "SHA1" - | "sha256" - | "SHA256" - | "sha512" - | "SHA512"; - -export class Code { - static readonly defaultDigits = 6; - static readonly defaultAlgo = "sha1"; - static readonly defaultPeriod = 30; - - // id for the corresponding auth entity - id?: String; - account: string; - issuer: string; - digits?: number; - period: number; - secret: string; - algorithm: AlgorithmType; - type: Type; - rawData?: string; - - constructor( - account: string, - issuer: string, - digits: number | undefined, - period: number, - secret: string, - algorithm: AlgorithmType, - type: Type, - rawData?: string, - id?: string, - ) { - this.account = account; - this.issuer = issuer; - this.digits = digits; - this.period = period; - this.secret = secret; - this.algorithm = algorithm; - this.type = type; - this.rawData = rawData; - this.id = id; - } - - static fromRawData(id: string, rawData: string): Code { - let santizedRawData = rawData - .replace(/\+/g, "%2B") - .replace(/:/g, "%3A") - .replaceAll("\r", ""); - if (santizedRawData.startsWith('"')) { - santizedRawData = santizedRawData.substring(1); - } - if (santizedRawData.endsWith('"')) { - santizedRawData = santizedRawData.substring( - 0, - santizedRawData.length - 1, - ); - } - - const uriParams = {}; - const searchParamsString = - decodeURIComponent(santizedRawData).split("?")[1]; - searchParamsString.split("&").forEach((pair) => { - const [key, value] = pair.split("="); - uriParams[key] = value; - }); - - const uri = URI.parse(santizedRawData); - let uriPath = decodeURIComponent(uri.path); - if ( - uriPath.startsWith("/otpauth://") || - uriPath.startsWith("otpauth://") - ) { - uriPath = uriPath.split("otpauth://")[1]; - } else if (uriPath.startsWith("otpauth%3A//")) { - uriPath = uriPath.split("otpauth%3A//")[1]; - } - - return new Code( - Code._getAccount(uriPath), - Code._getIssuer(uriPath, uriParams), - Code._getDigits(uriParams), - Code._getPeriod(uriParams), - Code.getSanitizedSecret(uriParams), - Code._getAlgorithm(uriParams), - Code._getType(uriPath), - rawData, - id, - ); - } - - private static _getAccount(uriPath: string): string { - try { - const path = decodeURIComponent(uriPath); - if (path.includes(":")) { - return path.split(":")[1]; - } else if (path.includes("/")) { - return path.split("/")[1]; - } - } catch (e) { - return ""; - } - } - - private static _getIssuer( - uriPath: string, - uriParams: { get?: any }, - ): string { - try { - if (uriParams["issuer"] !== undefined) { - let issuer = uriParams["issuer"]; - // This is to handle bug in the ente auth app - if (issuer.endsWith("period")) { - issuer = issuer.substring(0, issuer.length - 6); - } - return issuer; - } - let path = decodeURIComponent(uriPath); - if (path.startsWith("totp/") || path.startsWith("hotp/")) { - path = path.substring(5); - } - if (path.includes(":")) { - return path.split(":")[0]; - } else if (path.includes("-")) { - return path.split("-")[0]; - } - return path; - } catch (e) { - return ""; - } - } - - private static _getDigits(uriParams): number { - try { - return parseInt(uriParams["digits"], 10) || Code.defaultDigits; - } catch (e) { - return Code.defaultDigits; - } - } - - private static _getPeriod(uriParams): number { - try { - return parseInt(uriParams["period"], 10) || Code.defaultPeriod; - } catch (e) { - return Code.defaultPeriod; - } - } - - private static _getAlgorithm(uriParams): AlgorithmType { - try { - const algorithm = uriParams["algorithm"].toLowerCase(); - if (algorithm === "sha256") { - return algorithm; - } else if (algorithm === "sha512") { - return algorithm; - } - } catch (e) { - // nothing - } - return "sha1"; - } - - private static _getType(uriPath: string): Type { - const oauthType = uriPath.split("/")[0].substring(0); - if (oauthType.toLowerCase() === "totp") { - return "totp"; - } else if (oauthType.toLowerCase() === "hotp") { - return "hotp"; - } - throw new Error(`Unsupported format with host ${oauthType}`); - } - - static getSanitizedSecret(uriParams): string { - return uriParams["secret"].replace(/ /g, "").toUpperCase(); - } -}