Ver Fonte

Accounts-passkeys (#1523)

Manav Rathi há 1 ano atrás
pai
commit
1c18fb8392
77 ficheiros alterados com 3170 adições e 72 exclusões
  1. 13 0
      apps/accounts/.eslintrc.js
  2. 36 0
      apps/accounts/.gitignore
  3. 40 0
      apps/accounts/README.md
  4. 11 0
      apps/accounts/next.config.js
  5. 18 0
      apps/accounts/package.json
  6. BIN
      apps/accounts/public/favicon.ico
  7. 93 0
      apps/accounts/public/fonts/OFL.txt
  8. BIN
      apps/accounts/public/fonts/inter-v11-latin-500.woff
  9. BIN
      apps/accounts/public/fonts/inter-v11-latin-500.woff2
  10. BIN
      apps/accounts/public/fonts/inter-v11-latin-600.woff
  11. BIN
      apps/accounts/public/fonts/inter-v11-latin-600.woff2
  12. BIN
      apps/accounts/public/fonts/inter-v11-latin-800.woff
  13. BIN
      apps/accounts/public/fonts/inter-v11-latin-800.woff2
  14. BIN
      apps/accounts/public/images/ente-circular.png
  15. 1 0
      apps/accounts/public/images/ente.svg
  16. BIN
      apps/accounts/public/images/favicon.png
  17. 642 0
      apps/accounts/public/locales/en/translation.json
  18. 1 0
      apps/accounts/public/next.svg
  19. 1 0
      apps/accounts/public/vercel.svg
  20. 3 0
      apps/accounts/sentry.client.config.ts
  21. 3 0
      apps/accounts/sentry.properties
  22. 0 0
      apps/accounts/sentry.server.config.ts
  23. 124 0
      apps/accounts/src/pages/_app.tsx
  24. 7 0
      apps/accounts/src/pages/_document.tsx
  25. 59 0
      apps/accounts/src/pages/account-handoff.tsx
  26. 17 0
      apps/accounts/src/pages/credentials/index.tsx
  27. 17 0
      apps/accounts/src/pages/generate/index.tsx
  28. 13 0
      apps/accounts/src/pages/index.tsx
  29. 17 0
      apps/accounts/src/pages/login/index.tsx
  30. 71 0
      apps/accounts/src/pages/passkeys/DeletePasskeyModal.tsx
  31. 100 0
      apps/accounts/src/pages/passkeys/ManagePasskeyDrawer.tsx
  32. 29 0
      apps/accounts/src/pages/passkeys/PasskeyListItem.tsx
  33. 26 0
      apps/accounts/src/pages/passkeys/PasskeysList.tsx
  34. 56 0
      apps/accounts/src/pages/passkeys/RenamePasskeyModal.tsx
  35. 6 0
      apps/accounts/src/pages/passkeys/finish.tsx
  36. 275 0
      apps/accounts/src/pages/passkeys/flow/index.tsx
  37. 167 0
      apps/accounts/src/pages/passkeys/index.tsx
  38. 17 0
      apps/accounts/src/pages/recover/index.tsx
  39. 17 0
      apps/accounts/src/pages/signup/index.tsx
  40. 17 0
      apps/accounts/src/pages/two-factor/recover/index.tsx
  41. 17 0
      apps/accounts/src/pages/two-factor/setup/index.tsx
  42. 17 0
      apps/accounts/src/pages/two-factor/verify/index.tsx
  43. 17 0
      apps/accounts/src/pages/verify/index.tsx
  44. 200 0
      apps/accounts/src/services/passkeysService.ts
  45. 194 0
      apps/accounts/src/styles/global.css
  46. 6 0
      apps/accounts/src/types/passkey.ts
  47. 25 0
      apps/accounts/tsconfig.json
  48. 1 2
      apps/auth/public/locales/en/translation.json
  49. 1 1
      apps/cast/src/pages/slideshow.tsx
  50. 1 0
      apps/photos/public/locales/en/translation.json
  51. 7 0
      apps/photos/src/components/EnteSpinner.tsx
  52. 34 7
      apps/photos/src/components/Sidebar/UtilitySection.tsx
  53. 11 0
      apps/photos/src/pages/passkeys/finish/index.tsx
  54. 32 12
      apps/photos/src/services/userService.ts
  55. 3 0
      package.json
  56. 46 27
      packages/accounts/pages/credentials.tsx
  57. 46 0
      packages/accounts/pages/passkeys/finish.tsx
  58. 36 22
      packages/accounts/pages/verify.tsx
  59. 1 0
      packages/accounts/types/user.ts
  60. 5 1
      packages/shared/apps/constants.ts
  61. 42 0
      packages/shared/components/CaptionedText.tsx
  62. 63 0
      packages/shared/components/Collections/CollectionShare/publicShare/switch.tsx
  63. 26 0
      packages/shared/components/Directory/changeOption.tsx
  64. 34 0
      packages/shared/components/Directory/index.tsx
  65. 10 0
      packages/shared/components/EnteDrawer.tsx
  66. 61 0
      packages/shared/components/Info/InfoItem.tsx
  67. 127 0
      packages/shared/components/Menu/EnteMenuItem.tsx
  68. 16 0
      packages/shared/components/Menu/MenuItemDivider.tsx
  69. 20 0
      packages/shared/components/Menu/MenuItemGroup.tsx
  70. 27 0
      packages/shared/components/Menu/MenuSectionTitle.tsx
  71. 56 0
      packages/shared/components/Titlebar.tsx
  72. 11 0
      packages/shared/components/styledComponents/SmallLoadingSpinner.tsx
  73. 15 0
      packages/shared/constants/pages.tsx
  74. 1 0
      packages/shared/error/index.ts
  75. 22 0
      packages/shared/network/HTTPService.ts
  76. 41 0
      packages/shared/network/api.ts
  77. 1 0
      packages/shared/storage/localStorage/index.ts

+ 13 - 0
apps/accounts/.eslintrc.js

@@ -0,0 +1,13 @@
+module.exports = {
+    // When root is set to true, ESLint will stop looking for configuration files in parent directories.
+    // This is required here to ensure desktop picks the right eslint config, where this app is
+    // packaged as a submodule.
+    root: true,
+    extends: ['@ente/eslint-config'],
+    parser: '@typescript-eslint/parser',
+    parserOptions: {
+        tsconfigRootDir: __dirname,
+        project: './tsconfig.json',
+    },
+    ignorePatterns: ['.eslintrc.js'],
+};

+ 36 - 0
apps/accounts/.gitignore

@@ -0,0 +1,36 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+.yarn/install-state.gz
+
+# testing
+/coverage
+
+# next.js
+/.next/
+/out/
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# local env files
+.env*.local
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts

+ 40 - 0
apps/accounts/README.md

@@ -0,0 +1,40 @@
+This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
+
+[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
+
+The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
+
+This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

+ 11 - 0
apps/accounts/next.config.js

@@ -0,0 +1,11 @@
+const nextConfigBase = require('@ente/shared/next/next.config.base.js');
+
+module.exports = {
+    ...nextConfigBase,
+    images: {
+        unoptimized: true,
+    },
+    experimental: {
+        externalDir: true,
+    },
+};

+ 18 - 0
apps/accounts/package.json

@@ -0,0 +1,18 @@
+{
+    "name": "accounts",
+    "version": "0.1.0",
+    "private": true,
+    "scripts": {
+        "dev": "next dev",
+        "build": "next build",
+        "start": "next start",
+        "lint": "next lint",
+        "export": "next export"
+    },
+    "dependencies": {},
+    "devDependencies": {
+        "@types/node": "^14.6.4",
+        "@types/react": "^16.9.49"
+    },
+    "types": "src/index.ts"
+}

BIN
apps/accounts/public/favicon.ico


+ 93 - 0
apps/accounts/public/fonts/OFL.txt

@@ -0,0 +1,93 @@
+Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
apps/accounts/public/fonts/inter-v11-latin-500.woff


BIN
apps/accounts/public/fonts/inter-v11-latin-500.woff2


BIN
apps/accounts/public/fonts/inter-v11-latin-600.woff


BIN
apps/accounts/public/fonts/inter-v11-latin-600.woff2


BIN
apps/accounts/public/fonts/inter-v11-latin-800.woff


BIN
apps/accounts/public/fonts/inter-v11-latin-800.woff2


BIN
apps/accounts/public/images/ente-circular.png


Diff do ficheiro suprimidas por serem muito extensas
+ 1 - 0
apps/accounts/public/images/ente.svg


BIN
apps/accounts/public/images/favicon.png


+ 642 - 0
apps/accounts/public/locales/en/translation.json

@@ -0,0 +1,642 @@
+{
+    "HERO_SLIDE_1_TITLE": "<div>Private backups</div><div>for your memories</div>",
+    "HERO_SLIDE_1": "End-to-end encrypted by default",
+    "HERO_SLIDE_2_TITLE": "<div>Safely stored</div><div>at a fallout shelter</div>",
+    "HERO_SLIDE_2": "Designed to outlive",
+    "HERO_SLIDE_3_TITLE": "<div>Available</div><div> everywhere</div>",
+    "HERO_SLIDE_3": "Android, iOS, Web, Desktop",
+    "LOGIN": "Login",
+    "SIGN_UP": "Signup",
+    "NEW_USER": "New to ente",
+    "EXISTING_USER": "Existing user",
+    "ENTER_NAME": "Enter name",
+    "PUBLIC_UPLOADER_NAME_MESSAGE": "Add a name so that your friends know who to thank for these great photos!",
+    "ENTER_EMAIL": "Enter email address",
+    "EMAIL_ERROR": "Enter a valid email",
+    "REQUIRED": "Required",
+    "EMAIL_SENT": "Verification code sent to <a>{{email}}</a>",
+    "CHECK_INBOX": "Please check your inbox (and spam) to complete verification",
+    "ENTER_OTT": "Verification code",
+    "RESEND_MAIL": "Resend code",
+    "VERIFY": "Verify",
+    "UNKNOWN_ERROR": "Something went wrong, please try again",
+    "INVALID_CODE": "Invalid verification code",
+    "EXPIRED_CODE": "Your verification code has expired",
+    "SENDING": "Sending...",
+    "SENT": "Sent!",
+    "PASSWORD": "Password",
+    "LINK_PASSWORD": "Enter password to unlock the album",
+    "RETURN_PASSPHRASE_HINT": "Password",
+    "SET_PASSPHRASE": "Set password",
+    "VERIFY_PASSPHRASE": "Sign in",
+    "INCORRECT_PASSPHRASE": "Incorrect password",
+    "ENTER_ENC_PASSPHRASE": "Please enter a password that we can use to encrypt your data",
+    "PASSPHRASE_DISCLAIMER": "We don't store your password, so if you forget it, <strong>we will not be able to help you </strong>recover your data without a recovery key.",
+    "WELCOME_TO_ENTE_HEADING": "Welcome to <a/>",
+    "WELCOME_TO_ENTE_SUBHEADING": "End to end encrypted photo storage and sharing",
+    "WHERE_YOUR_BEST_PHOTOS_LIVE": "Where your best photos live",
+    "KEY_GENERATION_IN_PROGRESS_MESSAGE": "Generating encryption keys...",
+    "PASSPHRASE_HINT": "Password",
+    "CONFIRM_PASSPHRASE": "Confirm password",
+    "REFERRAL_CODE_HINT": "How did you hear about Ente? (optional)",
+    "REFERRAL_INFO": "We don't track app installs, It'd help us if you told us where you found us!",
+    "PASSPHRASE_MATCH_ERROR": "Passwords don't match",
+    "CONSOLE_WARNING_STOP": "STOP!",
+    "CONSOLE_WARNING_DESC": "This is a browser feature intended for developers. Please don't copy-paste unverified code here.",
+    "CREATE_COLLECTION": "New album",
+    "ENTER_ALBUM_NAME": "Album name",
+    "CLOSE_OPTION": "Close (Esc)",
+    "ENTER_FILE_NAME": "File name",
+    "CLOSE": "Close",
+    "NO": "No",
+    "NOTHING_HERE": "Nothing to see here yet 👀",
+    "UPLOAD": "Upload",
+    "IMPORT": "Import",
+    "ADD_PHOTOS": "Add photos",
+    "ADD_MORE_PHOTOS": "Add more photos",
+    "add_photos_one": "Add 1 item",
+    "add_photos_other": "Add {{count, number}} items",
+    "SELECT_PHOTOS": "Select photos",
+    "FILE_UPLOAD": "File Upload",
+    "UPLOAD_STAGE_MESSAGE": {
+        "0": "Preparing to upload",
+        "1": "Reading google metadata files",
+        "2": "{{uploadCounter.finished, number}} / {{uploadCounter.total, number}} files metadata extracted",
+        "3": "{{uploadCounter.finished, number}} / {{uploadCounter.total, number}} files processed",
+        "4": "Cancelling remaining uploads",
+        "5": "Backup complete"
+    },
+    "FILE_NOT_UPLOADED_LIST": "The following files were not uploaded",
+    "SUBSCRIPTION_EXPIRED": "Subscription expired",
+    "SUBSCRIPTION_EXPIRED_MESSAGE": "Your subscription has expired, please <a>renew</a>",
+    "STORAGE_QUOTA_EXCEEDED": "Storage limit exceeded",
+    "INITIAL_LOAD_DELAY_WARNING": "First load may take some time",
+    "USER_DOES_NOT_EXIST": "Sorry, could not find a user with that email",
+    "NO_ACCOUNT": "Don't have an account",
+    "ACCOUNT_EXISTS": "Already have an account",
+    "CREATE": "Create",
+    "DOWNLOAD": "Download",
+    "DOWNLOAD_OPTION": "Download (D)",
+    "DOWNLOAD_FAVORITES": "Download favorites",
+    "DOWNLOAD_UNCATEGORIZED": "Download uncategorized",
+    "DOWNLOAD_HIDDEN_ITEMS": "Download hidden items",
+    "COPY_OPTION": "Copy as PNG (Ctrl/Cmd - C)",
+    "TOGGLE_FULLSCREEN": "Toggle fullscreen (F)",
+    "ZOOM_IN_OUT": "Zoom in/out",
+    "PREVIOUS": "Previous (←)",
+    "NEXT": "Next (→)",
+    "TITLE_PHOTOS": "Ente Photos",
+    "TITLE_ALBUMS": "Ente Photos",
+    "TITLE_AUTH": "Ente Auth",
+    "UPLOAD_FIRST_PHOTO": "Upload your first photo",
+    "IMPORT_YOUR_FOLDERS": "Import your folders",
+    "UPLOAD_DROPZONE_MESSAGE": "Drop to backup your files",
+    "WATCH_FOLDER_DROPZONE_MESSAGE": "Drop to add watched folder",
+    "TRASH_FILES_TITLE": "Delete files?",
+    "TRASH_FILE_TITLE": "Delete file?",
+    "DELETE_FILES_TITLE": "Delete immediately?",
+    "DELETE_FILES_MESSAGE": "Selected files will be permanently deleted from your ente account.",
+    "DELETE": "Delete",
+    "DELETE_OPTION": "Delete (DEL)",
+    "FAVORITE_OPTION": "Favorite (L)",
+    "UNFAVORITE_OPTION": "Unfavorite (L)",
+    "MULTI_FOLDER_UPLOAD": "Multiple folders detected",
+    "UPLOAD_STRATEGY_CHOICE": "Would you like to upload them into",
+    "UPLOAD_STRATEGY_SINGLE_COLLECTION": "A single album",
+    "OR": "or",
+    "UPLOAD_STRATEGY_COLLECTION_PER_FOLDER": "Separate albums",
+    "SESSION_EXPIRED_MESSAGE": "Your session has expired, please login again to continue",
+    "SESSION_EXPIRED": "Session expired",
+    "PASSWORD_GENERATION_FAILED": "Your browser was unable to generate a strong key that meets ente's encryption standards, please try using the mobile app or another browser",
+    "CHANGE_PASSWORD": "Change password",
+    "GO_BACK": "Go back",
+    "RECOVERY_KEY": "Recovery key",
+    "SAVE_LATER": "Do this later",
+    "SAVE": "Save Key",
+    "RECOVERY_KEY_DESCRIPTION": "If you forget your password, the only way you can recover your data is with this key.",
+    "RECOVER_KEY_GENERATION_FAILED": "Recovery code could not be generated, please try again",
+    "KEY_NOT_STORED_DISCLAIMER": "We don't store this key, so please save this in a safe place",
+    "FORGOT_PASSWORD": "Forgot password",
+    "RECOVER_ACCOUNT": "Recover account",
+    "RECOVERY_KEY_HINT": "Recovery key",
+    "RECOVER": "Recover",
+    "NO_RECOVERY_KEY": "No recovery key?",
+    "INCORRECT_RECOVERY_KEY": "Incorrect recovery key",
+    "SORRY": "Sorry",
+    "NO_RECOVERY_KEY_MESSAGE": "Due to the nature of our end-to-end encryption protocol, your data cannot be decrypted without your password or recovery key",
+    "NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE": "Please drop an email to <a>{{emailID}}</a> from your registered email address",
+    "CONTACT_SUPPORT": "Contact support",
+    "REQUEST_FEATURE": "Request Feature",
+    "SUPPORT": "Support",
+    "CONFIRM": "Confirm",
+    "CANCEL": "Cancel",
+    "LOGOUT": "Logout",
+    "DELETE_ACCOUNT": "Delete account",
+    "DELETE_ACCOUNT_MESSAGE": "<p>Please send an email to <a>{{emailID}}</a> from your registered email address.</p><p>Your request will be processed within 72 hours.</p>",
+    "LOGOUT_MESSAGE": "Are you sure you want to logout?",
+    "CHANGE_EMAIL": "Change email",
+    "OK": "OK",
+    "SUCCESS": "Success",
+    "ERROR": "Error",
+    "MESSAGE": "Message",
+    "INSTALL_MOBILE_APP": "Install our <a>Android</a> or <b>iOS</b> app to automatically backup all your photos",
+    "DOWNLOAD_APP_MESSAGE": "Sorry, this operation is currently only supported on our desktop app",
+    "DOWNLOAD_APP": "Download desktop app",
+    "EXPORT": "Export Data",
+    "SUBSCRIPTION": "Subscription",
+    "SUBSCRIBE": "Subscribe",
+    "MANAGEMENT_PORTAL": "Manage payment method",
+    "MANAGE_FAMILY_PORTAL": "Manage family",
+    "LEAVE_FAMILY_PLAN": "Leave family plan",
+    "LEAVE": "Leave",
+    "LEAVE_FAMILY_CONFIRM": "Are you sure that you want to leave family plan?",
+    "CHOOSE_PLAN": "Choose your plan",
+    "MANAGE_PLAN": "Manage your subscription",
+    "ACTIVE": "Active",
+    "OFFLINE_MSG": "You are offline, cached memories are being shown",
+    "FREE_SUBSCRIPTION_INFO": "You are on the <strong>free</strong> plan that expires on {{date, dateTime}}",
+    "FAMILY_SUBSCRIPTION_INFO": "You are on a family plan managed by",
+    "RENEWAL_ACTIVE_SUBSCRIPTION_STATUS": "Renews on {{date, dateTime}}",
+    "RENEWAL_CANCELLED_SUBSCRIPTION_STATUS": "Ends on {{date, dateTime}}",
+    "RENEWAL_CANCELLED_SUBSCRIPTION_INFO": "Your subscription will be cancelled on {{date, dateTime}}",
+    "ADD_ON_AVAILABLE_TILL": "Your {{storage, string}} add-on is valid till {{date, dateTime}}",
+    "STORAGE_QUOTA_EXCEEDED_SUBSCRIPTION_INFO": "You have exceeded your storage quota, please <a>upgrade</a>",
+    "SUBSCRIPTION_PURCHASE_SUCCESS": "<p>We've received your payment</p><p>Your subscription is valid till <strong>{{date, dateTime}}</strong></p>",
+    "SUBSCRIPTION_PURCHASE_CANCELLED": "Your purchase was canceled, please try again if you want to subscribe",
+    "SUBSCRIPTION_PURCHASE_FAILED": "Subscription purchase failed , please try again",
+    "SUBSCRIPTION_UPDATE_FAILED": "Subscription updated failed , please try again",
+    "UPDATE_PAYMENT_METHOD_MESSAGE": "We are sorry, payment failed when we tried to charge your card, please update your payment method and try again",
+    "STRIPE_AUTHENTICATION_FAILED": "We are unable to authenticate your payment method. please choose a different payment method and try again",
+    "UPDATE_PAYMENT_METHOD": "Update payment method",
+    "MONTHLY": "Monthly",
+    "YEARLY": "Yearly",
+    "UPDATE_SUBSCRIPTION_MESSAGE": "Are you sure you want to change your plan?",
+    "UPDATE_SUBSCRIPTION": "Change plan",
+    "CANCEL_SUBSCRIPTION": "Cancel subscription",
+    "CANCEL_SUBSCRIPTION_MESSAGE": "<p>All of your data will be deleted from our servers at the end of this billing period.</p><p>Are you sure that you want to cancel your subscription?</p>",
+    "CANCEL_SUBSCRIPTION_WITH_ADDON_MESSAGE": "<p>Are you sure you want to cancel your subscription?</p>",
+    "SUBSCRIPTION_CANCEL_FAILED": "Failed to cancel subscription",
+    "SUBSCRIPTION_CANCEL_SUCCESS": "Subscription canceled successfully",
+    "REACTIVATE_SUBSCRIPTION": "Reactivate subscription",
+    "REACTIVATE_SUBSCRIPTION_MESSAGE": "Once reactivated, you will be billed on {{date, dateTime}}",
+    "SUBSCRIPTION_ACTIVATE_SUCCESS": "Subscription activated successfully ",
+    "SUBSCRIPTION_ACTIVATE_FAILED": "Failed to reactivate subscription renewals",
+    "SUBSCRIPTION_PURCHASE_SUCCESS_TITLE": "Thank you",
+    "CANCEL_SUBSCRIPTION_ON_MOBILE": "Cancel mobile subscription",
+    "CANCEL_SUBSCRIPTION_ON_MOBILE_MESSAGE": "Please cancel your subscription from the mobile app to activate a subscription here",
+    "MAIL_TO_MANAGE_SUBSCRIPTION": "Please contact us at <a>{{emailID}}</a> to manage your subscription",
+    "RENAME": "Rename",
+    "RENAME_FILE": "Rename file",
+    "RENAME_COLLECTION": "Rename album",
+    "DELETE_COLLECTION_TITLE": "Delete album?",
+    "DELETE_COLLECTION": "Delete album",
+    "DELETE_COLLECTION_MESSAGE": "Also delete the photos (and videos) present in this album from <a>all</a> other albums they are part of?",
+    "DELETE_PHOTOS": "Delete photos",
+    "KEEP_PHOTOS": "Keep photos",
+    "SHARE": "Share",
+    "SHARE_COLLECTION": "Share album",
+    "SHAREES": "Shared with",
+    "SHARE_WITH_SELF": "Oops, you cannot share with yourself",
+    "ALREADY_SHARED": "Oops, you're already sharing this with {{email}}",
+    "SHARING_BAD_REQUEST_ERROR": "Sharing album not allowed",
+    "SHARING_DISABLED_FOR_FREE_ACCOUNTS": "Sharing is disabled for free accounts",
+    "DOWNLOAD_COLLECTION": "Download album",
+    "DOWNLOAD_COLLECTION_MESSAGE": "<p>Are you sure you want to download the complete album?</p><p>All files will be queued for download sequentially</p>",
+    "CREATE_ALBUM_FAILED": "Failed to create album , please try again",
+    "SEARCH": "Search",
+    "SEARCH_RESULTS": "Search results",
+    "NO_RESULTS": "No results found",
+    "SEARCH_HINT": "Search for albums, dates, descriptions, ...",
+    "SEARCH_TYPE": {
+        "COLLECTION": "Album",
+        "LOCATION": "Location",
+        "DATE": "Date",
+        "FILE_NAME": "File name",
+        "THING": "Content",
+        "FILE_CAPTION": "Description",
+        "FILE_TYPE": "File type",
+        "CLIP": "Magic"
+    },
+    "photos_count_zero": "No memories",
+    "photos_count_one": "1 memory",
+    "photos_count_other": "{{count, number}} memories",
+    "TERMS_AND_CONDITIONS": "I agree to the <a>terms</a> and <b>privacy policy</b>",
+    "ADD_TO_COLLECTION": "Add to album",
+    "SELECTED": "selected",
+    "VIDEO_PLAYBACK_FAILED_DOWNLOAD_INSTEAD": "This video cannot be played on your browser",
+    "PEOPLE": "People",
+    "INDEXING_SCHEDULED": "Indexing is scheduled...",
+    "ANALYZING_PHOTOS": "Indexing photos ({{indexStatus.nSyncedFiles,number}} / {{indexStatus.nTotalFiles,number}})",
+    "INDEXING_PEOPLE": "Indexing people in {{indexStatus.nSyncedFiles,number}} photos...",
+    "INDEXING_DONE": "Indexed {{indexStatus.nSyncedFiles,number}} photos",
+    "UNIDENTIFIED_FACES": "unidentified faces",
+    "OBJECTS": "objects",
+    "TEXT": "text",
+    "INFO": "Info ",
+    "INFO_OPTION": "Info (I)",
+    "FILE_NAME": "File name",
+    "CAPTION_PLACEHOLDER": "Add a description",
+    "LOCATION": "Location",
+    "SHOW_ON_MAP": "View on OpenStreetMap",
+    "MAP": "Map",
+    "MAP_SETTINGS": "Map Settings",
+    "ENABLE_MAPS": "Enable Maps?",
+    "ENABLE_MAP": "Enable map",
+    "DISABLE_MAPS": "Disable Maps?",
+    "ENABLE_MAP_DESCRIPTION": "<p>This will show your photos on a world map.</p> <p>The map is hosted by <a>OpenStreetMap</a>, and the exact locations of your photos are never shared.</p> <p>You can disable this feature anytime from Settings.</p>",
+    "DISABLE_MAP_DESCRIPTION": "<p>This will disable the display of your photos on a world map.</p> <p>You can enable this feature anytime from Settings.</p>",
+    "DISABLE_MAP": "Disable map",
+    "DETAILS": "Details",
+    "VIEW_EXIF": "View all EXIF data",
+    "NO_EXIF": "No EXIF data",
+    "EXIF": "EXIF",
+    "ISO": "ISO",
+    "TWO_FACTOR": "Two-factor",
+    "TWO_FACTOR_AUTHENTICATION": "Two-factor authentication",
+    "TWO_FACTOR_QR_INSTRUCTION": "Scan the QR code below with your favorite authenticator app",
+    "ENTER_CODE_MANUALLY": "Enter the code manually",
+    "TWO_FACTOR_MANUAL_CODE_INSTRUCTION": "Please enter this code in your favorite authenticator app",
+    "SCAN_QR_CODE": "Scan QR code instead",
+    "ENABLE_TWO_FACTOR": "Enable two-factor",
+    "ENABLE": "Enable",
+    "LOST_DEVICE": "Lost two-factor device",
+    "INCORRECT_CODE": "Incorrect code",
+    "TWO_FACTOR_INFO": "Add an additional layer of security by requiring more than your email and password to log in to your account",
+    "DISABLE_TWO_FACTOR_LABEL": "Disable two-factor authentication",
+    "UPDATE_TWO_FACTOR_LABEL": "Update your authenticator device",
+    "DISABLE": "Disable",
+    "RECONFIGURE": "Reconfigure",
+    "UPDATE_TWO_FACTOR": "Update two-factor",
+    "UPDATE_TWO_FACTOR_MESSAGE": "Continuing forward will void any previously configured authenticators",
+    "UPDATE": "Update",
+    "DISABLE_TWO_FACTOR": "Disable two-factor",
+    "DISABLE_TWO_FACTOR_MESSAGE": "Are you sure you want to disable your two-factor authentication",
+    "TWO_FACTOR_DISABLE_FAILED": "Failed to disable two factor, please try again",
+    "EXPORT_DATA": "Export data",
+    "SELECT_FOLDER": "Select folder",
+    "DESTINATION": "Destination",
+    "START": "Start",
+    "LAST_EXPORT_TIME": "Last export time",
+    "EXPORT_AGAIN": "Resync",
+    "LOCAL_STORAGE_NOT_ACCESSIBLE": "Local storage not accessible",
+    "LOCAL_STORAGE_NOT_ACCESSIBLE_MESSAGE": "Your browser or an addon is blocking ente from saving data into local storage. please try loading this page after switching your browsing mode.",
+    "SEND_OTT": "Send OTP",
+    "EMAIl_ALREADY_OWNED": "Email already taken",
+    "ETAGS_BLOCKED": "<p>We were unable to upload the following files because of your browser configuration.</p><p>Please disable any addons that might be preventing ente from using <code>eTags</code> to upload large files, or use our <a>desktop app</a> for a more reliable import experience.</p>",
+    "SKIPPED_VIDEOS_INFO": "<p>Presently we do not support adding videos via public links.</p><p>To share videos, please <a>signup</a> for ente and share with the intended recipients using their email.</p>",
+    "LIVE_PHOTOS_DETECTED": "The photo and video files from your Live Photos have been merged into a single file",
+    "RETRY_FAILED": "Retry failed uploads",
+    "FAILED_UPLOADS": "Failed uploads ",
+    "SKIPPED_FILES": "Ignored uploads",
+    "THUMBNAIL_GENERATION_FAILED_UPLOADS": "Thumbnail generation failed",
+    "UNSUPPORTED_FILES": "Unsupported files",
+    "SUCCESSFUL_UPLOADS": "Successful uploads",
+    "SKIPPED_INFO": "Skipped these as there are files with matching names in the same album",
+    "UNSUPPORTED_INFO": "ente does not support these file formats yet",
+    "BLOCKED_UPLOADS": "Blocked uploads",
+    "SKIPPED_VIDEOS": "Skipped videos",
+    "INPROGRESS_METADATA_EXTRACTION": "In progress",
+    "INPROGRESS_UPLOADS": "Uploads in progress",
+    "TOO_LARGE_UPLOADS": "Large files",
+    "LARGER_THAN_AVAILABLE_STORAGE_UPLOADS": "Insufficient storage",
+    "LARGER_THAN_AVAILABLE_STORAGE_INFO": "These files were not uploaded as they exceed the maximum size limit for your storage plan",
+    "TOO_LARGE_INFO": "These files were not uploaded as they exceed our maximum file size limit",
+    "THUMBNAIL_GENERATION_FAILED_INFO": "These files were uploaded, but unfortunately we could not generate the thumbnails for them.",
+    "UPLOAD_TO_COLLECTION": "Upload to album",
+    "UNCATEGORIZED": "Uncategorized",
+    "ARCHIVE": "Archive",
+    "FAVORITES": "Favorites",
+    "ARCHIVE_COLLECTION": "Archive album",
+    "ARCHIVE_SECTION_NAME": "Archive",
+    "ALL_SECTION_NAME": "All",
+    "MOVE_TO_COLLECTION": "Move to album",
+    "UNARCHIVE": "Unarchive",
+    "UNARCHIVE_COLLECTION": "Unarchive album",
+    "HIDE_COLLECTION": "Hide album",
+    "UNHIDE_COLLECTION": "Unhide album",
+    "MOVE": "Move",
+    "ADD": "Add",
+    "REMOVE": "Remove",
+    "YES_REMOVE": "Yes, remove",
+    "REMOVE_FROM_COLLECTION": "Remove from album",
+    "TRASH": "Trash",
+    "MOVE_TO_TRASH": "Move to trash",
+    "TRASH_FILES_MESSAGE": "Selected files will be removed from all albums and moved to trash.",
+    "TRASH_FILE_MESSAGE": "The file will be removed from all albums and moved to trash.",
+    "DELETE_PERMANENTLY": "Delete permanently",
+    "RESTORE": "Restore",
+    "RESTORE_TO_COLLECTION": "Restore to album",
+    "EMPTY_TRASH": "Empty trash",
+    "EMPTY_TRASH_TITLE": "Empty trash?",
+    "EMPTY_TRASH_MESSAGE": "These files will be permanently deleted from your ente account.",
+    "LEAVE_SHARED_ALBUM": "Yes, leave",
+    "LEAVE_ALBUM": "Leave album",
+    "LEAVE_SHARED_ALBUM_TITLE": "Leave shared album?",
+    "LEAVE_SHARED_ALBUM_MESSAGE": "You will leave the album, and it will stop being visible to you.",
+    "NOT_FILE_OWNER": "You cannot delete files in a shared album",
+    "CONFIRM_SELF_REMOVE_MESSAGE": "Selected items will be removed from this album. Items which are only in this album will be moved to Uncategorized.",
+    "CONFIRM_SELF_AND_OTHER_REMOVE_MESSAGE": "Some of the items you are removing were added by other people, and you will lose access to them.",
+    "SORT_BY_CREATION_TIME_ASCENDING": "Oldest",
+    "SORT_BY_UPDATION_TIME_DESCENDING": "Last updated",
+    "SORT_BY_NAME": "Name",
+    "COMPRESS_THUMBNAILS": "Compress thumbnails",
+    "THUMBNAIL_REPLACED": "Thumbnails compressed",
+    "FIX_THUMBNAIL": "Compress",
+    "FIX_THUMBNAIL_LATER": "Compress later",
+    "REPLACE_THUMBNAIL_NOT_STARTED": "Some of your videos thumbnails can be compressed to save space. would you like ente to compress them?",
+    "REPLACE_THUMBNAIL_COMPLETED": "Successfully compressed all thumbnails",
+    "REPLACE_THUMBNAIL_NOOP": "You have no thumbnails that can be compressed further",
+    "REPLACE_THUMBNAIL_COMPLETED_WITH_ERROR": "Could not compress some of your thumbnails, please retry",
+    "FIX_CREATION_TIME": "Fix time",
+    "FIX_CREATION_TIME_IN_PROGRESS": "Fixing time",
+    "CREATION_TIME_UPDATED": "File time updated",
+    "UPDATE_CREATION_TIME_NOT_STARTED": "Select the option you want to use",
+    "UPDATE_CREATION_TIME_COMPLETED": "Successfully updated all files",
+    "UPDATE_CREATION_TIME_COMPLETED_WITH_ERROR": "File time updation failed for some files, please retry",
+    "CAPTION_CHARACTER_LIMIT": "5000 characters max",
+    "DATE_TIME_ORIGINAL": "EXIF:DateTimeOriginal",
+    "DATE_TIME_DIGITIZED": "EXIF:DateTimeDigitized",
+    "METADATA_DATE": "EXIF:MetadataDate",
+    "CUSTOM_TIME": "Custom time",
+    "REOPEN_PLAN_SELECTOR_MODAL": "Re-open plans",
+    "OPEN_PLAN_SELECTOR_MODAL_FAILED": "Failed to open plans",
+    "INSTALL": "Install",
+    "SHARING_DETAILS": "Sharing details",
+    "MODIFY_SHARING": "Modify sharing",
+    "ADD_COLLABORATORS": "Add collaborators",
+    "ADD_NEW_EMAIL": "Add a new email",
+    "shared_with_people_zero": "Share with specific people",
+    "shared_with_people_one": "Shared with 1 person",
+    "shared_with_people_other": "Shared with {{count, number}} people",
+    "participants_zero": "No participants",
+    "participants_one": "1 participant",
+    "participants_other": "{{count, number}} participants",
+    "ADD_VIEWERS": "Add viewers",
+    "PARTICIPANTS": "Participants",
+    "CHANGE_PERMISSIONS_TO_VIEWER": "<p>{{selectedEmail}} will not be able to add more photos to the album</p> <p>They will still be able to remove photos added by them</p>",
+    "CHANGE_PERMISSIONS_TO_COLLABORATOR": "{{selectedEmail}} will be able to add photos to the album",
+    "CONVERT_TO_VIEWER": "Yes, convert to viewer",
+    "CONVERT_TO_COLLABORATOR": "Yes, convert to collaborator",
+    "CHANGE_PERMISSION": "Change permission?",
+    "REMOVE_PARTICIPANT": "Remove?",
+    "CONFIRM_REMOVE": "Yes, remove",
+    "MANAGE": "Manage",
+    "ADDED_AS": "Added as",
+    "COLLABORATOR_RIGHTS": "Collaborators can add photos and videos to the shared album",
+    "REMOVE_PARTICIPANT_HEAD": "Remove participant",
+    "OWNER": "Owner",
+    "COLLABORATORS": "Collaborators",
+    "ADD_MORE": "Add more",
+    "VIEWERS": "Viewers",
+    "OR_ADD_EXISTING": "Or pick an existing one",
+    "REMOVE_PARTICIPANT_MESSAGE": "<p>{{selectedEmail}} will be removed from the album</p> <p>Any photos added by them will also be removed from the album</p>",
+    "NOT_FOUND": "404 - not found",
+    "LINK_EXPIRED": "Link expired",
+    "LINK_EXPIRED_MESSAGE": "This link has either expired or been disabled!",
+    "MANAGE_LINK": "Manage link",
+    "LINK_TOO_MANY_REQUESTS": "Sorry, this album has been viewed on too many devices!",
+    "FILE_DOWNLOAD": "Allow downloads",
+    "LINK_PASSWORD_LOCK": "Password lock",
+    "PUBLIC_COLLECT": "Allow adding photos",
+    "LINK_DEVICE_LIMIT": "Device limit",
+    "NO_DEVICE_LIMIT": "None",
+    "LINK_EXPIRY": "Link expiry",
+    "NEVER": "Never",
+    "DISABLE_FILE_DOWNLOAD": "Disable download",
+    "DISABLE_FILE_DOWNLOAD_MESSAGE": "<p>Are you sure that you want to disable the download button for files?</p><p>Viewers can still take screenshots or save a copy of your photos using external tools.</p>",
+    "MALICIOUS_CONTENT": "Contains malicious content",
+    "COPYRIGHT": "Infringes on the copyright of someone I am authorized to represent",
+    "SHARED_USING": "Shared using ",
+    "ENTE_IO": "ente.io",
+    "SHARING_REFERRAL_CODE": "Use code <strong>{{referralCode}}</strong> to get 10 GB free",
+    "LIVE": "LIVE",
+    "DISABLE_PASSWORD": "Disable password lock",
+    "DISABLE_PASSWORD_MESSAGE": "Are you sure that you want to disable the password lock?",
+    "PASSWORD_LOCK": "Password lock",
+    "LOCK": "Lock",
+    "DOWNLOAD_UPLOAD_LOGS": "Debug logs",
+    "UPLOAD_FILES": "File",
+    "UPLOAD_DIRS": "Folder",
+    "UPLOAD_GOOGLE_TAKEOUT": "Google takeout",
+    "DEDUPLICATE_FILES": "Deduplicate files",
+    "AUTHENTICATOR_SECTION": "Authenticator",
+    "NO_DUPLICATES_FOUND": "You've no duplicate files that can be cleared",
+    "CLUB_BY_CAPTURE_TIME": "Club by capture time",
+    "FILES": "Files",
+    "EACH": "Each",
+    "DEDUPLICATE_BASED_ON_SIZE": "The following files were clubbed based on their sizes, please review and delete items you believe are duplicates",
+    "STOP_ALL_UPLOADS_MESSAGE": "Are you sure that you want to stop all the uploads in progress?",
+    "STOP_UPLOADS_HEADER": "Stop uploads?",
+    "YES_STOP_UPLOADS": "Yes, stop uploads",
+    "STOP_DOWNLOADS_HEADER": "Stop downloads?",
+    "YES_STOP_DOWNLOADS": "Yes, stop downloads",
+    "STOP_ALL_DOWNLOADS_MESSAGE": "Are you sure that you want to stop all the downloads in progress?",
+    "albums_one": "1 Album",
+    "albums_other": "{{count, number}} Albums",
+    "ALL_ALBUMS": "All Albums",
+    "ALBUMS": "Albums",
+    "ALL_HIDDEN_ALBUMS": "All hidden albums",
+    "HIDDEN_ALBUMS": "Hidden albums",
+    "HIDDEN_ITEMS": "Hidden items",
+    "HIDDEN_ITEMS_SECTION_NAME": "Hidden_items",
+    "ENTER_TWO_FACTOR_OTP": "Enter the 6-digit code from your authenticator app.",
+    "CREATE_ACCOUNT": "Create account",
+    "COPIED": "Copied",
+    "CANVAS_BLOCKED_TITLE": "Unable to generate thumbnail",
+    "CANVAS_BLOCKED_MESSAGE": "<p>It looks like your browser has disabled access to canvas, which is necessary to generate thumbnails for your photos </p> <p> Please enable access to your browser's canvas, or check out our desktop app</p>",
+    "WATCH_FOLDERS": "Watch folders",
+    "UPGRADE_NOW": "Upgrade now",
+    "RENEW_NOW": "Renew now",
+    "STORAGE": "Storage",
+    "USED": "used",
+    "YOU": "You",
+    "FAMILY": "Family",
+    "FREE": "free",
+    "OF": "of",
+    "WATCHED_FOLDERS": "Watched folders",
+    "NO_FOLDERS_ADDED": "No folders added yet!",
+    "FOLDERS_AUTOMATICALLY_MONITORED": "The folders you add here will monitored to automatically",
+    "UPLOAD_NEW_FILES_TO_ENTE": "Upload new files to ente",
+    "REMOVE_DELETED_FILES_FROM_ENTE": "Remove deleted files from ente",
+    "ADD_FOLDER": "Add folder",
+    "STOP_WATCHING": "Stop watching",
+    "STOP_WATCHING_FOLDER": "Stop watching folder?",
+    "STOP_WATCHING_DIALOG_MESSAGE": "Your existing files will not be deleted, but ente will stop automatically updating the linked ente album on changes in this folder.",
+    "YES_STOP": "Yes, stop",
+    "MONTH_SHORT": "mo",
+    "YEAR": "year",
+    "FAMILY_PLAN": "Family plan",
+    "DOWNLOAD_LOGS": "Download logs",
+    "DOWNLOAD_LOGS_MESSAGE": "<p>This will download debug logs, which you can email to us to help debug your issue.</p><p> Please note that file names will be included to help track issues with specific files. </p>",
+    "CHANGE_FOLDER": "Change Folder",
+    "TWO_MONTHS_FREE": "Get 2 months free on yearly plans",
+    "GB": "GB",
+    "POPULAR": "Popular",
+    "FREE_PLAN_OPTION_LABEL": "Continue with free trial",
+    "FREE_PLAN_DESCRIPTION": "1 GB for 1 year",
+    "CURRENT_USAGE": "Current usage is <strong>{{usage}}</strong>",
+    "WEAK_DEVICE": "The web browser you're using is not powerful enough to encrypt your photos. Please try to log in to ente on your computer, or download the ente mobile/desktop app.",
+    "DRAG_AND_DROP_HINT": "Or drag and drop into the ente window",
+    "CONFIRM_ACCOUNT_DELETION_MESSAGE": "Your uploaded data will be scheduled for deletion, and your account will be permanently deleted.<br/><br/>This action is not reversible.",
+    "AUTHENTICATE": "Authenticate",
+    "UPLOADED_TO_SINGLE_COLLECTION": "Uploaded to single collection",
+    "UPLOADED_TO_SEPARATE_COLLECTIONS": "Uploaded to separate collections",
+    "NEVERMIND": "Nevermind",
+    "UPDATE_AVAILABLE": "Update available",
+    "UPDATE_INSTALLABLE_MESSAGE": "A new version of ente is ready to be installed.",
+    "INSTALL_NOW": "Install now",
+    "INSTALL_ON_NEXT_LAUNCH": "Install on next launch",
+    "UPDATE_AVAILABLE_MESSAGE": "A new version of ente has been released, but it cannot be automatically downloaded and installed.",
+    "DOWNLOAD_AND_INSTALL": "Download and install",
+    "IGNORE_THIS_VERSION": "Ignore this version",
+    "TODAY": "Today",
+    "YESTERDAY": "Yesterday",
+    "NAME_PLACEHOLDER": "Name...",
+    "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED": "Cannot create albums from file/folder mix",
+    "ROOT_LEVEL_FILE_WITH_FOLDER_NOT_ALLOWED_MESSAGE": "<p>You have dragged and dropped a mixture of files and folders.</p><p>Please provide either only files, or only folders when selecting option to create separate albums</p>",
+    "CHOSE_THEME": "Choose theme",
+    "ML_SEARCH": "ML search (beta)",
+    "ENABLE_ML_SEARCH_DESCRIPTION": "<p>This will enable on-device machine learning and face search which will start analyzing your uploaded photos locally.</p><p>For the first run after login or enabling this feature, it will download all images on local device to analyze them. So please only enable this if you are ok with bandwidth and local processing of all images in your photo library.</p><p>If this is the first time you're enabling this, we'll also ask your permission to process face data.</p>",
+    "ML_MORE_DETAILS": "More details",
+    "ENABLE_FACE_SEARCH": "Enable face search",
+    "ENABLE_FACE_SEARCH_TITLE": "Enable face search?",
+    "ENABLE_FACE_SEARCH_DESCRIPTION": "<p>If you enable face search, ente will extract face geometry from your photos. This will happen on your device, and any generated biometric data will be end-to-encrypted.<p/><p><a>Please click here for more details about this feature in our privacy policy</a></p>",
+    "DISABLE_BETA": "Disable beta",
+    "DISABLE_FACE_SEARCH": "Disable face search",
+    "DISABLE_FACE_SEARCH_TITLE": "Disable face search?",
+    "DISABLE_FACE_SEARCH_DESCRIPTION": "<p>ente will stop processing face geometry, and will also disable ML search (beta)</p><p>You can reenable face search again if you wish, so this operation is safe.</p>",
+    "ADVANCED": "Advanced",
+    "FACE_SEARCH_CONFIRMATION": "I understand, and wish to allow ente to process face geometry",
+    "LABS": "Labs",
+    "YOURS": "yours",
+    "PASSPHRASE_STRENGTH_WEAK": "Password strength: Weak",
+    "PASSPHRASE_STRENGTH_MODERATE": "Password strength: Moderate",
+    "PASSPHRASE_STRENGTH_STRONG": "Password strength: Strong",
+    "PREFERENCES": "Preferences",
+    "LANGUAGE": "Language",
+    "EXPORT_DIRECTORY_DOES_NOT_EXIST": "Invalid export directory",
+    "EXPORT_DIRECTORY_DOES_NOT_EXIST_MESSAGE": "<p>The export directory you have selected does not exist.</p><p> Please select a valid directory.</p>",
+    "SUBSCRIPTION_VERIFICATION_ERROR": "Subscription verification failed",
+    "STORAGE_UNITS": {
+        "B": "B",
+        "KB": "KB",
+        "MB": "MB",
+        "GB": "GB",
+        "TB": "TB"
+    },
+    "AFTER_TIME": {
+        "HOUR": "after an hour",
+        "DAY": "after a day",
+        "WEEK": "after a week",
+        "MONTH": "after a month",
+        "YEAR": "after a year"
+    },
+    "COPY_LINK": "Copy link",
+    "DONE": "Done",
+    "LINK_SHARE_TITLE": "Or share a link",
+    "REMOVE_LINK": "Remove link",
+    "CREATE_PUBLIC_SHARING": "Create public link",
+    "PUBLIC_LINK_CREATED": "Public link created",
+    "PUBLIC_LINK_ENABLED": "Public link enabled",
+    "COLLECT_PHOTOS": "Collect photos",
+    "PUBLIC_COLLECT_SUBTEXT": "Allow people with the link to also add photos to the shared album.",
+    "STOP_EXPORT": "Stop",
+    "EXPORT_PROGRESS": "<a>{{progress.success, number}} / {{progress.total, number}}</a> items synced",
+    "MIGRATING_EXPORT": "Preparing...",
+    "RENAMING_COLLECTION_FOLDERS": "Renaming album folders...",
+    "TRASHING_DELETED_FILES": "Trashing deleted files...",
+    "TRASHING_DELETED_COLLECTIONS": "Trashing deleted albums...",
+    "EXPORT_NOTIFICATION": {
+        "START": "Export started",
+        "IN_PROGRESS": "Export already in progress",
+        "FINISH": "Export finished",
+        "UP_TO_DATE": "No new files to export"
+    },
+    "CONTINUOUS_EXPORT": "Sync continuously",
+    "TOTAL_ITEMS": "Total items",
+    "PENDING_ITEMS": "Pending items",
+    "EXPORT_STARTING": "Export starting...",
+    "DELETE_ACCOUNT_REASON_LABEL": "What is the main reason you are deleting your account?",
+    "DELETE_ACCOUNT_REASON_PLACEHOLDER": "Select a reason",
+    "DELETE_REASON": {
+        "MISSING_FEATURE": "It's missing a key feature that I need",
+        "BROKEN_BEHAVIOR": "The app or a certain feature does not behave as I think it should",
+        "FOUND_ANOTHER_SERVICE": "I found another service that I like better",
+        "NOT_LISTED": "My reason isn't listed"
+    },
+    "DELETE_ACCOUNT_FEEDBACK_LABEL": "We are sorry to see you go. Please explain why you are leaving to help us improve.",
+    "DELETE_ACCOUNT_FEEDBACK_PLACEHOLDER": "Feedback",
+    "CONFIRM_DELETE_ACCOUNT_CHECKBOX_LABEL": "Yes, I want to permanently delete this account and all its data",
+    "CONFIRM_DELETE_ACCOUNT": "Confirm Account Deletion",
+    "FEEDBACK_REQUIRED": "Kindly help us with this information",
+    "FEEDBACK_REQUIRED_FOUND_ANOTHER_SERVICE": "What does the other service do better?",
+    "RECOVER_TWO_FACTOR": "Recover two-factor",
+    "at": "at",
+    "AUTH_NEXT": "next",
+    "AUTH_DOWNLOAD_MOBILE_APP": "Download our mobile app to manage your secrets",
+    "HIDDEN": "Hidden",
+    "HIDE": "Hide",
+    "UNHIDE": "Unhide",
+    "UNHIDE_TO_COLLECTION": "Unhide to album",
+    "SORT_BY": "Sort by",
+    "NEWEST_FIRST": "Newest first",
+    "OLDEST_FIRST": "Oldest first",
+    "CONVERSION_FAILED_NOTIFICATION_MESSAGE": "This file could not be previewed. Click here to download the original.",
+    "SELECT_COLLECTION": "Select album",
+    "PIN_ALBUM": "Pin album",
+    "UNPIN_ALBUM": "Unpin album",
+    "DOWNLOAD_COMPLETE": "Download complete",
+    "DOWNLOADING_COLLECTION": "Downloading {{name}}",
+    "DOWNLOAD_FAILED": "Download failed",
+    "DOWNLOAD_PROGRESS": "{{progress.current}} / {{progress.total}} files",
+    "CRASH_REPORTING": "Crash reporting",
+    "CHRISTMAS": "Christmas",
+    "CHRISTMAS_EVE": "Christmas Eve",
+    "NEW_YEAR": "New Year",
+    "NEW_YEAR_EVE": "New Year's Eve",
+    "IMAGE": "Image",
+    "VIDEO": "Video",
+    "LIVE_PHOTO": "Live Photo",
+    "CONVERT": "Convert",
+    "CONFIRM_EDITOR_CLOSE_MESSAGE": "Are you sure you want to close the editor?",
+    "CONFIRM_EDITOR_CLOSE_DESCRIPTION": "Download your edited image or save a copy to ente to persist your changes.",
+    "BRIGHTNESS": "Brightness",
+    "CONTRAST": "Contrast",
+    "SATURATION": "Saturation",
+    "BLUR": "Blur",
+    "INVERT_COLORS": "Invert Colors",
+    "ASPECT_RATIO": "Aspect Ratio",
+    "SQUARE": "Square",
+    "ROTATE_LEFT": "Rotate Left",
+    "ROTATE_RIGHT": "Rotate Right",
+    "FLIP_VERTICALLY": "Flip Vertically",
+    "FLIP_HORIZONTALLY": "Flip Horizontally",
+    "DOWNLOAD_EDITED": "Download Edited",
+    "SAVE_A_COPY_TO_ENTE": "Save a copy to ente",
+    "RESTORE_ORIGINAL": "Restore Original",
+    "TRANSFORM": "Transform",
+    "COLORS": "Colors",
+    "FLIP": "Flip",
+    "ROTATION": "Rotation",
+    "RESET": "Reset",
+    "PHOTO_EDITOR": "Photo Editor",
+    "FASTER_UPLOAD": "Faster uploads",
+    "FASTER_UPLOAD_DESCRIPTION": "Route uploads through nearby servers",
+    "STATUS": "Status",
+    "INDEXED_ITEMS": "Indexed items",
+    "CACHE_DIRECTORY": "Cache folder",
+    "DELETE_PASSKEY": "Delete passkey",
+    "DELETE_PASSKEY_CONFIRMATION": "Are you sure you want to delete this passkey? This action is irreversible.",
+    "RENAME_PASSKEY": "Rename passkey",
+    "ADD_PASSKEY": "Add passkey",
+    "ENTER_PASSKEY_NAME": "Enter passkey name",
+    "PASSKEYS_DESCRIPTION": "Passkeys are a modern and secure second-factor for your Ente account. They use on-device biometric authentication for convenience and security.",
+    "CREATED_AT": "Created at",
+    "PASSKEY_LOGIN_FAILED": "Passkey login failed",
+    "PASSKEY_LOGIN_URL_INVALID": "The login URL is invalid.",
+    "PASSKEY_LOGIN_ERRORED": "An error occurred while logging in with passkey.",
+"TRY_AGAIN": "Try again",
+    "PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER": "Follow the steps from your browser to continue logging in.",
+    "LOGIN_WITH_PASSKEY": "Login with passkey"
+}
+

+ 1 - 0
apps/accounts/public/next.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

+ 1 - 0
apps/accounts/public/vercel.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

+ 3 - 0
apps/accounts/sentry.client.config.ts

@@ -0,0 +1,3 @@
+import { initSentry } from '@ente/shared/sentry/config/sentry.config.base';
+
+initSentry('https://bd3656fc40d74d5e8f278132817963a3@sentry.ente.io/2');

+ 3 - 0
apps/accounts/sentry.properties

@@ -0,0 +1,3 @@
+defaults.url=https://sentry.ente.io/
+defaults.org=ente
+defaults.project=photos-web

+ 0 - 0
apps/accounts/sentry.server.config.ts


+ 124 - 0
apps/accounts/src/pages/_app.tsx

@@ -0,0 +1,124 @@
+import { CacheProvider } from '@emotion/react';
+import { APPS } from '@ente/shared/apps/constants';
+import { EnteAppProps } from '@ente/shared/apps/types';
+import { Overlay } from '@ente/shared/components/Container';
+import DialogBoxV2 from '@ente/shared/components/DialogBoxV2';
+import {
+    DialogBoxAttributesV2,
+    SetDialogBoxAttributesV2,
+} from '@ente/shared/components/DialogBoxV2/types';
+import EnteSpinner from '@ente/shared/components/EnteSpinner';
+import AppNavbar from '@ente/shared/components/Navbar/app';
+import { useLocalState } from '@ente/shared/hooks/useLocalState';
+import { setupI18n } from '@ente/shared/i18n';
+import HTTPService from '@ente/shared/network/HTTPService';
+import { LS_KEYS, getData } from '@ente/shared/storage/localStorage';
+import { getTheme } from '@ente/shared/themes';
+import { THEME_COLOR } from '@ente/shared/themes/constants';
+import createEmotionCache from '@ente/shared/themes/createEmotionCache';
+import { CssBaseline, useMediaQuery } from '@mui/material';
+import { ThemeProvider } from '@mui/material/styles';
+import { useRouter } from 'next/router';
+import { createContext, useEffect, useState } from 'react';
+import 'styles/global.css';
+
+interface AppContextProps {
+    isMobile: boolean;
+    showNavBar: (show: boolean) => void;
+    setDialogBoxAttributesV2: SetDialogBoxAttributesV2;
+}
+
+export const AppContext = createContext<AppContextProps>({} as AppContextProps);
+
+// Client-side cache, shared for the whole session of the user in the browser.
+const clientSideEmotionCache = createEmotionCache();
+
+export default function App(props: EnteAppProps) {
+    const [isI18nReady, setIsI18nReady] = useState<boolean>(false);
+
+    const [showNavbar, setShowNavBar] = useState(false);
+
+    const [dialogBoxAttributeV2, setDialogBoxAttributesV2] =
+        useState<DialogBoxAttributesV2>();
+
+    const [dialogBoxV2View, setDialogBoxV2View] = useState(false);
+
+    useEffect(() => {
+        setDialogBoxV2View(true);
+    }, [dialogBoxAttributeV2]);
+
+    const showNavBar = (show: boolean) => setShowNavBar(show);
+
+    const isMobile = useMediaQuery('(max-width:428px)');
+
+    const router = useRouter();
+
+    const {
+        Component,
+        emotionCache = clientSideEmotionCache,
+        pageProps,
+    } = props;
+
+    const [themeColor] = useLocalState(LS_KEYS.THEME, THEME_COLOR.DARK);
+
+    useEffect(() => {
+        setupI18n().finally(() => setIsI18nReady(true));
+    }, []);
+
+    const setupPackageName = () => {
+        const pkg = getData(LS_KEYS.CLIENT_PACKAGE);
+        if (!pkg) return;
+        HTTPService.setHeaders({
+            'X-Client-Package': pkg.name,
+        });
+    };
+
+    useEffect(() => {
+        router.events.on('routeChangeComplete', setupPackageName);
+        return () => {
+            router.events.off('routeChangeComplete', setupPackageName);
+        };
+    }, [router.events]);
+
+    const closeDialogBoxV2 = () => setDialogBoxV2View(false);
+
+    const theme = getTheme(themeColor, APPS.PHOTOS);
+
+    return (
+        <CacheProvider value={emotionCache}>
+            <ThemeProvider theme={theme}>
+                <CssBaseline enableColorScheme />
+                <DialogBoxV2
+                    sx={{ zIndex: 1600 }}
+                    open={dialogBoxV2View}
+                    onClose={closeDialogBoxV2}
+                    attributes={dialogBoxAttributeV2 as any}
+                />
+
+                <AppContext.Provider
+                    value={{
+                        isMobile,
+                        showNavBar,
+                        setDialogBoxAttributesV2:
+                            setDialogBoxAttributesV2 as any,
+                    }}>
+                    {!isI18nReady && (
+                        <Overlay
+                            sx={(theme) => ({
+                                display: 'flex',
+                                justifyContent: 'center',
+                                alignItems: 'center',
+                                zIndex: 2000,
+                                backgroundColor: (theme as any).colors
+                                    .background.base,
+                            })}>
+                            <EnteSpinner />
+                        </Overlay>
+                    )}
+                    {showNavbar && <AppNavbar isMobile={isMobile} />}
+                    <Component {...pageProps} />
+                </AppContext.Provider>
+            </ThemeProvider>
+        </CacheProvider>
+    );
+}

+ 7 - 0
apps/accounts/src/pages/_document.tsx

@@ -0,0 +1,7 @@
+import DocumentPage, {
+    EnteDocumentProps,
+} from '@ente/shared/next/pages/_document';
+
+export default function Document(props: EnteDocumentProps) {
+    return <DocumentPage {...props} />;
+}

+ 59 - 0
apps/accounts/src/pages/account-handoff.tsx

@@ -0,0 +1,59 @@
+import { VerticallyCentered } from '@ente/shared/components/Container';
+import EnteSpinner from '@ente/shared/components/EnteSpinner';
+import { ACCOUNTS_PAGES } from '@ente/shared/constants/pages';
+import HTTPService from '@ente/shared/network/HTTPService';
+import { logError } from '@ente/shared/sentry';
+import { LS_KEYS, getData, setData } from '@ente/shared/storage/localStorage';
+import { useRouter } from 'next/router';
+import { useEffect } from 'react';
+
+const AccountHandoff = () => {
+    const router = useRouter();
+
+    const retrieveAccountData = () => {
+        try {
+            extractAccountsToken();
+
+            router.push(ACCOUNTS_PAGES.PASSKEYS);
+        } catch (e) {
+            logError(e, 'Failed to deserialize and set passed user data');
+            router.push(ACCOUNTS_PAGES.LOGIN);
+        }
+    };
+
+    const getClientPackageName = () => {
+        const urlParams = new URLSearchParams(window.location.search);
+        const pkg = urlParams.get('package');
+        if (!pkg) return;
+        setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg });
+        HTTPService.setHeaders({
+            'X-Client-Package': pkg,
+        });
+    };
+
+    const extractAccountsToken = () => {
+        const urlParams = new URLSearchParams(window.location.search);
+        const token = urlParams.get('token');
+        if (!token) {
+            throw new Error('token not found');
+        }
+
+        const user = getData(LS_KEYS.USER) || {};
+        user.token = token;
+
+        setData(LS_KEYS.USER, user);
+    };
+
+    useEffect(() => {
+        getClientPackageName();
+        retrieveAccountData();
+    }, []);
+
+    return (
+        <VerticallyCentered>
+            <EnteSpinner />
+        </VerticallyCentered>
+    );
+};
+
+export default AccountHandoff;

+ 17 - 0
apps/accounts/src/pages/credentials/index.tsx

@@ -0,0 +1,17 @@
+import CredentialPage from '@ente/accounts/pages/credentials';
+import { useRouter } from 'next/router';
+import { AppContext } from '../_app';
+import { useContext } from 'react';
+import { APPS } from '@ente/shared/apps/constants';
+
+export default function Credential() {
+    const appContext = useContext(AppContext);
+    const router = useRouter();
+    return (
+        <CredentialPage
+            appContext={appContext}
+            router={router}
+            appName={APPS.ACCOUNTS}
+        />
+    );
+}

+ 17 - 0
apps/accounts/src/pages/generate/index.tsx

@@ -0,0 +1,17 @@
+import GeneratePage from '@ente/accounts/pages/generate';
+import { APPS } from '@ente/shared/apps/constants';
+import { useRouter } from 'next/router';
+import { AppContext } from 'pages/_app';
+import { useContext } from 'react';
+
+export default function Generate() {
+	const appContext = useContext(AppContext);
+	const router = useRouter();
+	return (
+		<GeneratePage
+			appContext={appContext}
+			router={router}
+			appName={APPS.ACCOUNTS}
+		/>
+	);
+}

+ 13 - 0
apps/accounts/src/pages/index.tsx

@@ -0,0 +1,13 @@
+import { useRouter } from 'next/router';
+import { useEffect } from 'react';
+
+const Index = () => {
+    const router = useRouter();
+
+    useEffect(() => {
+        router.push('/login');
+    }, []);
+    return <></>;
+};
+
+export default Index;

+ 17 - 0
apps/accounts/src/pages/login/index.tsx

@@ -0,0 +1,17 @@
+import LoginPage from '@ente/accounts/pages/login';
+import { useRouter } from 'next/router';
+import { AppContext } from '../_app';
+import { useContext } from 'react';
+import { APPS } from '@ente/shared/apps/constants';
+
+export default function Login() {
+    const appContext = useContext(AppContext);
+    const router = useRouter();
+    return (
+        <LoginPage
+            appContext={appContext}
+            router={router}
+            appName={APPS.ACCOUNTS}
+        />
+    );
+}

+ 71 - 0
apps/accounts/src/pages/passkeys/DeletePasskeyModal.tsx

@@ -0,0 +1,71 @@
+import DialogBoxV2 from '@ente/shared/components/DialogBoxV2';
+import EnteButton from '@ente/shared/components/EnteButton';
+import { Button, Stack, Typography } from '@mui/material';
+import { AppContext } from 'pages/_app';
+import { useContext, useState } from 'react';
+import { deletePasskey } from 'services/passkeysService';
+import { PasskeysContext } from '.';
+import { t } from 'i18next';
+
+interface IProps {
+    open: boolean;
+    onClose: () => void;
+}
+
+const DeletePasskeyModal = (props: IProps) => {
+    const { isMobile } = useContext(AppContext);
+    const { selectedPasskey, setShowPasskeyDrawer } =
+        useContext(PasskeysContext);
+
+    const [loading, setLoading] = useState(false);
+
+    const doDelete = async () => {
+        if (!selectedPasskey) return;
+        setLoading(true);
+        try {
+            await deletePasskey(selectedPasskey.id);
+        } catch (error) {
+            console.error(error);
+            return;
+        } finally {
+            setLoading(false);
+        }
+        props.onClose();
+        setShowPasskeyDrawer(false);
+    };
+
+    return (
+        <DialogBoxV2
+            fullWidth
+            open={props.open}
+            onClose={props.onClose}
+            fullScreen={isMobile}
+            attributes={{
+                title: t('DELETE_PASSKEY'),
+                secondary: {
+                    action: props.onClose,
+                    text: t('CANCEL'),
+                },
+            }}>
+            <Stack spacing={'8px'}>
+                <Typography>{t('DELETE_PASSKEY_CONFIRMATION')}</Typography>
+                <EnteButton
+                    type="submit"
+                    size="large"
+                    color="critical"
+                    loading={loading}
+                    onClick={doDelete}>
+                    {t('DELETE')}
+                </EnteButton>
+                <Button
+                    size="large"
+                    color={'secondary'}
+                    onClick={props.onClose}>
+                    {t('CANCEL')}
+                </Button>
+            </Stack>
+        </DialogBoxV2>
+    );
+};
+
+export default DeletePasskeyModal;

+ 100 - 0
apps/accounts/src/pages/passkeys/ManagePasskeyDrawer.tsx

@@ -0,0 +1,100 @@
+import { EnteDrawer } from '@ente/shared/components/EnteDrawer';
+import { PasskeysContext } from '.';
+import { Stack } from '@mui/material';
+import Titlebar from '@ente/shared/components/Titlebar';
+import { MenuItemGroup } from '@ente/shared/components/Menu/MenuItemGroup';
+import { EnteMenuItem } from '@ente/shared/components/Menu/EnteMenuItem';
+import { useContext, useState } from 'react';
+import EditIcon from '@mui/icons-material/Edit';
+import DeleteIcon from '@mui/icons-material/Delete';
+import MenuItemDivider from '@ente/shared/components/Menu/MenuItemDivider';
+import DeletePasskeyModal from './DeletePasskeyModal';
+import RenamePasskeyModal from './RenamePasskeyModal';
+import InfoItem from '@ente/shared/components/Info/InfoItem';
+import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
+import { formatDateTimeFull } from '@ente/shared/time/format';
+import { t } from 'i18next';
+
+interface IProps {
+    open: boolean;
+}
+
+const ManagePasskeyDrawer = (props: IProps) => {
+    const { setShowPasskeyDrawer, refreshPasskeys, selectedPasskey } =
+        useContext(PasskeysContext);
+
+    const [showDeletePasskeyModal, setShowDeletePasskeyModal] = useState(false);
+    const [showRenamePasskeyModal, setShowRenamePasskeyModal] = useState(false);
+
+    return (
+        <>
+            <EnteDrawer
+                anchor="right"
+                open={props.open}
+                onClose={() => {
+                    setShowPasskeyDrawer(false);
+                }}>
+                {selectedPasskey && (
+                    <>
+                        <Stack spacing={'4px'} py={'12px'}>
+                            <Titlebar
+                                onClose={() => {
+                                    setShowPasskeyDrawer(false);
+                                }}
+                                title="Manage Passkey"
+                                onRootClose={() => {
+                                    setShowPasskeyDrawer(false);
+                                }}
+                            />
+                            <InfoItem
+                                icon={<CalendarTodayIcon />}
+                                title={t('CREATED_AT')}
+                                caption={
+                                    `${formatDateTimeFull(
+                                        selectedPasskey.createdAt / 1000
+                                    )}` || ''
+                                }
+                                loading={!selectedPasskey}
+                                hideEditOption
+                            />
+                            <MenuItemGroup>
+                                <EnteMenuItem
+                                    onClick={() => {
+                                        setShowRenamePasskeyModal(true);
+                                    }}
+                                    startIcon={<EditIcon />}
+                                    label={'Rename Passkey'}
+                                />
+                                <MenuItemDivider />
+                                <EnteMenuItem
+                                    onClick={() => {
+                                        setShowDeletePasskeyModal(true);
+                                    }}
+                                    startIcon={<DeleteIcon />}
+                                    label={'Delete Passkey'}
+                                    color="critical"
+                                />
+                            </MenuItemGroup>
+                        </Stack>
+                    </>
+                )}
+            </EnteDrawer>
+            <DeletePasskeyModal
+                open={showDeletePasskeyModal}
+                onClose={() => {
+                    setShowDeletePasskeyModal(false);
+                    refreshPasskeys();
+                }}
+            />
+            <RenamePasskeyModal
+                open={showRenamePasskeyModal}
+                onClose={() => {
+                    setShowRenamePasskeyModal(false);
+                    refreshPasskeys();
+                }}
+            />
+        </>
+    );
+};
+
+export default ManagePasskeyDrawer;

+ 29 - 0
apps/accounts/src/pages/passkeys/PasskeyListItem.tsx

@@ -0,0 +1,29 @@
+import { EnteMenuItem } from '@ente/shared/components/Menu/EnteMenuItem';
+import { Passkey } from 'types/passkey';
+import ChevronRightIcon from '@mui/icons-material/ChevronRight';
+import { useContext } from 'react';
+import { PasskeysContext } from '.';
+import KeyIcon from '@mui/icons-material/Key';
+
+interface IProps {
+    passkey: Passkey;
+}
+
+const PasskeyListItem = (props: IProps) => {
+    const { setSelectedPasskey, setShowPasskeyDrawer } =
+        useContext(PasskeysContext);
+
+    return (
+        <EnteMenuItem
+            onClick={() => {
+                setSelectedPasskey(props.passkey);
+                setShowPasskeyDrawer(true);
+            }}
+            startIcon={<KeyIcon />}
+            endIcon={<ChevronRightIcon />}
+            label={props.passkey?.friendlyName}
+        />
+    );
+};
+
+export default PasskeyListItem;

+ 26 - 0
apps/accounts/src/pages/passkeys/PasskeysList.tsx

@@ -0,0 +1,26 @@
+import { MenuItemGroup } from '@ente/shared/components/Menu/MenuItemGroup';
+import { Passkey } from 'types/passkey';
+import PasskeyListItem from './PasskeyListItem';
+import MenuItemDivider from '@ente/shared/components/Menu/MenuItemDivider';
+import { Fragment } from 'react';
+
+interface IProps {
+    passkeys: Passkey[];
+}
+
+const PasskeyComponent = (props: IProps) => {
+    return (
+        <>
+            <MenuItemGroup>
+                {props.passkeys?.map((passkey, i) => (
+                    <Fragment key={passkey.id}>
+                        <PasskeyListItem passkey={passkey} />
+                        {i < props.passkeys.length - 1 && <MenuItemDivider />}
+                    </Fragment>
+                ))}
+            </MenuItemGroup>
+        </>
+    );
+};
+
+export default PasskeyComponent;

+ 56 - 0
apps/accounts/src/pages/passkeys/RenamePasskeyModal.tsx

@@ -0,0 +1,56 @@
+import DialogBoxV2 from '@ente/shared/components/DialogBoxV2';
+import { AppContext } from 'pages/_app';
+import { useContext } from 'react';
+import { PasskeysContext } from '.';
+import SingleInputForm from '@ente/shared/components/SingleInputForm';
+import { t } from 'i18next';
+import { renamePasskey } from 'services/passkeysService';
+
+interface IProps {
+    open: boolean;
+    onClose: () => void;
+}
+
+const RenamePasskeyModal = (props: IProps) => {
+    const { isMobile } = useContext(AppContext);
+    const { selectedPasskey } = useContext(PasskeysContext);
+
+    const onSubmit = async (inputValue: string) => {
+        if (!selectedPasskey) return;
+        try {
+            await renamePasskey(selectedPasskey.id, inputValue);
+        } catch (error) {
+            console.error(error);
+            return;
+        }
+
+        props.onClose();
+    };
+
+    return (
+        <DialogBoxV2
+            fullWidth
+            open={props.open}
+            onClose={props.onClose}
+            fullScreen={isMobile}
+            attributes={{
+                title: t('RENAME_PASSKEY'),
+                secondary: {
+                    action: props.onClose,
+                    text: t('CANCEL'),
+                },
+            }}>
+            <SingleInputForm
+                initialValue={selectedPasskey?.friendlyName}
+                callback={onSubmit}
+                placeholder={t('ENTER_PASSKEY_NAME')}
+                buttonText={t('RENAME')}
+                fieldType="text"
+                secondaryButtonAction={props.onClose}
+                submitButtonProps={{ sx: { mt: 1, mb: 2 } }}
+            />
+        </DialogBoxV2>
+    );
+};
+
+export default RenamePasskeyModal;

+ 6 - 0
apps/accounts/src/pages/passkeys/finish.tsx

@@ -0,0 +1,6 @@
+import PasskeysFinishPage from '@ente/accounts/pages/passkeys/finish';
+const PasskeysFinish = () => {
+    return <PasskeysFinishPage />;
+};
+
+export default PasskeysFinish;

+ 275 - 0
apps/accounts/src/pages/passkeys/flow/index.tsx

@@ -0,0 +1,275 @@
+import { APPS, CLIENT_PACKAGE_NAMES } from '@ente/shared/apps/constants';
+import {
+    CenteredFlex,
+    VerticallyCentered,
+} from '@ente/shared/components/Container';
+import EnteButton from '@ente/shared/components/EnteButton';
+import EnteSpinner from '@ente/shared/components/EnteSpinner';
+import FormPaper from '@ente/shared/components/Form/FormPaper';
+import HTTPService from '@ente/shared/network/HTTPService';
+import { logError } from '@ente/shared/sentry';
+import { LS_KEYS, setData } from '@ente/shared/storage/localStorage';
+import InfoIcon from '@mui/icons-material/Info';
+import { Box, Typography } from '@mui/material';
+import { t } from 'i18next';
+import _sodium from 'libsodium-wrappers';
+import Image from 'next/image';
+import { useEffect, useState } from 'react';
+import {
+    BeginPasskeyAuthenticationResponse,
+    beginPasskeyAuthentication,
+    finishPasskeyAuthentication,
+} from 'services/passkeysService';
+
+const PasskeysFlow = () => {
+    const [errored, setErrored] = useState(false);
+
+    const [invalidInfo, setInvalidInfo] = useState(false);
+
+    const [loading, setLoading] = useState(true);
+
+    const init = async () => {
+        const searchParams = new URLSearchParams(window.location.search);
+
+        // get redirect from the query params
+        const redirect = searchParams.get('redirect') as string;
+
+        const redirectURL = new URL(redirect);
+        if (process.env.NEXT_PUBLIC_DISABLE_REDIRECT_CHECK !== 'true') {
+            if (
+                redirect !== '' &&
+                !redirectURL.host.endsWith('.ente.io') &&
+                redirectURL.protocol !== 'ente:' &&
+                redirectURL.protocol !== 'enteauth:'
+            ) {
+                setInvalidInfo(true);
+                setLoading(false);
+                return;
+            }
+        }
+
+        let pkg = CLIENT_PACKAGE_NAMES.get(APPS.PHOTOS);
+        if (redirectURL.protocol === 'enteauth:') {
+            pkg = CLIENT_PACKAGE_NAMES.get(APPS.AUTH);
+        } else if (redirectURL.hostname.startsWith('accounts')) {
+            pkg = CLIENT_PACKAGE_NAMES.get(APPS.ACCOUNTS);
+        }
+
+        setData(LS_KEYS.CLIENT_PACKAGE, { name: pkg });
+        HTTPService.setHeaders({
+            'X-Client-Package': pkg,
+        });
+
+        // get passkeySessionID from the query params
+        const passkeySessionID = searchParams.get('passkeySessionID') as string;
+
+        setLoading(true);
+
+        let beginData: BeginPasskeyAuthenticationResponse;
+
+        try {
+            beginData = await beginAuthentication(passkeySessionID);
+        } catch (e) {
+            logError(e, "Couldn't begin passkey authentication");
+            setErrored(true);
+            return;
+        } finally {
+            setLoading(false);
+        }
+
+        let credential: Credential | null = null;
+
+        let tries = 0;
+        const maxTries = 3;
+
+        while (tries < maxTries) {
+            try {
+                credential = await getCredential(beginData.options.publicKey);
+            } catch (e) {
+                logError(e, "Couldn't get credential");
+                continue;
+            } finally {
+                tries++;
+            }
+
+            break;
+        }
+
+        if (!credential) {
+            setErrored(true);
+            return;
+        }
+
+        setLoading(true);
+
+        let finishData;
+
+        try {
+            finishData = await finishAuthentication(
+                credential,
+                passkeySessionID,
+                beginData.ceremonySessionID
+            );
+        } catch (e) {
+            logError(e, "Couldn't finish passkey authentication");
+            setErrored(true);
+            setLoading(false);
+            return;
+        }
+
+        const encodedResponse = _sodium.to_base64(JSON.stringify(finishData));
+
+        window.location.href = `${redirect}?response=${encodedResponse}`;
+    };
+
+    const beginAuthentication = async (sessionId: string) => {
+        const data = await beginPasskeyAuthentication(sessionId);
+        return data;
+    };
+
+    const getCredential = async (
+        publicKey: any,
+        timeoutMillis: number = 60000 // Default timeout of 60 seconds
+    ): Promise<Credential | null> => {
+        publicKey.challenge = _sodium.from_base64(
+            publicKey.challenge,
+            _sodium.base64_variants.URLSAFE_NO_PADDING
+        );
+        publicKey.allowCredentials?.forEach(function (listItem: any) {
+            listItem.id = _sodium.from_base64(
+                listItem.id,
+                _sodium.base64_variants.URLSAFE_NO_PADDING
+            );
+        });
+        publicKey.timeout = timeoutMillis;
+        const credential = await navigator.credentials.get({
+            publicKey,
+        });
+
+        return credential;
+    };
+
+    const finishAuthentication = async (
+        credential: Credential,
+        sessionId: string,
+        ceremonySessionId: string
+    ) => {
+        const data = await finishPasskeyAuthentication(
+            credential,
+            sessionId,
+            ceremonySessionId
+        );
+        return data;
+    };
+
+    useEffect(() => {
+        init();
+    }, []);
+
+    if (loading) {
+        return (
+            <VerticallyCentered>
+                <EnteSpinner />
+            </VerticallyCentered>
+        );
+    }
+
+    if (invalidInfo) {
+        return (
+            <Box
+                display="flex"
+                justifyContent="center"
+                alignItems="center"
+                height="100%">
+                <Box maxWidth="30rem">
+                    <FormPaper
+                        style={{
+                            padding: '1rem',
+                        }}>
+                        <InfoIcon />
+                        <Typography fontWeight="bold" variant="h1">
+                            {t('PASSKEY_LOGIN_FAILED')}
+                        </Typography>
+                        <Typography marginTop="1rem">
+                            {t('PASSKEY_LOGIN_URL_INVALID')}
+                        </Typography>
+                    </FormPaper>
+                </Box>
+            </Box>
+        );
+    }
+
+    if (errored) {
+        return (
+            <Box
+                display="flex"
+                justifyContent="center"
+                alignItems="center"
+                height="100%">
+                <Box maxWidth="30rem">
+                    <FormPaper
+                        style={{
+                            padding: '1rem',
+                        }}>
+                        <InfoIcon />
+                        <Typography fontWeight="bold" variant="h1">
+                            {t('PASSKEY_LOGIN_FAILED')}
+                        </Typography>
+                        <Typography marginTop="1rem">
+                            {t('PASSKEY_LOGIN_ERRORED')}
+                        </Typography>
+                        <EnteButton
+                            onClick={() => {
+                                setErrored(false);
+                                init();
+                            }}
+                            fullWidth
+                            style={{
+                                marginTop: '1rem',
+                            }}
+                            color="primary"
+                            type="button"
+                            variant="contained">
+                            {t('TRY_AGAIN')}
+                        </EnteButton>
+                    </FormPaper>
+                </Box>
+            </Box>
+        );
+    }
+
+    return (
+        <>
+            <Box
+                display="flex"
+                justifyContent="center"
+                alignItems="center"
+                height="100%">
+                <Box maxWidth="30rem">
+                    <FormPaper
+                        style={{
+                            padding: '1rem',
+                        }}>
+                        <InfoIcon />
+                        <Typography fontWeight="bold" variant="h1">
+                            {t('LOGIN_WITH_PASSKEY')}
+                        </Typography>
+                        <Typography marginTop="1rem">
+                            {t('PASSKEY_FOLLOW_THE_STEPS_FROM_YOUR_BROWSER')}
+                        </Typography>
+                        <CenteredFlex marginTop="1rem">
+                            <Image
+                                alt="ente Logo Circular"
+                                height={150}
+                                width={150}
+                                src="/images/ente-circular.png"
+                            />
+                        </CenteredFlex>
+                    </FormPaper>
+                </Box>
+            </Box>
+        </>
+    );
+};
+
+export default PasskeysFlow;

+ 167 - 0
apps/accounts/src/pages/passkeys/index.tsx

@@ -0,0 +1,167 @@
+import { CenteredFlex } from '@ente/shared/components/Container';
+import FormPaper from '@ente/shared/components/Form/FormPaper';
+import SingleInputForm from '@ente/shared/components/SingleInputForm';
+import { ACCOUNTS_PAGES } from '@ente/shared/constants/pages';
+import { logError } from '@ente/shared/sentry';
+import { getToken } from '@ente/shared/storage/localStorage/helpers';
+import { Box, Typography } from '@mui/material';
+import { t } from 'i18next';
+import _sodium from 'libsodium-wrappers';
+import { useRouter } from 'next/router';
+import { AppContext } from 'pages/_app';
+import {
+    Dispatch,
+    SetStateAction,
+    createContext,
+    useContext,
+    useEffect,
+    useState,
+} from 'react';
+import { Passkey } from 'types/passkey';
+import {
+    finishPasskeyRegistration,
+    getPasskeyRegistrationOptions,
+    getPasskeys,
+} from '../../services/passkeysService';
+import ManagePasskeyDrawer from './ManagePasskeyDrawer';
+import PasskeysList from './PasskeysList';
+
+export const PasskeysContext = createContext(
+    {} as {
+        selectedPasskey: Passkey | null;
+        setSelectedPasskey: Dispatch<SetStateAction<Passkey | null>>;
+        setShowPasskeyDrawer: Dispatch<SetStateAction<boolean>>;
+        refreshPasskeys: () => void;
+    }
+);
+
+const Passkeys = () => {
+    const { showNavBar } = useContext(AppContext);
+
+    const [selectedPasskey, setSelectedPasskey] = useState<Passkey | null>(
+        null
+    );
+
+    const [showPasskeyDrawer, setShowPasskeyDrawer] = useState(false);
+
+    const [passkeys, setPasskeys] = useState<Passkey[]>([]);
+
+    const router = useRouter();
+
+    const checkLoggedIn = () => {
+        const token = getToken();
+        if (!token) {
+            router.push(ACCOUNTS_PAGES.LOGIN);
+        }
+    };
+
+    const init = async () => {
+        checkLoggedIn();
+        const data = await getPasskeys();
+        setPasskeys(data.passkeys || []);
+    };
+
+    useEffect(() => {
+        showNavBar(true);
+        init();
+    }, []);
+
+    const handleSubmit = async (
+        inputValue: string,
+        setFieldError: (errorMessage: string) => void
+    ) => {
+        let response: {
+            options: {
+                publicKey: PublicKeyCredentialCreationOptions;
+            };
+            sessionID: string;
+        };
+
+        try {
+            response = await getPasskeyRegistrationOptions();
+        } catch {
+            setFieldError('Failed to begin registration');
+            return;
+        }
+
+        const options = response.options;
+
+        options.publicKey.challenge = _sodium.from_base64(
+            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+            // @ts-ignore
+            options.publicKey.challenge
+        );
+        options.publicKey.user.id = _sodium.from_base64(
+            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+            // @ts-ignore
+            options.publicKey.user.id
+        );
+
+        // create new credential
+        let newCredential: Credential | null = null;
+
+        try {
+            newCredential = await navigator.credentials.create(options);
+        } catch (e) {
+            logError(e, 'Error creating credential');
+            setFieldError('Failed to create credential');
+            return;
+        }
+
+        try {
+            await finishPasskeyRegistration(
+                inputValue,
+                newCredential,
+                response.sessionID
+            );
+        } catch {
+            setFieldError('Failed to finish registration');
+            return;
+        }
+
+        init();
+    };
+
+    return (
+        <>
+            <PasskeysContext.Provider
+                value={{
+                    selectedPasskey,
+                    setSelectedPasskey,
+                    setShowPasskeyDrawer,
+                    refreshPasskeys: init,
+                }}>
+                <CenteredFlex>
+                    <Box maxWidth="20rem">
+                        <Box marginBottom="1rem">
+                            <Typography>{t('PASSKEYS_DESCRIPTION')}</Typography>
+                        </Box>
+                        <FormPaper
+                            style={{
+                                padding: '1rem',
+                            }}>
+                            <SingleInputForm
+                                fieldType="text"
+                                placeholder={t('ENTER_PASSKEY_NAME')}
+                                buttonText={t('ADD_PASSKEY')}
+                                initialValue={''}
+                                callback={handleSubmit}
+                                submitButtonProps={{
+                                    sx: {
+                                        marginBottom: 1,
+                                    },
+                                }}
+                            />
+                        </FormPaper>
+                        <Box marginTop="1rem">
+                            <PasskeysList passkeys={passkeys} />
+                        </Box>
+                    </Box>
+                </CenteredFlex>
+                <ManagePasskeyDrawer open={showPasskeyDrawer} />
+            </PasskeysContext.Provider>
+        </>
+    );
+};
+
+export default Passkeys;

+ 17 - 0
apps/accounts/src/pages/recover/index.tsx

@@ -0,0 +1,17 @@
+import RecoverPage from '@ente/accounts/pages/recover';
+import { useRouter } from 'next/router';
+import { AppContext } from 'pages/_app';
+import { useContext } from 'react';
+import { APPS } from '@ente/shared/apps/constants';
+
+export default function Recover() {
+    const appContext = useContext(AppContext);
+    const router = useRouter();
+    return (
+        <RecoverPage
+            appContext={appContext}
+            router={router}
+            appName={APPS.ACCOUNTS}
+        />
+    );
+}

+ 17 - 0
apps/accounts/src/pages/signup/index.tsx

@@ -0,0 +1,17 @@
+import SignupPage from '@ente/accounts/pages/signup';
+import { APPS } from '@ente/shared/apps/constants';
+import { useRouter } from 'next/router';
+import { AppContext } from 'pages/_app';
+import { useContext } from 'react';
+
+export default function Sigup() {
+	const appContext = useContext(AppContext);
+	const router = useRouter();
+	return (
+		<SignupPage
+			appContext={appContext}
+			router={router}
+			appName={APPS.ACCOUNTS}
+		/>
+	);
+}

+ 17 - 0
apps/accounts/src/pages/two-factor/recover/index.tsx

@@ -0,0 +1,17 @@
+import TwoFactorRecoverPage from '@ente/accounts/pages/two-factor/recover';
+import { useRouter } from 'next/router';
+import { AppContext } from 'pages/_app';
+import { useContext } from 'react';
+import { APPS } from '@ente/shared/apps/constants';
+
+export default function TwoFactorRecover() {
+    const appContext = useContext(AppContext);
+    const router = useRouter();
+    return (
+        <TwoFactorRecoverPage
+            appContext={appContext}
+            router={router}
+            appName={APPS.ACCOUNTS}
+        />
+    );
+}

+ 17 - 0
apps/accounts/src/pages/two-factor/setup/index.tsx

@@ -0,0 +1,17 @@
+import TwoFactorSetupPage from '@ente/accounts/pages/two-factor/setup';
+import { useRouter } from 'next/router';
+import { AppContext } from 'pages/_app';
+import { useContext } from 'react';
+import { APPS } from '@ente/shared/apps/constants';
+
+export default function TwoFactorSetup() {
+    const appContext = useContext(AppContext);
+    const router = useRouter();
+    return (
+        <TwoFactorSetupPage
+            appContext={appContext}
+            router={router}
+            appName={APPS.ACCOUNTS}
+        />
+    );
+}

+ 17 - 0
apps/accounts/src/pages/two-factor/verify/index.tsx

@@ -0,0 +1,17 @@
+import TwoFactorVerifyPage from '@ente/accounts/pages/two-factor/verify';
+import { useRouter } from 'next/router';
+import { AppContext } from 'pages/_app';
+import { useContext } from 'react';
+import { APPS } from '@ente/shared/apps/constants';
+
+export default function TwoFactorVerify() {
+    const appContext = useContext(AppContext);
+    const router = useRouter();
+    return (
+        <TwoFactorVerifyPage
+            appContext={appContext}
+            router={router}
+            appName={APPS.ACCOUNTS}
+        />
+    );
+}

+ 17 - 0
apps/accounts/src/pages/verify/index.tsx

@@ -0,0 +1,17 @@
+import VerifyPage from '@ente/accounts/pages/verify';
+import { useRouter } from 'next/router';
+import { AppContext } from 'pages/_app';
+import { useContext } from 'react';
+import { APPS } from '@ente/shared/apps/constants';
+
+export default function Verify() {
+    const appContext = useContext(AppContext);
+    const router = useRouter();
+    return (
+        <VerifyPage
+            appContext={appContext}
+            router={router}
+            appName={APPS.ACCOUNTS}
+        />
+    );
+}

+ 200 - 0
apps/accounts/src/services/passkeysService.ts

@@ -0,0 +1,200 @@
+import HTTPService from '@ente/shared/network/HTTPService';
+import { getEndpoint } from '@ente/shared/network/api';
+import { logError } from '@ente/shared/sentry';
+import { getToken } from '@ente/shared/storage/localStorage/helpers';
+import _sodium from 'libsodium-wrappers';
+const ENDPOINT = getEndpoint();
+
+export const getPasskeys = async () => {
+    try {
+        const token = getToken();
+        if (!token) return;
+        const response = await HTTPService.get(
+            `${ENDPOINT}/passkeys`,
+            {},
+            { 'X-Auth-Token': token }
+        );
+        return await response.data;
+    } catch (e) {
+        logError(e, 'get passkeys failed');
+        throw e;
+    }
+};
+
+export const renamePasskey = async (id: string, name: string) => {
+    try {
+        const token = getToken();
+        if (!token) return;
+        const response = await HTTPService.patch(
+            `${ENDPOINT}/passkeys/${id}`,
+            {},
+            { friendlyName: name },
+            { 'X-Auth-Token': token }
+        );
+        return await response.data;
+    } catch (e) {
+        logError(e, 'rename passkey failed');
+        throw e;
+    }
+};
+
+export const deletePasskey = async (id: string) => {
+    try {
+        const token = getToken();
+        if (!token) return;
+        const response = await HTTPService.delete(
+            `${ENDPOINT}/passkeys/${id}`,
+            {},
+            {},
+            { 'X-Auth-Token': token }
+        );
+        return await response.data;
+    } catch (e) {
+        logError(e, 'delete passkey failed');
+        throw e;
+    }
+};
+
+export const getPasskeyRegistrationOptions = async () => {
+    try {
+        const token = getToken();
+        if (!token) return;
+        const response = await HTTPService.get(
+            `${ENDPOINT}/passkeys/registration/begin`,
+            {},
+            {
+                'X-Auth-Token': token,
+            }
+        );
+        return await response.data;
+    } catch (e) {
+        logError(e, 'get passkey registration options failed');
+        throw e;
+    }
+};
+
+export const finishPasskeyRegistration = async (
+    friendlyName: string,
+    credential: Credential,
+    sessionId: string
+) => {
+    try {
+        const attestationObjectB64 = _sodium.to_base64(
+            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+            // @ts-ignore
+            new Uint8Array(credential.response.attestationObject),
+            _sodium.base64_variants.URLSAFE_NO_PADDING
+        );
+        const clientDataJSONB64 = _sodium.to_base64(
+            // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+            // @ts-ignore
+            new Uint8Array(credential.response.clientDataJSON),
+            _sodium.base64_variants.URLSAFE_NO_PADDING
+        );
+
+        const token = getToken();
+        if (!token) return;
+
+        const response = await HTTPService.post(
+            `${ENDPOINT}/passkeys/registration/finish`,
+            JSON.stringify({
+                id: credential.id,
+                rawId: credential.id,
+                type: credential.type,
+                response: {
+                    attestationObject: attestationObjectB64,
+                    clientDataJSON: clientDataJSONB64,
+                },
+            }),
+            {
+                friendlyName,
+                sessionID: sessionId,
+            },
+            {
+                'X-Auth-Token': token,
+            }
+        );
+        return await response.data;
+    } catch (e) {
+        logError(e, 'finish passkey registration failed');
+        throw e;
+    }
+};
+
+export interface BeginPasskeyAuthenticationResponse {
+    ceremonySessionID: string;
+    options: Options;
+}
+interface Options {
+    publicKey: PublicKeyCredentialRequestOptions;
+}
+
+export const beginPasskeyAuthentication = async (
+    sessionId: string
+): Promise<BeginPasskeyAuthenticationResponse> => {
+    try {
+        const data = await HTTPService.post(
+            `${ENDPOINT}/users/two-factor/passkeys/begin`,
+            {
+                sessionID: sessionId,
+            }
+        );
+
+        return data.data;
+    } catch (e) {
+        logError(e, 'begin passkey authentication failed');
+        throw e;
+    }
+};
+
+export const finishPasskeyAuthentication = async (
+    credential: Credential,
+    sessionId: string,
+    ceremonySessionId: string
+) => {
+    try {
+        const data = await HTTPService.post(
+            `${ENDPOINT}/users/two-factor/passkeys/finish`,
+            {
+                id: credential.id,
+                rawId: credential.id,
+                type: credential.type,
+                response: {
+                    authenticatorData: _sodium.to_base64(
+                        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+                        // @ts-ignore
+                        new Uint8Array(credential.response.authenticatorData),
+                        _sodium.base64_variants.URLSAFE_NO_PADDING
+                    ),
+                    clientDataJSON: _sodium.to_base64(
+                        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+                        // @ts-ignore
+                        new Uint8Array(credential.response.clientDataJSON),
+                        _sodium.base64_variants.URLSAFE_NO_PADDING
+                    ),
+                    signature: _sodium.to_base64(
+                        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+                        // @ts-ignore
+                        new Uint8Array(credential.response.signature),
+                        _sodium.base64_variants.URLSAFE_NO_PADDING
+                    ),
+                    userHandle: _sodium.to_base64(
+                        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+                        // @ts-ignore
+                        new Uint8Array(credential.response.userHandle),
+                        _sodium.base64_variants.URLSAFE_NO_PADDING
+                    ),
+                },
+            },
+            {
+                sessionID: sessionId,
+                ceremonySessionID: ceremonySessionId,
+            }
+        );
+
+        return data.data;
+    } catch (e) {
+        logError(e, 'finish passkey authentication failed');
+        throw e;
+    }
+};

+ 194 - 0
apps/accounts/src/styles/global.css

@@ -0,0 +1,194 @@
+/* inter-regular - latin */
+@font-face {
+    font-family: 'Inter';
+    font-style: normal;
+    font-weight: 400;
+    src: local(''), url('/fonts/inter-v11-latin-500.woff2') format('woff2'),
+        /* Chrome 26+, Opera 23+, Firefox 39+ */
+            url('/fonts/inter-v11-latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+    font-display: swap; /*https://web.dev/font-display/?utm_source=lighthouse&utm_medium=devtools#how-to-avoid-showing-invisible-text*/
+}
+
+/* inter-600 - latin */
+@font-face {
+    font-family: 'Inter';
+    font-style: normal;
+    font-weight: 700;
+    src: local(''), url('/fonts/inter-v11-latin-600.woff2') format('woff2'),
+        /* Chrome 26+, Opera 23+, Firefox 39+ */
+            url('/fonts/inter-v11-latin-600.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+    font-display: swap;
+}
+
+/* inter-800 - latin */
+@font-face {
+    font-family: 'Inter';
+    font-style: normal;
+    font-weight: 900;
+    src: local(''), url('/fonts/inter-v11-latin-800.woff2') format('woff2'),
+        /* Chrome 26+, Opera 23+, Firefox 39+ */
+            url('/fonts/inter-v11-latin-800.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+    font-display: swap;
+}
+
+html,
+body {
+    min-height: 100vh;
+    height: 100vh;
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+}
+
+#__next {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    min-height: 100vh;
+    height: 100vh;
+}
+
+.pswp__button--custom {
+    width: 48px;
+    height: 48px;
+    background: none !important;
+    background-image: none !important;
+    color: #fff;
+}
+
+.pswp__item video {
+    width: 100%;
+    height: 100%;
+}
+
+.pswp-item-container {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    object-fit: contain;
+}
+
+.pswp-item-container > * {
+    position: absolute;
+    transition: opacity 1s ease;
+    max-width: 100%;
+    max-height: 100%;
+}
+
+.pswp-item-container > img {
+    opacity: 1;
+}
+
+.pswp-item-container > video {
+    opacity: 0;
+}
+
+.pswp-item-container > div.download-banner {
+    width: 100%;
+    height: 16vh;
+    padding: 2vh 0;
+    background-color: #151414;
+    color: #ddd;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: space-around;
+    opacity: 0.8;
+    font-size: 20px;
+}
+
+.download-banner > a {
+    width: 130px;
+}
+
+.pswp__img {
+    object-fit: contain;
+}
+
+.pswp__button--arrow--left,
+.pswp__button--arrow--right {
+    color: #fff;
+    background-color: #333333 !important;
+    border-radius: 50%;
+    width: 56px;
+    height: 56px;
+}
+.pswp__button--arrow--left::before,
+.pswp__button--arrow--right::before {
+    background: none !important;
+}
+
+.pswp__button--arrow--left {
+    margin-left: 20px;
+}
+
+.pswp__button--arrow--right {
+    margin-right: 20px;
+}
+
+.pswp-custom-caption-container {
+    width: 100%;
+    display: flex;
+    justify-content: flex-end;
+    bottom: 56px;
+    background-color: transparent !important;
+}
+
+.pswp__caption--empty {
+    display: none;
+}
+
+.bg-upload-progress-bar {
+    background-color: #51cd7c;
+}
+
+.carousel-inner {
+    padding-bottom: 50px !important;
+}
+
+.carousel-indicators li {
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+    margin-right: 12px;
+}
+
+.carousel-indicators .active {
+    background-color: #51cd7c;
+}
+
+div.otp-input input {
+    width: 36px !important;
+    height: 36px;
+    margin: 0 10px;
+}
+
+div.otp-input input::placeholder {
+    opacity: 0;
+}
+
+div.otp-input input:not(:placeholder-shown),
+div.otp-input input:focus {
+    border: 2px solid #51cd7c;
+    border-radius: 1px;
+    -webkit-transition: 0.5s;
+    transition: 0.5s;
+    outline: none;
+}
+
+.flash-message {
+    padding: 16px;
+    display: flex;
+    align-items: center;
+}
+
+@-webkit-keyframes rotation {
+    from {
+        -webkit-transform: rotate(0deg);
+    }
+    to {
+        -webkit-transform: rotate(359deg);
+    }
+}

+ 6 - 0
apps/accounts/src/types/passkey.ts

@@ -0,0 +1,6 @@
+export interface Passkey {
+    id: string;
+    userID: number;
+    friendlyName: string;
+    createdAt: number;
+}

+ 25 - 0
apps/accounts/tsconfig.json

@@ -0,0 +1,25 @@
+{
+    "extends": "../../tsconfig.base.json",
+    "compilerOptions": {
+        "baseUrl": "./src",
+        "downlevelIteration": true,
+        "jsx": "preserve",
+        "jsxImportSource": "@emotion/react",
+        "lib": ["dom", "dom.iterable", "esnext", "webworker"],
+        "noImplicitAny": false,
+        "noUnusedLocals": false,
+        "noUnusedParameters": false,
+        "strictNullChecks": false,
+        "target": "es5",
+        "useUnknownInCatchVariables": false
+    },
+    "include": [
+        "next-env.d.ts",
+        "**/*.ts",
+        "**/*.tsx",
+        "**/*.js",
+        "../../packages/shared/themes/mui-theme.d.ts",
+        "../../packages/accounts/**/*.tsx"
+    ],
+    "exclude": ["node_modules", "out", ".next", "thirdparty"]
+}

+ 1 - 2
apps/auth/public/locales/en/translation.json

@@ -615,6 +615,5 @@
     "COLORS": "Colors",
     "FLIP": "Flip",
     "ROTATION": "Rotation",
-    "RESET": "Reset",
-    "PHOTO_EDITOR": "Photo Editor"
+    "RESET": "Reset"
 }

+ 1 - 1
apps/cast/src/pages/slideshow.tsx

@@ -47,7 +47,7 @@ export default function Slideshow() {
                 castCollection.updationTime !== collection.updationTime
             ) {
                 setCastCollection(collection);
-                await syncPublicFiles(token, collection, () => { });
+                await syncPublicFiles(token, collection, () => {});
                 const files = await getLocalFiles(String(collection.id));
                 setCollectionFiles(
                     files.filter((file) => isFileEligibleForCast(file))

+ 1 - 0
apps/photos/public/locales/en/translation.json

@@ -637,6 +637,7 @@
     "VISIT_CAST_ENTE_IO": "Visit cast.ente.io on the device you want to pair.",
     "CAST_AUTO_PAIR_FAILED": "Chromecast Auto Pair failed. Please try again.",
     "CACHE_DIRECTORY": "Cache folder",
+    "PASSKEYS": "Passkeys",
     "FREEHAND": "Freehand",
     "APPLY_CROP": "Apply Crop",
     "PHOTO_EDIT_REQUIRED_TO_SAVE": "At least one transformation or color adjustment must be performed before saving."

+ 7 - 0
apps/photos/src/components/EnteSpinner.tsx

@@ -0,0 +1,7 @@
+import CircularProgress, {
+    CircularProgressProps,
+} from '@mui/material/CircularProgress';
+
+export default function EnteSpinner(props: CircularProgressProps) {
+    return <CircularProgress color="accent" size={32} {...props} />;
+}

+ 34 - 7
apps/photos/src/components/Sidebar/UtilitySection.tsx

@@ -1,22 +1,28 @@
-import { useContext, useState } from 'react';
 import { t } from 'i18next';
+import { useContext, useState } from 'react';
 
 // import FixLargeThumbnails from 'components/FixLargeThumbnail';
 import RecoveryKey from '@ente/shared/components/RecoveryKey';
+import {
+    ACCOUNTS_PAGES,
+    PHOTOS_PAGES as PAGES,
+} from '@ente/shared/constants/pages';
 import TwoFactorModal from 'components/TwoFactor/Modal';
-import { PHOTOS_PAGES as PAGES } from '@ente/shared/constants/pages';
 import { useRouter } from 'next/router';
 import { AppContext } from 'pages/_app';
 // import mlIDbStorage from 'utils/storage/mlIDbStorage';
-import isElectron from 'is-electron';
+import { APPS, CLIENT_PACKAGE_NAMES } from '@ente/shared/apps/constants';
+import ThemeSwitcher from '@ente/shared/components/ThemeSwitcher';
+import { getAccountsURL } from '@ente/shared/network/api';
+import { logError } from '@ente/shared/sentry';
+import { THEME_COLOR } from '@ente/shared/themes/constants';
+import { EnteMenuItem } from 'components/Menu/EnteMenuItem';
 import WatchFolder from 'components/WatchFolder';
+import isElectron from 'is-electron';
+import { getAccountsToken } from 'services/userService';
 import { getDownloadAppMessage } from 'utils/ui';
-
 import { isInternalUser } from 'utils/user';
 import Preferences from './Preferences';
-import { EnteMenuItem } from 'components/Menu/EnteMenuItem';
-import ThemeSwitcher from '@ente/shared/components/ThemeSwitcher';
-import { THEME_COLOR } from '@ente/shared/themes/constants';
 
 export default function UtilitySection({ closeSidebar }) {
     const router = useRouter();
@@ -62,6 +68,21 @@ export default function UtilitySection({ closeSidebar }) {
         router.push(PAGES.CHANGE_EMAIL);
     };
 
+    const redirectToAccountsPage = async () => {
+        closeSidebar();
+
+        try {
+            const accountsToken = await getAccountsToken();
+
+            window.location.href = `${getAccountsURL()}${ACCOUNTS_PAGES.ACCOUNT_HANDOFF
+                }?package=${CLIENT_PACKAGE_NAMES.get(
+                    APPS.PHOTOS
+                )}&token=${accountsToken}`;
+        } catch (e) {
+            logError(e, 'failed to redirect to accounts page');
+        }
+    };
+
     const redirectToDeduplicatePage = () => router.push(PAGES.DEDUPLICATE);
 
     const somethingWentWrong = () =>
@@ -112,6 +133,12 @@ export default function UtilitySection({ closeSidebar }) {
                 label={t('TWO_FACTOR')}
             />
 
+            <EnteMenuItem
+                variant="secondary"
+                onClick={redirectToAccountsPage}
+                label={t('PASSKEYS')}
+            />
+
             <EnteMenuItem
                 variant="secondary"
                 onClick={redirectToChangePasswordPage}

+ 11 - 0
apps/photos/src/pages/passkeys/finish/index.tsx

@@ -0,0 +1,11 @@
+import PasskeysFinishPage from '@ente/accounts/pages/passkeys/finish';
+
+const PasskeysFinish = () => {
+    return (
+        <>
+            <PasskeysFinishPage />
+        </>
+    );
+};
+
+export default PasskeysFinish;

+ 32 - 12
apps/photos/src/services/userService.ts

@@ -1,22 +1,24 @@
-import { getEndpoint, getFamilyPortalURL } from '@ente/shared/network/api';
-import { getData, LS_KEYS } from '@ente/shared/storage/localStorage';
-import localForage from '@ente/shared/storage/localForage';
-import { getToken } from '@ente/shared/storage/localStorage/helpers';
-import HTTPService from '@ente/shared/network/HTTPService';
+import { putAttributes } from '@ente/accounts/api/user';
+import { logoutUser } from '@ente/accounts/services/user';
 import { getRecoveryKey } from '@ente/shared/crypto/helpers';
+import { ApiError } from '@ente/shared/error';
+import HTTPService from '@ente/shared/network/HTTPService';
+import { getEndpoint, getFamilyPortalURL } from '@ente/shared/network/api';
 import { logError } from '@ente/shared/sentry';
+import localForage from '@ente/shared/storage/localForage';
+import { getData, LS_KEYS } from '@ente/shared/storage/localStorage';
+import {
+    getToken,
+    setLocalMapEnabled,
+} from '@ente/shared/storage/localStorage/helpers';
+import { AxiosResponse, HttpStatusCode } from 'axios';
 import {
-    UserDetails,
     DeleteChallengeResponse,
-    GetRemoteStoreValueResponse,
     GetFeatureFlagResponse,
+    GetRemoteStoreValueResponse,
+    UserDetails,
 } from 'types/user';
-import { ApiError } from '@ente/shared/error';
 import { getLocalFamilyData, isPartOfFamily } from 'utils/user/family';
-import { AxiosResponse, HttpStatusCode } from 'axios';
-import { setLocalMapEnabled } from '@ente/shared/storage/localStorage/helpers';
-import { putAttributes } from '@ente/accounts/api/user';
-import { logoutUser } from '@ente/accounts/services/user';
 
 const ENDPOINT = getEndpoint();
 
@@ -66,6 +68,24 @@ export const getFamiliesToken = async () => {
     }
 };
 
+export const getAccountsToken = async () => {
+    try {
+        const token = getToken();
+
+        const resp = await HTTPService.get(
+            `${ENDPOINT}/users/accounts-token`,
+            null,
+            {
+                'X-Auth-Token': token,
+            }
+        );
+        return resp.data['accountsToken'];
+    } catch (e) {
+        logError(e, 'failed to get accounts token');
+        throw e;
+    }
+};
+
 export const getRoadmapRedirectURL = async () => {
     try {
         const token = getToken();

+ 3 - 0
package.json

@@ -10,12 +10,15 @@
         "build:photos": "turbo run build --filter=photos",
         "build:auth": "turbo run build --filter=auth",
         "build:cast": "turbo run build --filter=cast",
+        "build:accounts": "turbo run build --filter=accounts",
         "dev:auth": "turbo run dev --filter=auth",
         "dev:photos": "turbo run dev --filter=photos",
+        "dev:accounts": "turbo run dev --filter=accounts",
         "dev:cast": "turbo run dev --filter=cast",
         "export:photos": "turbo run export --filter=photos",
         "export:auth": "turbo run export --filter=auth",
         "export:cast": "turbo run export --filter=cast",
+        "export:accounts": "turbo run export --filter=accounts",
         "lint": "turbo run lint",
         "albums": "turbo run albums",
         "prepare": "husky install"

+ 46 - 27
packages/accounts/pages/credentials.tsx

@@ -2,57 +2,57 @@ import { useEffect, useState } from 'react';
 
 import { t } from 'i18next';
 
+import {
+    decryptAndStoreToken,
+    generateAndSaveIntermediateKeyAttributes,
+    generateLoginSubKey,
+    saveKeyInSessionStore,
+} from '@ente/shared/crypto/helpers';
 import {
     clearData,
     getData,
     LS_KEYS,
     setData,
 } from '@ente/shared/storage/localStorage';
-import { PAGES } from '../constants/pages';
 import {
-    SESSION_KEYS,
     getKey,
     removeKey,
+    SESSION_KEYS,
     setKey,
 } from '@ente/shared/storage/sessionStorage';
-import {
-    decryptAndStoreToken,
-    generateAndSaveIntermediateKeyAttributes,
-    generateLoginSubKey,
-    saveKeyInSessionStore,
-} from '@ente/shared/crypto/helpers';
+import { PAGES } from '../constants/pages';
 import { generateSRPSetupAttributes } from '../services/srp';
 import { logoutUser } from '../services/user';
 
-import { configureSRP, loginViaSRP } from '../services/srp';
-import { getSRPAttributes } from '../api/srp';
-import { SRPAttributes } from '../types/srp';
-
-import {
-    isFirstLogin,
-    setIsFirstLogin,
-} from '@ente/shared/storage/localStorage/helpers';
-import { KeyAttributes, User } from '@ente/shared/user/types';
+import { VerticallyCentered } from '@ente/shared/components/Container';
+import EnteSpinner from '@ente/shared/components/EnteSpinner';
 import FormPaper from '@ente/shared/components/Form/FormPaper';
-import FormPaperTitle from '@ente/shared/components/Form/FormPaper/Title';
 import FormPaperFooter from '@ente/shared/components/Form/FormPaper/Footer';
+import FormPaperTitle from '@ente/shared/components/Form/FormPaper/Title';
 import LinkButton from '@ente/shared/components/LinkButton';
-import isElectron from 'is-electron';
-import { VerticallyCentered } from '@ente/shared/components/Container';
-import EnteSpinner from '@ente/shared/components/EnteSpinner';
 import VerifyMasterPasswordForm, {
     VerifyMasterPasswordFormProps,
 } from '@ente/shared/components/VerifyMasterPasswordForm';
+import { getAccountsURL } from '@ente/shared/network/api';
+import {
+    isFirstLogin,
+    setIsFirstLogin,
+} from '@ente/shared/storage/localStorage/helpers';
+import { KeyAttributes, User } from '@ente/shared/user/types';
+import isElectron from 'is-electron';
+import { getSRPAttributes } from '../api/srp';
+import { configureSRP, loginViaSRP } from '../services/srp';
+import { SRPAttributes } from '../types/srp';
 // import { APPS, getAppName } from '@ente/shared/apps';
-import { addLocalLog } from '@ente/shared/logging';
+import { APP_HOMES } from '@ente/shared/apps/constants';
+import { PageProps } from '@ente/shared/apps/types';
 import ComlinkCryptoWorker from '@ente/shared/crypto';
 import { B64EncryptionResult } from '@ente/shared/crypto/types';
+import ElectronAPIs from '@ente/shared/electron';
 import { CustomError } from '@ente/shared/error';
-import InMemoryStore, { MS_KEYS } from '@ente/shared/storage/InMemoryStore';
-import { PageProps } from '@ente/shared/apps/types';
-import { APP_HOMES } from '@ente/shared/apps/constants';
+import { addLocalLog } from '@ente/shared/logging';
 import { logError } from '@ente/shared/sentry';
-import ElectronAPIs from '@ente/shared/electron';
+import InMemoryStore, { MS_KEYS } from '@ente/shared/storage/InMemoryStore';
 
 export default function Credentials({
     appContext,
@@ -148,9 +148,28 @@ export default function Credentials({
                     token,
                     id,
                     twoFactorSessionID,
+                    passkeySessionID,
                 } = await loginViaSRP(srpAttributes, kek);
                 setIsFirstLogin(true);
-                if (twoFactorSessionID) {
+                if (passkeySessionID) {
+                    const sessionKeyAttributes =
+                        await cryptoWorker.generateKeyAndEncryptToB64(kek);
+                    setKey(
+                        SESSION_KEYS.KEY_ENCRYPTION_KEY,
+                        sessionKeyAttributes
+                    );
+                    const user = getData(LS_KEYS.USER);
+                    setData(LS_KEYS.USER, {
+                        ...user,
+                        passkeySessionID,
+                        isTwoFactorEnabled: true,
+                        isTwoFactorPasskeysEnabled: true,
+                    });
+                    InMemoryStore.set(MS_KEYS.REDIRECT_URL, PAGES.ROOT);
+                    window.location.href = `${getAccountsURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${window.location.origin
+                        }/passkeys/finish`;
+                    return;
+                } else if (twoFactorSessionID) {
                     const sessionKeyAttributes =
                         await cryptoWorker.generateKeyAndEncryptToB64(kek);
                     setKey(

+ 46 - 0
packages/accounts/pages/passkeys/finish.tsx

@@ -0,0 +1,46 @@
+import { PAGES } from '@ente/accounts/constants/pages';
+import { VerticallyCentered } from '@ente/shared/components/Container';
+import EnteSpinner from '@ente/shared/components/EnteSpinner';
+import InMemoryStore, { MS_KEYS } from '@ente/shared/storage/InMemoryStore';
+import { LS_KEYS, getData, setData } from '@ente/shared/storage/localStorage';
+import { useRouter } from 'next/router';
+import { useEffect } from 'react';
+
+const PasskeysFinishPage = () => {
+    const router = useRouter();
+
+    const init = async () => {
+        // get response from query params
+        const searchParams = new URLSearchParams(window.location.search);
+        const response = searchParams.get('response');
+
+        if (!response) return;
+
+        // decode response
+        const decodedResponse = JSON.parse(atob(response));
+
+        const { keyAttributes, encryptedToken, token, id } = decodedResponse;
+        setData(LS_KEYS.USER, {
+            ...getData(LS_KEYS.USER),
+            token,
+            encryptedToken,
+            id,
+        });
+        setData(LS_KEYS.KEY_ATTRIBUTES, keyAttributes);
+        const redirectURL = InMemoryStore.get(MS_KEYS.REDIRECT_URL);
+        InMemoryStore.delete(MS_KEYS.REDIRECT_URL);
+        router.push(redirectURL ?? PAGES.ROOT);
+    };
+
+    useEffect(() => {
+        init();
+    }, []);
+
+    return (
+        <VerticallyCentered>
+            <EnteSpinner />
+        </VerticallyCentered>
+    );
+};
+
+export default PasskeysFinishPage;

+ 36 - 22
packages/accounts/pages/verify.tsx

@@ -1,35 +1,36 @@
-import { useState, useEffect } from 'react';
-import { Trans } from 'react-i18next';
 import { t } from 'i18next';
+import { useEffect, useState } from 'react';
+import { Trans } from 'react-i18next';
 
-import { LS_KEYS, getData, setData } from '@ente/shared/storage/localStorage';
-import { verifyOtt, sendOtt, putAttributes } from '../api/user';
-import { logoutUser } from '../services/user';
-import { configureSRP } from '../services/srp';
+import { UserVerificationResponse } from '@ente/accounts/types/user';
+import { PageProps } from '@ente/shared/apps/types';
+import { VerticallyCentered } from '@ente/shared/components/Container';
+import EnteSpinner from '@ente/shared/components/EnteSpinner';
+import FormPaper from '@ente/shared/components/Form/FormPaper';
+import FormPaperFooter from '@ente/shared/components/Form/FormPaper/Footer';
+import FormPaperTitle from '@ente/shared/components/Form/FormPaper/Title';
+import LinkButton from '@ente/shared/components/LinkButton';
+import SingleInputForm, {
+    SingleInputFormProps,
+} from '@ente/shared/components/SingleInputForm';
+import { ApiError } from '@ente/shared/error';
+import { getAccountsURL } from '@ente/shared/network/api';
+import InMemoryStore, { MS_KEYS } from '@ente/shared/storage/InMemoryStore';
 import { clearFiles } from '@ente/shared/storage/localForage/helpers';
+import { LS_KEYS, getData, setData } from '@ente/shared/storage/localStorage';
 import {
     getLocalReferralSource,
     setIsFirstLogin,
 } from '@ente/shared/storage/localStorage/helpers';
 import { clearKeys } from '@ente/shared/storage/sessionStorage';
-import { PAGES } from '../constants/pages';
 import { KeyAttributes, User } from '@ente/shared/user/types';
-import { SRPSetupAttributes } from '../types/srp';
 import { Box, Typography } from '@mui/material';
-import FormPaperTitle from '@ente/shared/components/Form/FormPaper/Title';
-import FormPaper from '@ente/shared/components/Form/FormPaper';
-import FormPaperFooter from '@ente/shared/components/Form/FormPaper/Footer';
-import LinkButton from '@ente/shared/components/LinkButton';
-import SingleInputForm, {
-    SingleInputFormProps,
-} from '@ente/shared/components/SingleInputForm';
-import EnteSpinner from '@ente/shared/components/EnteSpinner';
-import { VerticallyCentered } from '@ente/shared/components/Container';
-import InMemoryStore, { MS_KEYS } from '@ente/shared/storage/InMemoryStore';
-import { ApiError } from '@ente/shared/error';
 import { HttpStatusCode } from 'axios';
-import { PageProps } from '@ente/shared/apps/types';
-import { UserVerificationResponse } from '@ente/accounts/types/user';
+import { putAttributes, sendOtt, verifyOtt } from '../api/user';
+import { PAGES } from '../constants/pages';
+import { configureSRP } from '../services/srp';
+import { logoutUser } from '../services/user';
+import { SRPSetupAttributes } from '../types/srp';
 
 export default function VerifyPage({ appContext, router, appName }: PageProps) {
     const [email, setEmail] = useState('');
@@ -69,8 +70,21 @@ export default function VerifyPage({ appContext, router, appName }: PageProps) {
                 token,
                 id,
                 twoFactorSessionID,
+                passkeySessionID,
             } = resp.data as UserVerificationResponse;
-            if (twoFactorSessionID) {
+            if (passkeySessionID) {
+                const user = getData(LS_KEYS.USER);
+                setData(LS_KEYS.USER, {
+                    ...user,
+                    passkeySessionID,
+                    isTwoFactorEnabled: true,
+                    isTwoFactorPasskeysEnabled: true,
+                });
+                setIsFirstLogin(true);
+                window.location.href = `${getAccountsURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${window.location.origin
+                    }/passkeys/finish`;
+                router.push(PAGES.CREDENTIALS);
+            } else if (twoFactorSessionID) {
                 setData(LS_KEYS.USER, {
                     email,
                     twoFactorSessionID,

+ 1 - 0
packages/accounts/types/user.ts

@@ -6,6 +6,7 @@ export interface UserVerificationResponse {
     encryptedToken?: string;
     token?: string;
     twoFactorSessionID: string;
+    passkeySessionID: string;
     srpM2?: string;
 }
 

+ 5 - 1
packages/shared/apps/constants.ts

@@ -1,27 +1,31 @@
-import { AUTH_PAGES, PHOTOS_PAGES } from '../constants/pages';
+import { ACCOUNTS_PAGES, AUTH_PAGES, PHOTOS_PAGES } from '../constants/pages';
 
 export enum APPS {
     PHOTOS = 'PHOTOS',
     AUTH = 'AUTH',
     ALBUMS = 'ALBUMS',
+    ACCOUNTS = 'ACCOUNTS',
 }
 
 export const CLIENT_PACKAGE_NAMES = new Map([
     [APPS.ALBUMS, 'io.ente.albums.web'],
     [APPS.PHOTOS, 'io.ente.photos.web'],
     [APPS.AUTH, 'io.ente.auth.web'],
+    [APPS.ACCOUNTS, 'io.ente.accounts.web'],
 ]);
 
 export const APP_TITLES = new Map([
     [APPS.ALBUMS, 'Ente Albums'],
     [APPS.PHOTOS, 'Ente Photos'],
     [APPS.AUTH, 'Ente Auth'],
+    [APPS.ACCOUNTS, 'Ente Accounts'],
 ]);
 
 export const APP_HOMES = new Map([
     [APPS.ALBUMS, '/'],
     [APPS.PHOTOS, PHOTOS_PAGES.GALLERY],
     [APPS.AUTH, AUTH_PAGES.AUTH],
+    [APPS.ACCOUNTS, ACCOUNTS_PAGES.PASSKEYS],
 ]);
 
 export const OTT_CLIENTS = new Map([

+ 42 - 0
packages/shared/components/CaptionedText.tsx

@@ -0,0 +1,42 @@
+import { ButtonProps, Typography } from '@mui/material';
+import { VerticallyCenteredFlex } from '@ente/shared/components/Container';
+
+interface Iprops {
+    mainText: string;
+    subText?: string;
+    subIcon?: React.ReactNode;
+    color?: ButtonProps['color'];
+}
+
+const getSubTextColor = (color: ButtonProps['color']) => {
+    switch (color) {
+        case 'critical':
+            return 'critical.main';
+        default:
+            return 'text.faint';
+    }
+};
+
+export const CaptionedText = (props: Iprops) => {
+    return (
+        <VerticallyCenteredFlex gap={'4px'}>
+            <Typography> {props.mainText}</Typography>
+            <Typography variant="small" color={getSubTextColor(props.color)}>
+                {'•'}
+            </Typography>
+            {props.subText ? (
+                <Typography
+                    variant="small"
+                    color={getSubTextColor(props.color)}>
+                    {props.subText}
+                </Typography>
+            ) : (
+                <Typography
+                    variant="small"
+                    color={getSubTextColor(props.color)}>
+                    {props.subIcon}
+                </Typography>
+            )}
+        </VerticallyCenteredFlex>
+    );
+};

+ 63 - 0
packages/shared/components/Collections/CollectionShare/publicShare/switch.tsx

@@ -0,0 +1,63 @@
+import React from 'react';
+import { SwitchProps, Switch } from '@mui/material';
+import { styled } from '@mui/material';
+const PublicShareSwitch = styled((props: SwitchProps) => (
+    <Switch
+        focusVisibleClassName=".Mui-focusVisible"
+        disableRipple
+        {...props}
+    />
+))(({ theme }) => ({
+    width: 40,
+    height: 24,
+    padding: 0,
+    '& .MuiSwitch-switchBase': {
+        padding: 0,
+        margin: 2,
+        transitionDuration: '300ms',
+        '&.Mui-checked': {
+            transform: 'translateX(16px)',
+            color: '#fff',
+            '& + .MuiSwitch-track': {
+                backgroundColor:
+                    theme.palette.mode === 'dark' ? '#2ECA45' : '#65C466',
+                opacity: 1,
+                border: 0,
+            },
+            '&.Mui-disabled + .MuiSwitch-track': {
+                opacity: 0.5,
+            },
+        },
+        '&.Mui-focusVisible .MuiSwitch-thumb': {
+            color: '#33cf4d',
+            border: '6px solid #fff',
+        },
+        '&.Mui-disabled .MuiSwitch-thumb': {
+            color:
+                theme.palette.mode === 'light'
+                    ? theme.palette.grey[100]
+                    : theme.palette.grey[600],
+        },
+        '&.Mui-disabled + .MuiSwitch-track': {
+            opacity: theme.palette.mode === 'light' ? 0.7 : 0.3,
+        },
+    },
+    '& .MuiSwitch-thumb': {
+        boxSizing: 'border-box',
+        width: 20,
+        height: 20,
+    },
+    '& .MuiSwitch-track': {
+        borderRadius: 22 / 2,
+        backgroundColor:
+            theme.palette.mode === 'light'
+                ? '#E9E9EA'
+                : theme.colors.fill.muted,
+        opacity: 1,
+        transition: theme.transitions.create(['background-color'], {
+            duration: 500,
+        }),
+    },
+}));
+
+export default PublicShareSwitch;

+ 26 - 0
packages/shared/components/Directory/changeOption.tsx

@@ -0,0 +1,26 @@
+import OverflowMenu from '@ente/shared/components/OverflowMenu/menu';
+import { OverflowMenuOption } from '@ente/shared/components/OverflowMenu/option';
+import MoreHoriz from '@mui/icons-material/MoreHoriz';
+import { t } from 'i18next';
+import FolderIcon from '@mui/icons-material/Folder';
+
+export default function ChangeDirectoryOption({
+    changeExportDirectory: changeDirectory,
+}) {
+    return (
+        <OverflowMenu
+            triggerButtonProps={{
+                sx: {
+                    ml: 1,
+                },
+            }}
+            ariaControls={'export-option'}
+            triggerButtonIcon={<MoreHoriz />}>
+            <OverflowMenuOption
+                onClick={changeDirectory}
+                startIcon={<FolderIcon />}>
+                {t('CHANGE_FOLDER')}
+            </OverflowMenuOption>
+        </OverflowMenu>
+    );
+}

+ 34 - 0
packages/shared/components/Directory/index.tsx

@@ -0,0 +1,34 @@
+import { styled } from '@mui/material/styles';
+import LinkButton from '@ente/shared/components/LinkButton';
+import { Tooltip } from '@mui/material';
+import ElectronAPIs from '@ente/shared/electron';
+import { logError } from '@ente/shared/sentry';
+
+const DirectoryPathContainer = styled(LinkButton)(
+    ({ width }) => `
+    width: ${width}px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    /* Beginning of string */
+    direction: rtl;
+    text-align: left;
+`
+);
+
+export const DirectoryPath = ({ width, path }) => {
+    const handleClick = async () => {
+        try {
+            await ElectronAPIs.openDirectory(path);
+        } catch (e) {
+            logError(e, 'openDirectory failed');
+        }
+    };
+    return (
+        <DirectoryPathContainer width={width} onClick={handleClick}>
+            <Tooltip title={path}>
+                <span>{path}</span>
+            </Tooltip>
+        </DirectoryPathContainer>
+    );
+};

+ 10 - 0
packages/shared/components/EnteDrawer.tsx

@@ -0,0 +1,10 @@
+import { Drawer, styled } from '@mui/material';
+
+export const EnteDrawer = styled(Drawer)(({ theme }) => ({
+    '& .MuiPaper-root': {
+        maxWidth: '375px',
+        width: '100%',
+        scrollbarWidth: 'thin',
+        padding: theme.spacing(1),
+    },
+}));

+ 61 - 0
packages/shared/components/Info/InfoItem.tsx

@@ -0,0 +1,61 @@
+import Edit from '@mui/icons-material/Edit';
+import { Box, IconButton, Typography } from '@mui/material';
+import { FlexWrapper } from '@ente/shared/components/Container';
+import React from 'react';
+import { SmallLoadingSpinner } from '../styledComponents/SmallLoadingSpinner';
+
+interface Iprops {
+    icon: JSX.Element;
+    title?: string;
+    caption?: string | JSX.Element;
+    openEditor?: any;
+    loading?: boolean;
+    hideEditOption?: any;
+    customEndButton?: any;
+    children?: any;
+}
+
+export default function InfoItem({
+    icon,
+    title,
+    caption,
+    openEditor,
+    loading,
+    hideEditOption,
+    customEndButton,
+    children,
+}: Iprops): JSX.Element {
+    return (
+        <FlexWrapper justifyContent="space-between">
+            <Box display={'flex'} alignItems="flex-start" gap={0.5} pr={1}>
+                <IconButton
+                    color="secondary"
+                    sx={{ '&&': { cursor: 'default', m: 0.5 } }}
+                    disableRipple>
+                    {icon}
+                </IconButton>
+                <Box py={0.5}>
+                    {children ? (
+                        children
+                    ) : (
+                        <>
+                            <Typography sx={{ wordBreak: 'break-all' }}>
+                                {title}
+                            </Typography>
+                            <Typography variant="small" color="text.muted">
+                                {caption}
+                            </Typography>
+                        </>
+                    )}
+                </Box>
+            </Box>
+            {customEndButton
+                ? customEndButton
+                : !hideEditOption && (
+                      <IconButton onClick={openEditor} color="secondary">
+                          {!loading ? <Edit /> : <SmallLoadingSpinner />}
+                      </IconButton>
+                  )}
+        </FlexWrapper>
+    );
+}

+ 127 - 0
packages/shared/components/Menu/EnteMenuItem.tsx

@@ -0,0 +1,127 @@
+import {
+    MenuItem,
+    ButtonProps,
+    Box,
+    Typography,
+    TypographyProps,
+} from '@mui/material';
+import { CaptionedText } from '../CaptionedText';
+import PublicShareSwitch from '../Collections/CollectionShare/publicShare/switch';
+import {
+    SpaceBetweenFlex,
+    VerticallyCenteredFlex,
+} from '@ente/shared/components/Container';
+import React from 'react';
+import ChangeDirectoryOption from '../Directory/changeOption';
+
+interface Iprops {
+    onClick: () => void;
+    color?: ButtonProps['color'];
+    variant?:
+        | 'primary'
+        | 'captioned'
+        | 'toggle'
+        | 'secondary'
+        | 'mini'
+        | 'path';
+    fontWeight?: TypographyProps['fontWeight'];
+    startIcon?: React.ReactNode;
+    endIcon?: React.ReactNode;
+    label?: string;
+    subText?: string;
+    subIcon?: React.ReactNode;
+    checked?: boolean;
+    labelComponent?: React.ReactNode;
+    disabled?: boolean;
+}
+export function EnteMenuItem({
+    onClick,
+    color = 'primary',
+    startIcon,
+    endIcon,
+    label,
+    subText,
+    subIcon,
+    checked,
+    variant = 'primary',
+    fontWeight = 'bold',
+    labelComponent,
+    disabled = false,
+}: Iprops) {
+    const handleButtonClick = () => {
+        if (variant === 'path' || variant === 'toggle') {
+            return;
+        }
+        onClick();
+    };
+
+    const handleIconClick = () => {
+        if (variant !== 'path' && variant !== 'toggle') {
+            return;
+        }
+        onClick();
+    };
+
+    return (
+        <MenuItem
+            disabled={disabled}
+            onClick={handleButtonClick}
+            sx={{
+                width: '100%',
+                color: (theme) =>
+                    variant !== 'captioned' && theme.palette[color].main,
+                ...(variant !== 'secondary' &&
+                    variant !== 'mini' && {
+                        backgroundColor: (theme) => theme.colors.fill.faint,
+                    }),
+                '&:hover': {
+                    backgroundColor: (theme) => theme.colors.fill.faintPressed,
+                },
+                '& .MuiSvgIcon-root': {
+                    fontSize: '20px',
+                },
+                p: 0,
+                borderRadius: '4px',
+            }}>
+            <SpaceBetweenFlex sx={{ pl: '16px', pr: '12px' }}>
+                <VerticallyCenteredFlex sx={{ py: '14px' }} gap={'10px'}>
+                    {startIcon && startIcon}
+                    <Box px={'2px'}>
+                        {labelComponent ? (
+                            labelComponent
+                        ) : variant === 'captioned' ? (
+                            <CaptionedText
+                                color={color}
+                                mainText={label}
+                                subText={subText}
+                                subIcon={subIcon}
+                            />
+                        ) : variant === 'mini' ? (
+                            <Typography variant="mini" color="text.muted">
+                                {label}
+                            </Typography>
+                        ) : (
+                            <Typography fontWeight={fontWeight}>
+                                {label}
+                            </Typography>
+                        )}
+                    </Box>
+                </VerticallyCenteredFlex>
+                <VerticallyCenteredFlex gap={'4px'}>
+                    {endIcon && endIcon}
+                    {variant === 'toggle' && (
+                        <PublicShareSwitch
+                            checked={checked}
+                            onClick={handleIconClick}
+                        />
+                    )}
+                    {variant === 'path' && (
+                        <ChangeDirectoryOption
+                            changeExportDirectory={handleIconClick}
+                        />
+                    )}
+                </VerticallyCenteredFlex>
+            </SpaceBetweenFlex>
+        </MenuItem>
+    );
+}

+ 16 - 0
packages/shared/components/Menu/MenuItemDivider.tsx

@@ -0,0 +1,16 @@
+import { Divider } from '@mui/material';
+interface Iprops {
+    hasIcon?: boolean;
+}
+export default function MenuItemDivider({ hasIcon = false }: Iprops) {
+    return (
+        <Divider
+            sx={{
+                '&&&': {
+                    my: 0,
+                    ml: hasIcon ? '48px' : '16px',
+                },
+            }}
+        />
+    );
+}

+ 20 - 0
packages/shared/components/Menu/MenuItemGroup.tsx

@@ -0,0 +1,20 @@
+import { styled } from '@mui/material';
+
+export const MenuItemGroup = styled('div')(
+    ({ theme }) => `
+    & > .MuiMenuItem-root{
+        border-radius: 8px;
+        background-color: transparent;
+    }
+    & > .MuiMenuItem-root:not(:last-of-type) {
+        border-bottom-left-radius: 0;
+        border-bottom-right-radius: 0;
+    }
+    & > .MuiMenuItem-root:not(:first-of-type) {
+        border-top-left-radius: 0;
+        border-top-right-radius: 0;
+    }
+    background-color: ${theme.colors.fill.faint};
+    border-radius: 8px;
+`
+);

+ 27 - 0
packages/shared/components/Menu/MenuSectionTitle.tsx

@@ -0,0 +1,27 @@
+import { Typography } from '@mui/material';
+import { VerticallyCenteredFlex } from '@ente/shared/components/Container';
+
+interface Iprops {
+    title: string;
+    icon?: JSX.Element;
+}
+
+export default function MenuSectionTitle({ title, icon }: Iprops) {
+    return (
+        <VerticallyCenteredFlex
+            px="8px"
+            py={'6px'}
+            gap={'8px'}
+            sx={{
+                '& > svg': {
+                    fontSize: '17px',
+                    color: (theme) => theme.colors.stroke.muted,
+                },
+            }}>
+            {icon && icon}
+            <Typography variant="small" color="text.muted">
+                {title}
+            </Typography>
+        </VerticallyCenteredFlex>
+    );
+}

+ 56 - 0
packages/shared/components/Titlebar.tsx

@@ -0,0 +1,56 @@
+import Close from '@mui/icons-material/Close';
+import ArrowBack from '@mui/icons-material/ArrowBack';
+import { Box, IconButton, Typography } from '@mui/material';
+import { FlexWrapper } from '@ente/shared/components/Container';
+
+interface Iprops {
+    title: string;
+    caption?: string;
+    onClose: () => void;
+    backIsClose?: boolean;
+    onRootClose?: () => void;
+    actionButton?: JSX.Element;
+}
+
+export default function Titlebar({
+    title,
+    caption,
+    onClose,
+    backIsClose,
+    actionButton,
+    onRootClose,
+}: Iprops): JSX.Element {
+    return (
+        <>
+            <FlexWrapper
+                height={48}
+                alignItems={'center'}
+                justifyContent="space-between">
+                <IconButton
+                    onClick={onClose}
+                    color={backIsClose ? 'secondary' : 'primary'}>
+                    {backIsClose ? <Close /> : <ArrowBack />}
+                </IconButton>
+                <Box display={'flex'} gap="4px">
+                    {actionButton && actionButton}
+                    {!backIsClose && (
+                        <IconButton onClick={onRootClose} color={'secondary'}>
+                            <Close />
+                        </IconButton>
+                    )}
+                </Box>
+            </FlexWrapper>
+            <Box py={0.5} px={2}>
+                <Typography variant="h3" fontWeight={'bold'}>
+                    {title}
+                </Typography>
+                <Typography
+                    variant="small"
+                    color="text.muted"
+                    sx={{ wordBreak: 'break-all', minHeight: '17px' }}>
+                    {caption}
+                </Typography>
+            </Box>
+        </>
+    );
+}

+ 11 - 0
packages/shared/components/styledComponents/SmallLoadingSpinner.tsx

@@ -0,0 +1,11 @@
+import React from 'react';
+import EnteSpinner from '@ente/shared/components/EnteSpinner';
+
+export const SmallLoadingSpinner = () => (
+    <EnteSpinner
+        style={{
+            width: '20px',
+            height: '20px',
+        }}
+    />
+);

+ 15 - 0
packages/shared/constants/pages.tsx

@@ -33,3 +33,18 @@ export enum AUTH_PAGES {
     ROOT = '/',
     AUTH = '/auth',
 }
+
+export enum ACCOUNTS_PAGES {
+    CREDENTIALS = '/credentials',
+    LOGIN = '/login',
+    RECOVER = '/recover',
+    SIGNUP = '/signup',
+    TWO_FACTOR_SETUP = '/two-factor/setup',
+    TWO_FACTOR_VERIFY = '/two-factor/verify',
+    TWO_FACTOR_RECOVER = '/two-factor/recover',
+    VERIFY = '/verify',
+    ROOT = '/',
+    PASSKEYS = '/passkeys',
+    ACCOUNT_HANDOFF = '/account-handoff',
+    GENERATE = '/generate',
+}

+ 1 - 0
packages/shared/error/index.ts

@@ -82,6 +82,7 @@ export const CustomError = {
     PROCESSING_FAILED: 'processing failed',
     EXPORT_RECORD_JSON_PARSING_FAILED: 'export record json parsing failed',
     TWO_FACTOR_ENABLED: 'two factor enabled',
+    PASSKEYS_TWO_FACTOR_ENABLED: 'passkeys two factor enabled',
     CLIENT_ERROR: 'client error',
     ServerError: 'server error',
     FILE_NOT_FOUND: 'file not found',

+ 22 - 0
packages/shared/network/HTTPService.ts

@@ -191,6 +191,28 @@ class HTTPService {
         );
     }
 
+    /**
+     * Patch request
+     */
+    public patch(
+        url: string,
+        data?: any,
+        params?: IQueryPrams,
+        headers?: IHTTPHeaders,
+        customConfig?: any
+    ) {
+        return this.request(
+            {
+                data,
+                headers,
+                method: 'PATCH',
+                params,
+                url,
+            },
+            customConfig
+        );
+    }
+
     /**
      * Put request
      */

+ 41 - 0
packages/shared/network/api.ts

@@ -90,6 +90,47 @@ export const getFamilyPortalURL = () => {
     return `https://family.ente.io`;
 };
 
+// getAuthenticatorURL returns the endpoint for the authenticator which can be used to
+// view authenticator codes.
+export const getAuthURL = () => {
+    const authURL = process.env.NEXT_PUBLIC_ENTE_AUTH_ENDPOINT;
+    if (isDevDeployment() && authURL) {
+        return authURL;
+    }
+    return `https://auth.ente.io`;
+};
+
+export const getAccountsURL = () => {
+    const accountsURL = process.env.NEXT_PUBLIC_ENTE_ACCOUNTS_ENDPOINT;
+    if (isDevDeployment() && accountsURL) {
+        return accountsURL;
+    }
+    return `https://accounts.ente.io`;
+};
+
+export const getSentryTunnelURL = () => {
+    return `https://sentry-reporter.ente.io`;
+};
+
+/*
+It's a dev deployment (and should use the environment override for endpoints ) in three cases:
+1. when the URL opened is that of the staging web app, or
+2. when the URL opened is that of the staging album app, or
+3. if the app is running locally (hence node_env is development)
+4. if the app is running in test mode
+*/
+export const isDevDeployment = () => {
+    if (globalThis?.location) {
+        return (
+            process.env.NEXT_PUBLIC_ENTE_WEB_ENDPOINT ===
+                globalThis.location.origin ||
+            process.env.NEXT_PUBLIC_ENTE_ALBUM_ENDPOINT ===
+                globalThis.location.origin ||
+            process.env.NEXT_PUBLIC_IS_TEST_APP === 'true' ||
+            process.env.NODE_ENV === 'development'
+        );
+    }
+};
 /**
  * A build is considered as a development build if either the NODE_ENV is
  * environment variable is set to 'development'.

+ 1 - 0
packages/shared/storage/localStorage/index.ts

@@ -28,6 +28,7 @@ export enum LS_KEYS {
     OPT_OUT_OF_CRASH_REPORTS = 'optOutOfCrashReports',
     CF_PROXY_DISABLED = 'cfProxyDisabled',
     REFERRAL_SOURCE = 'referralSource',
+    CLIENT_PACKAGE = 'clientPackage',
 }
 
 export const setData = (key: LS_KEYS, value: object) => {

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff