Merge branch 'main' into face_fix
This commit is contained in:
commit
1f9e222d6e
29 changed files with 680 additions and 715 deletions
2
.github/workflows/mobile-lint.yml
vendored
2
.github/workflows/mobile-lint.yml
vendored
|
@ -9,7 +9,7 @@ on:
|
|||
- ".github/workflows/mobile-lint.yml"
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.19.5"
|
||||
FLUTTER_VERSION: "3.22.0"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
|
|
@ -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}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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`
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
import { Button } from "@mui/material";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const AuthFooter = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<p>{t("AUTH_DOWNLOAD_MOBILE_APP")}</p>
|
||||
<a
|
||||
href="https://github.com/ente-io/ente/tree/main/auth#-download"
|
||||
download
|
||||
>
|
||||
<Button color="accent">{t("DOWNLOAD")}</Button>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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 (
|
||||
<NavbarBase isMobile={isMobile}>
|
||||
<HorizontalFlex flex={1} justifyContent={"center"}>
|
||||
<EnteLogo />
|
||||
</HorizontalFlex>
|
||||
<HorizontalFlex position={"absolute"} right="24px">
|
||||
<OverflowMenu
|
||||
ariaControls={"auth-options"}
|
||||
triggerButtonIcon={<MoreHoriz />}
|
||||
>
|
||||
<OverflowMenuOption
|
||||
color="critical"
|
||||
startIcon={<LogoutOutlined />}
|
||||
onClick={logout}
|
||||
>
|
||||
{t("LOGOUT")}
|
||||
</OverflowMenuOption>
|
||||
</OverflowMenu>
|
||||
</HorizontalFlex>
|
||||
</NavbarBase>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "rgba(40, 40, 40, 0.6)",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<TimerProgress period={period ?? Code.defaultPeriod} />
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 20px 0px 20px",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
minWidth: "320px",
|
||||
minHeight: "120px",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
minWidth: "200px",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
margin: "0px",
|
||||
fontSize: "14px",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{issuer}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
marginTop: "0px",
|
||||
marginBottom: "8px",
|
||||
textAlign: "left",
|
||||
fontSize: "12px",
|
||||
maxWidth: "200px",
|
||||
minHeight: "16px",
|
||||
color: "grey",
|
||||
}}
|
||||
>
|
||||
{account}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: "0px",
|
||||
marginBottom: "1rem",
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
minWidth: "120px",
|
||||
textAlign: "right",
|
||||
marginTop: "auto",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "0px",
|
||||
fontSize: "10px",
|
||||
marginTop: "auto",
|
||||
textAlign: "right",
|
||||
color: "grey",
|
||||
}}
|
||||
>
|
||||
{t("AUTH_NEXT")}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "0px",
|
||||
marginTop: "auto",
|
||||
textAlign: "right",
|
||||
color: "grey",
|
||||
}}
|
||||
>
|
||||
{nextCode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function BadCodeInfo({ codeInfo, codeErr }) {
|
||||
const [showRawData, setShowRawData] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="code-info">
|
||||
<div>{codeInfo.title}</div>
|
||||
<div>{codeErr}</div>
|
||||
<div>
|
||||
{showRawData ? (
|
||||
<div onClick={() => setShowRawData(false)}>
|
||||
{codeInfo.rawData ?? "no raw data"}
|
||||
</div>
|
||||
) : (
|
||||
<div onClick={() => setShowRawData(true)}>Show rawData</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ padding: "8px" }}>
|
||||
{codeErr === "" ? (
|
||||
<ButtonBase
|
||||
component="div"
|
||||
onClick={() => {
|
||||
copyCode();
|
||||
}}
|
||||
>
|
||||
<TOTPDisplay
|
||||
period={codeInfo.period}
|
||||
issuer={codeInfo.issuer}
|
||||
account={codeInfo.account}
|
||||
code={code}
|
||||
nextCode={nextCode}
|
||||
/>
|
||||
<Snackbar
|
||||
open={hasCopied}
|
||||
message="Code copied to clipboard"
|
||||
/>
|
||||
</ButtonBase>
|
||||
) : (
|
||||
<BadCodeInfo codeInfo={codeInfo} codeErr={codeErr} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OTPDisplay;
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
borderTopLeftRadius: "3px",
|
||||
width: `${progress * 100}%`,
|
||||
height: "3px",
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimerProgress;
|
449
web/apps/auth/src/pages/auth.tsx
Normal file
449
web/apps/auth/src/pages/auth.tsx
Normal file
|
@ -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 (
|
||||
<>
|
||||
<VerticallyCentered>
|
||||
<EnteSpinner></EnteSpinner>
|
||||
</VerticallyCentered>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthNavbar />
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "800px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "1rem" }} />
|
||||
{filteredCodes.length === 0 && searchTerm.length === 0 ? (
|
||||
<></>
|
||||
) : (
|
||||
<TextField
|
||||
id="search"
|
||||
name="search"
|
||||
label={t("SEARCH")}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
variant="filled"
|
||||
style={{ width: "350px" }}
|
||||
value={searchTerm}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: "1rem" }} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{filteredCodes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
textAlign: "center",
|
||||
marginTop: "32px",
|
||||
}}
|
||||
>
|
||||
{searchTerm.length !== 0 ? (
|
||||
<p>{t("NO_RESULTS")}</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredCodes.map((code) => (
|
||||
<CodeDisplay codeInfo={code} key={code.id} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginBottom: "2rem" }} />
|
||||
<AuthFooter />
|
||||
<div style={{ marginBottom: "4rem" }} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticatorCodesPage;
|
||||
|
||||
const AuthNavbar: React.FC = () => {
|
||||
const { isMobile, logout } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<NavbarBase isMobile={isMobile}>
|
||||
<HorizontalFlex flex={1} justifyContent={"center"}>
|
||||
<EnteLogo />
|
||||
</HorizontalFlex>
|
||||
<HorizontalFlex position={"absolute"} right="24px">
|
||||
<OverflowMenu
|
||||
ariaControls={"auth-options"}
|
||||
triggerButtonIcon={<MoreHoriz />}
|
||||
>
|
||||
<OverflowMenuOption
|
||||
color="critical"
|
||||
startIcon={<LogoutOutlined />}
|
||||
onClick={logout}
|
||||
>
|
||||
{t("LOGOUT")}
|
||||
</OverflowMenuOption>
|
||||
</OverflowMenu>
|
||||
</HorizontalFlex>
|
||||
</NavbarBase>
|
||||
);
|
||||
};
|
||||
|
||||
interface CodeDisplay {
|
||||
codeInfo: Code;
|
||||
}
|
||||
|
||||
const CodeDisplay: React.FC<CodeDisplay> = ({ 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 (
|
||||
<div style={{ padding: "8px" }}>
|
||||
{codeErr === "" ? (
|
||||
<ButtonBase
|
||||
component="div"
|
||||
onClick={() => {
|
||||
copyCode();
|
||||
}}
|
||||
>
|
||||
<OTPDisplay code={codeInfo} otp={otp} nextOTP={nextOTP} />
|
||||
<Snackbar
|
||||
open={hasCopied}
|
||||
message="Code copied to clipboard"
|
||||
/>
|
||||
</ButtonBase>
|
||||
) : (
|
||||
<BadCodeInfo codeInfo={codeInfo} codeErr={codeErr} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface OTPDisplayProps {
|
||||
code: Code;
|
||||
otp: string;
|
||||
nextOTP: string;
|
||||
}
|
||||
|
||||
const OTPDisplay: React.FC<OTPDisplayProps> = ({ code, otp, nextOTP }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "rgba(40, 40, 40, 0.6)",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<TimerProgress period={code.period} />
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 20px 0px 20px",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
minWidth: "320px",
|
||||
minHeight: "120px",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
minWidth: "200px",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
margin: "0px",
|
||||
fontSize: "14px",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{code.issuer}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
marginTop: "0px",
|
||||
marginBottom: "8px",
|
||||
textAlign: "left",
|
||||
fontSize: "12px",
|
||||
maxWidth: "200px",
|
||||
minHeight: "16px",
|
||||
color: "grey",
|
||||
}}
|
||||
>
|
||||
{code.account}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
margin: "0px",
|
||||
marginBottom: "1rem",
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{otp}
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-end",
|
||||
minWidth: "120px",
|
||||
textAlign: "right",
|
||||
marginTop: "auto",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: "bold",
|
||||
marginBottom: "0px",
|
||||
fontSize: "10px",
|
||||
marginTop: "auto",
|
||||
textAlign: "right",
|
||||
color: "grey",
|
||||
}}
|
||||
>
|
||||
{t("AUTH_NEXT")}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
marginBottom: "0px",
|
||||
marginTop: "auto",
|
||||
textAlign: "right",
|
||||
color: "grey",
|
||||
}}
|
||||
>
|
||||
{nextOTP}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TimerProgressProps {
|
||||
period: number;
|
||||
}
|
||||
|
||||
const TimerProgress: React.FC<TimerProgressProps> = ({ 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 (
|
||||
<div
|
||||
style={{
|
||||
borderTopLeftRadius: "3px",
|
||||
width: `${progress * 100}%`,
|
||||
height: "3px",
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function BadCodeInfo({ codeInfo, codeErr }) {
|
||||
const [showRawData, setShowRawData] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="code-info">
|
||||
<div>{codeInfo.title}</div>
|
||||
<div>{codeErr}</div>
|
||||
<div>
|
||||
{showRawData ? (
|
||||
<div onClick={() => setShowRawData(false)}>
|
||||
{codeInfo.uriString ?? "(no raw data)"}
|
||||
</div>
|
||||
) : (
|
||||
<div onClick={() => setShowRawData(true)}>Show rawData</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AuthFooter: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<p>{t("AUTH_DOWNLOAD_MOBILE_APP")}</p>
|
||||
<a
|
||||
href="https://github.com/ente-io/ente/tree/main/auth#-download"
|
||||
download
|
||||
>
|
||||
<Button color="accent">{t("DOWNLOAD")}</Button>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -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 (
|
||||
<>
|
||||
<VerticallyCentered>
|
||||
<EnteSpinner></EnteSpinner>
|
||||
</VerticallyCentered>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthNavbar />
|
||||
<div
|
||||
style={{
|
||||
maxWidth: "800px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "1rem" }} />
|
||||
{filteredCodes.length === 0 && searchTerm.length === 0 ? (
|
||||
<></>
|
||||
) : (
|
||||
<TextField
|
||||
id="search"
|
||||
name="search"
|
||||
label={t("SEARCH")}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
variant="filled"
|
||||
style={{ width: "350px" }}
|
||||
value={searchTerm}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: "1rem" }} />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{filteredCodes.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
textAlign: "center",
|
||||
marginTop: "32px",
|
||||
}}
|
||||
>
|
||||
{searchTerm.length !== 0 ? (
|
||||
<p>{t("NO_RESULTS")}</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredCodes.map((code) => (
|
||||
<OTPDisplay codeInfo={code} key={code.id} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginBottom: "2rem" }} />
|
||||
<AuthFooter />
|
||||
<div style={{ marginBottom: "4rem" }} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticatorCodesPage;
|
|
@ -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);
|
154
web/apps/auth/src/services/code.ts
Normal file
154
web/apps/auth/src/services/code.ts
Normal file
|
@ -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();
|
||||
};
|
|
@ -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<Code[]> => {
|
||||
const masterKey = await getActualKey();
|
||||
try {
|
||||
|
@ -33,7 +33,7 @@ export const getAuthCodes = async (): Promise<Code[]> => {
|
|||
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<Code[]> => {
|
|||
}
|
||||
};
|
||||
|
||||
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<AuthKey> => {
|
||||
try {
|
||||
const resp = await HTTPService.get(
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue