├── .editorconfig ├── .env.example ├── .eslintrc.cjs ├── .github ├── dependabot.yml └── workflows │ ├── bot.yml │ └── deploy.yml ├── .gitignore ├── LICENSE ├── README.md ├── bot ├── Dockerfile ├── docker-compose.example.yml ├── lang │ ├── de.yml │ ├── en.yml │ ├── es.yml │ ├── fr.yml │ ├── hi.yml │ ├── pt.yml │ ├── ru.yml │ └── uk.yml ├── main.py └── requirements.txt ├── crowdin.yml ├── index.html ├── package-lock.json ├── package.json ├── public └── logo.png ├── src ├── Root.tsx ├── assets │ ├── change_password_lottie.json │ ├── crash_lottie.json │ ├── create_lottie.json │ ├── export_link_lottie.json │ ├── export_lottie.json │ ├── magnification_glass_lottie.json │ ├── manual_lottie.json │ ├── new_account_lottie.json │ ├── password_lottie.json │ ├── password_reset_lottie.json │ └── unlock_lottie.json ├── components │ ├── AccountDragPreview.tsx │ ├── AccountSelectButton.tsx │ ├── ColorPicker.tsx │ ├── FlatButton.tsx │ ├── IconPicker.tsx │ ├── LottieAnimation.tsx │ ├── NewAccountButton.tsx │ ├── NewUpdateDialog.tsx │ ├── PlausibleAnalytics.tsx │ └── TelegramTextField.tsx ├── drag.ts ├── global.css ├── globals.tsx ├── hooks │ ├── telegram │ │ ├── useTelegramBackButton.ts │ │ ├── useTelegramHaptics.ts │ │ ├── useTelegramHeaderColor.ts │ │ ├── useTelegramMainButton.ts │ │ ├── useTelegramQrScanner.ts │ │ └── useTelegramTheme.ts │ ├── useAccount.ts │ ├── useAccountTheme.ts │ └── useL10n.ts ├── icons │ ├── icons.test.ts │ ├── icons.ts │ └── normalizeCustomColor.ts ├── lang │ ├── de.ts │ ├── en.ts │ ├── es.ts │ ├── fr.ts │ ├── hi.ts │ ├── pt.ts │ ├── ru.ts │ └── uk.ts ├── main.tsx ├── managers │ ├── biometrics.tsx │ ├── encryption.tsx │ ├── localization.tsx │ ├── settings.tsx │ └── storage │ │ ├── migrate.test.ts │ │ ├── migrate.ts │ │ ├── migrations.ts │ │ └── storage.tsx ├── migration │ ├── export.ts │ ├── import.ts │ └── proto │ │ └── migration.proto ├── pages │ ├── Accounts.tsx │ ├── CreateAccount.tsx │ ├── Decrypt.tsx │ ├── DevToolsPage.tsx │ ├── EditAccount.tsx │ ├── IconBrowser.tsx │ ├── ManualAccount.tsx │ ├── NewAccount.tsx │ ├── PasswordSetup.tsx │ ├── ResetAccounts.tsx │ ├── SelectLanguage.tsx │ ├── Settings.tsx │ ├── UserErrorPage.tsx │ └── export │ │ ├── ExportAccounts.tsx │ │ ├── LinkExport.tsx │ │ └── QrExport.tsx └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | tab_width = 4 6 | insert_final_newline = true 7 | indent_style = space 8 | 9 | [*.{json,yml,md,html}] 10 | tab_width = 2 11 | 12 | [*.{js,jsx,ts,tsx,py}] 13 | charset = utf-8 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | VITE_BOT_USERNAME=TeleOTPAppBot 2 | VITE_PLAUSIBLE_DOMAIN=teleotp.pages.dev 3 | VITE_PLAUSIBLE_API_HOST=https://analytics.gesti.tech 4 | VITE_CHANNEL_LINK=https://t.me/teleotpapp 5 | VITE_APP_NAME=app 6 | VITE_TRANSLATE_LINK=https://crowdin.com/project/teleotp 7 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/strict-type-checked', 7 | 'plugin:react-hooks/recommended', 8 | 'plugin:@typescript-eslint/stylistic-type-checked', 9 | 'plugin:react/recommended', 10 | 'plugin:react/jsx-runtime', 11 | ], 12 | ignorePatterns: ['dist', '.eslintrc.cjs', 'vite.config.ts'], 13 | parser: '@typescript-eslint/parser', 14 | plugins: ['react-refresh'], 15 | rules: { 16 | '@typescript-eslint/no-unsafe-member-access': 'off', 17 | '@typescript-eslint/no-unsafe-call': 'off', 18 | '@typescript-eslint/no-unsafe-assignment': 'off', 19 | '@typescript-eslint/no-unsafe-argument': 'off', 20 | 'react-refresh/only-export-components': [ 21 | 'warn', 22 | { allowConstantExport: true }, 23 | ], 24 | '@typescript-eslint/restrict-template-expressions': [ 25 | 'warn', 26 | { 27 | allowNumber: true, 28 | } 29 | ] 30 | }, 31 | parserOptions: { 32 | ecmaVersion: 'latest', 33 | sourceType: 'module', 34 | project: ['./tsconfig.json', './tsconfig.node.json'], 35 | tsconfigRootDir: __dirname, 36 | }, 37 | overrides: [ 38 | { 39 | // This allows us to use FCs without props. 40 | // See https://github.com/typescript-eslint/typescript-eslint/issues/2063#issuecomment-675156492 41 | files: ['*.tsx', '*.jsx'], 42 | rules: { 43 | '@typescript-eslint/ban-types': [ 44 | 'error', 45 | { 46 | extendDefaults: true, 47 | types: { 48 | '{}': false, 49 | }, 50 | }, 51 | ], 52 | }, 53 | }, 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/bot.yml: -------------------------------------------------------------------------------- 1 | name: Build Telegram bot image 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | permissions: 14 | contents: read 15 | packages: write 16 | 17 | steps: 18 | - 19 | name: Checkout 20 | uses: actions/checkout@v4 21 | - 22 | name: Login to GitHub Container Registry 23 | uses: docker/login-action@v3 24 | with: 25 | registry: ghcr.io 26 | username: ${{ github.actor }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | - 29 | name: Extract metadata (tags, labels) for Docker 30 | id: meta 31 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 32 | with: 33 | images: ghcr.io/${{ github.repository_owner }}/teleotp-bot 34 | - 35 | name: Build and push 36 | uses: docker/build-push-action@v5 37 | with: 38 | context: ./bot/ 39 | file: ./bot/Dockerfile 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy static content to Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ['main'] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: 'pages' 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | # Single deploy job since we're just deploying 25 | deploy: 26 | environment: 27 | name: github-pages 28 | url: ${{ steps.deployment.outputs.page_url }} 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | - name: Set up Node 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: 18 37 | cache: 'npm' 38 | - name: Install dependencies 39 | run: npm install 40 | - name: Build 41 | run: npm run build -- --base=/TeleOTP/ 42 | env: 43 | VITE_BOT_USERNAME: ${{ vars.VITE_BOT_USERNAME }} 44 | - name: Setup Pages 45 | uses: actions/configure-pages@v3 46 | - name: Upload artifact 47 | uses: actions/upload-pages-artifact@v1 48 | with: 49 | # Upload dist repository 50 | path: './dist' 51 | - name: Deploy to GitHub Pages 52 | id: deployment 53 | uses: actions/deploy-pages@v1 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | /src/migration/proto/generated/ 26 | /.env 27 | 28 | docker-compose.yml 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Ivan Evstratov 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔐 TeleOTP 2 | [![Build Telegram bot image](https://github.com/UselessStudio/TeleOTP/actions/workflows/bot.yml/badge.svg)](https://github.com/UselessStudio/TeleOTP/actions/workflows/bot.yml) 3 | [![Telegram channel](https://img.shields.io/badge/Telegram-channel-blue)](https://t.me/teleotpapp) 4 | [![Plausible analytics](https://img.shields.io/badge/Plausible-analytics-blue)](https://analytics.gesti.tech/teleotp.gesti.tech) 5 | [![Crowdin](https://badges.crowdin.net/teleotp/localized.svg)](https://crowdin.com/project/teleotp) 6 | 7 | Telegram Mini App that allows you to generate one-time 2FA passwords inside Telegram. 8 | 9 | [➡️ **OPEN**](http://t.me/TeleOTPAppBot) 10 | 11 | ## ✨ Features 12 | 13 | * ✅ **Universal:** TeleOTP implements [TOTP](https://en.wikipedia.org/wiki/Time-based_one-time_password) (Time-Based One-Time Password Algorithm) which is used by most services. 14 | * 👌 **Convenient:** Accounts are safely stored in your Telegram cloud storage, 15 | so you can access them anywhere you can use Telegram. 16 | * 🔒 **Secure:** All accounts are encrypted using AES. 17 | That means even if your Telegram account is breached, 18 | the attacker won't have access to your tokens without the encryption password. 19 | * 🥰 **User-friendly:** TeleOTP is designed to look like Telegram and follows your color theme. 20 | * 🤗 **Open:** TeleOTP supports account migration to and from Google Authenticator. 21 | You can switch the platforms at any time without any hassle! 22 | 23 | ## Table of contents 24 | 25 | * [🔐 TeleOTP](#-teleotp) 26 | * [✨ Features](#-features) 27 | * [Table of contents](#table-of-contents) 28 | * [⚙️ Setup guide](#-setup-guide) 29 | * [📱 Mini App](#-mini-app) 30 | * [Installing the dependencies](#installing-the-dependencies) 31 | * [Configuring the environment](#configuring-the-environment) 32 | * [Starting the development server](#starting-the-development-server) 33 | * [Building the app](#building-the-app) 34 | * [🔁 CI/CD](#-cicd) 35 | * [💬 Bot](#-bot) 36 | * [Starting bot](#starting-bot) 37 | * [Environment variables](#environment-variables) 38 | * [Running in Docker](#running-in-docker) 39 | * [🔁 CI/CD](#-cicd-1) 40 | * [💻 Structure](#-structure) 41 | * [🛣️ Routing](#-routing) 42 | * [✈️ Migration](#-migration) 43 | * [🤗 Icons and colors](#-icons-and-colors) 44 | * [🌍 Translating](#-translating) 45 | * [👋 Acknowledgements](#-acknowledgements) 46 | * [🖌️ Content](#-content) 47 | * [📚 Libraries used](#-libraries-used) 48 | 49 | 50 | # ⚙️ Setup guide 51 | 52 | ## 📱 Mini App 53 | 54 | TeleOTP is made with **React**, **Typescript**, and **[Material UI](https://mui.com/material-ui/)**. 55 | **Vite** frontend tooling is used for rapid development and easy deployment. 56 | 57 | ### Installing the dependencies 58 | 59 | To begin working with the project, 60 | you should install the dependencies by running this command: 61 | 62 | ```shell 63 | npm install 64 | ``` 65 | 66 | ### Configuring the environment 67 | 68 | Before starting the server or building the app, make sure that 69 | your project directory has a file named `.env`. 70 | It should follow the `.env.example` file structure. 71 | You could also set these variables directly when running the app. 72 | 73 | The app uses following environment variables: 74 | 75 | * `VITE_BOT_USERNAME` - This value contains the bot username. 76 | It is used to send export requests to the bot. 77 | _Example: `VITE_BOT_USERNAME=TeleOTPAppBot`_ 78 | * `VITE_PLAUSIBLE_DOMAIN` - The domain for Plausible analytics. 79 | * `VITE_PLAUSIBLE_API_HOST` - API host to report Plausible analytics to. 80 | * `VITE_CHANNEL_LINK` - Link to the news channel. 81 | * `VITE_APP_NAME` - Name of the app for the URL. 82 | * For the app https://t.me/TeleOTPAppBot/app, app name is `app` 83 | * `VITE_TRANSLATE_LINK` - Link to the translation website 84 | 85 | ### Starting the development server 86 | To start the development server with hot reload, run: 87 | 88 | ```shell 89 | npm run dev 90 | ``` 91 | 92 | After that, the server will be accessible on http://localhost:5173/ 93 | 94 | > [!NOTE] 95 | > If you want the app to be accessible on your local network, you should add `--host` argument to the command 96 | 97 | ```shell 98 | npm run dev -- --host 99 | ``` 100 | 101 | ### Building the app 102 | 103 | ```shell 104 | npm run build 105 | ``` 106 | After a successful build, app bundle will be available in `./dist`. 107 | 108 | ### 🔁 CI/CD 109 | The app is built and deployed automatically using **Cloudflare Pages**. 110 | 111 | ## 💬 Bot 112 | 113 | TeleOTP uses a helper bot to send user a link to the app and assist with account migration. 114 | The bot is written in **Python** using [Python Telegram Bot library](https://github.com/python-telegram-bot/python-telegram-bot). 115 | 116 | ### Starting bot 117 | 118 | To start the bot, you have to run the `main.py` script with environment variables. 119 | ```shell 120 | python main.py 121 | ``` 122 | 123 | ### Environment variables 124 | 125 | * `TOKEN` - Telegram bot token provided by @BotFather 126 | * `TG_APP` - A link to the Mini App in Telegram (e.g. https://t.me/TeleOTPAppBot/app) 127 | * `WEBAPP_URL` - Deployed Mini App URL (e.g. https://uselessstudio.github.io/TeleOTP) 128 | 129 | > [!NOTE] 130 | > Make sure that `WEBAPP_URL` doesn't end with a `/`! 131 | > It is added automatically by the bot. 132 | 133 | ### Running in Docker 134 | 135 | We recommend running the bot inside the Docker container. 136 | The latest image is available at `ghcr.io/uselessstudio/teleotp-bot:main`. 137 | 138 | Example `docker-compose.yml` file: 139 | 140 | ```yaml 141 | services: 142 | bot: 143 | image: ghcr.io/uselessstudio/teleotp-bot:main 144 | restart: unless-stopped 145 | environment: 146 | - TG_APP=https://t.me/TeleOTPAppBot/app 147 | - WEBAPP_URL=https://uselessstudio.github.io/TeleOTP 148 | - TOKEN= 149 | ``` 150 | 151 | And running is as simple as: 152 | ```shell 153 | docker compose up 154 | ``` 155 | 156 | ### 🔁 CI/CD 157 | GitHub Actions is used to automate the building of the bot container. 158 | The workflow is defined in the [`bot.yml` file](.github/workflows/bot.yml) 159 | and ran on every push to `main`. After a successful build, 160 | the container is published in the GitHub Container Registry. 161 | 162 | 163 | # 💻 Structure 164 | 165 | ## 🛣️ Routing 166 | 167 | TeleOTP uses React Router to switch between pages. 168 | Routes are specified in the `main.tsx` file. 169 | 170 | * Route implemented in `Root.tsx` is responsible for showing "required" screens: 171 | * `PasswordSetup.tsx` is a screen which is showed when no password is created. 172 | Alternatively, this screen is shown when user clicked a button to change the password. 173 | It displays a prompt to create a new password which is used to encrypt the accounts. 174 | * `Decrypt.tsx` is a screen which shows a password prompt to decrypt stored accounts. 175 | By default, it is shown only once on a new device. 176 | Later, the password retrieved from `localStorage` (if not disabled in the settings). 177 | * `Accounts.tsx` is the main screen, which shows the generated password and a list of accounts that user has. 178 | * `NewAccount.tsx` is a screen, which prompts user to open the QR-code scanner. 179 | Otherwise, user could press a button to enter an account manually, which would redirect them to `ManualAccount.tsx` 180 | * `ManualAccount.tsx` prompts user to enter a secret for an OTP account. 181 | * `CreateAccount.tsx` is a final step in the account creation flow. User is redirected here after scanning a QR-code 182 | or after manually providing a secret. 183 | This screen allows user to change issuer and label and to select an icon with a color for the account. 184 | * `EditAccount.tsx` is a screen similar to `CreateAccount.tsx` which allows 185 | user to edit or delete an account. 186 | * `Settings.tsx` is a menu screen with a few options. User can delete all accounts, 187 | encrypt them, change the password, or set preferences. 188 | * `ExportAccounts.tsx` is a page that handles the export logic. 189 | * `QrExport.tsx` is a screen that provides a QR-code with account data. 190 | * `LinkExport.tsx` is a screen which allows to copy a link to import accounts. 191 | * `ResetAccounts.tsx` is a page that verifies that the user wants to delete all accounts and reset the password. 192 | This page can be accessed through the settings, or by typing in the password incorrectly when decrypting. 193 | * `DevToolsPage.tsx` is a debugging page, which allows checking the storage. (Only available in dev env) 194 | * `IconBrowser.tsx` is a page for browsing icons from [simpleicons.org](https://simpleicons.org/). 195 | User is able to search for icons. 196 | 197 | ## ✈️ Migration 198 | 199 | TeleOTP implements the `otpauth-migration` URI standard. 200 | During the migration, accounts are (de)serialized using Protocol Buffers. 201 | 202 | ## 🤗 Icons and colors 203 | 204 | All generic icons and colors for accounts are defined in the `globals.tsx` file. 205 | Available icons are exported in the `icons` const, colors are available in the `colors` const. 206 | `Icon` and `Color` types are provided for checking the validity of the corresponding item. 207 | 208 | ## 🌍 Translating 209 | 210 | TeleOTP is officially translated only to Russian. If you have spare time and would like to help out the project, 211 | please add translations at [Crowdin](https://crowdin.com/project/teleotp). 212 | 213 | Currently supported languages: 214 | * English (official) 215 | * Russian (official) 216 | * Ukrainian 217 | * German 218 | * French 219 | * Spanish 220 | 221 | If you need to add new strings: 222 | 1. Modify the file `src/lang/en.ts` to have a new string with a descriptive key. 223 | 1. If a string needs to have variables, put them in braces `like {this}` 224 | 2. Use the `useL10n()` hook to get the translation. 225 | 226 | # 👋 Acknowledgements 227 | 228 | ## 🖌️ Content 229 | 230 | * Emoji animations from [Telegram stickers](https://t.me/addstickers/AnimatedEmojies). 231 | * [Duck stickers](https://t.me/addstickers/UtyaDuck) 232 | * Brand icons from [Simple Icons](https://simpleicons.org/) 233 | 234 | ## 📚 Libraries used 235 | 236 | * [@twa-dev/types](https://github.com/twa-dev/types) - Typescript types for Telegram Mini App SDK 237 | * [OTPAuth](https://www.npmjs.com/package/otpauth) - generating TOTP codes 238 | * [nanoid](https://www.npmjs.com/package/nanoid) - generating unique ids for accounts 239 | * [lottie-react](https://www.npmjs.com/package/lottie-react) - rendering lottie animations 240 | * [copy-text-to-clipboard](https://www.npmjs.com/package/copy-text-to-clipboard) - copying codes to the clipboard 241 | 242 | 🏆 TeleOTP won first place in the [Telegram Mini App Contest](https://contest.com/mini-apps). 243 | 244 | Designed by [@lunnaholy](https://github.com/lunnaholy), 245 | implemented by [@LowderPlay](https://github.com/LowderPlay) & [@lenchq](https://github.com/lenchq) with ❤️ 246 | -------------------------------------------------------------------------------- /bot/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY requirements.txt ./ 6 | RUN pip install --no-cache-dir -r requirements.txt 7 | 8 | COPY . . 9 | 10 | CMD [ "python", "./main.py" ] 11 | -------------------------------------------------------------------------------- /bot/docker-compose.example.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | bot: 5 | image: ghcr.io/uselessstudio/teleotp-bot:main 6 | restart: unless-stopped 7 | environment: 8 | - TG_APP=https://t.me/TeleOTPAppBot/app 9 | # For testing on localhost use http://127.0.0.1 instead of http://localhost 10 | #! Do not use trailing slash 11 | - WEBAPP_URL=https://uselessstudio.github.io/TeleOTP 12 | # For using on beta server use token like: /test 13 | - TOKEN= 14 | -------------------------------------------------------------------------------- /bot/lang/de.yml: -------------------------------------------------------------------------------- 1 | ButtonText: TeleOTP öffnen 2 | Welcome: '👋 Willkommen bei TeleOTP!' 3 | ICanHelp: Ich kann Ihnen helfen, Ihre Konten mit 2FA zu schützen. 4 | ClickButton: Klicken Sie auf den Button unten, um loszulegen! 5 | -------------------------------------------------------------------------------- /bot/lang/en.yml: -------------------------------------------------------------------------------- 1 | ButtonText: Open TeleOTP 2 | Welcome: 👋 Welcome to TeleOTP! 3 | ICanHelp: I can help you protect your accounts with 2FA. 4 | ClickButton: Click the button below to get started! 5 | -------------------------------------------------------------------------------- /bot/lang/es.yml: -------------------------------------------------------------------------------- 1 | ButtonText: Abrir TeleOTP 2 | Welcome: '👋 ¡Bienvenido a TeleOTP!' 3 | ICanHelp: Puedo ayudarte a proteger tus cuentas con 2FA. 4 | ClickButton: '¡Haz clic en el botón de abajo para empezar!' 5 | -------------------------------------------------------------------------------- /bot/lang/fr.yml: -------------------------------------------------------------------------------- 1 | ButtonText: Ouvrir TeleOTP 2 | Welcome: '👋 Bienvenue sur TeleOTP!' 3 | ICanHelp: Je peux vous aider à protéger vos comptes avec 2FA. 4 | ClickButton: Cliquez sur le bouton ci-dessous pour commencer! 5 | -------------------------------------------------------------------------------- /bot/lang/hi.yml: -------------------------------------------------------------------------------- 1 | ButtonText: खोले TeleOTP 2 | Welcome: '👋 TeleOTP में स्वागत है!' 3 | ICanHelp: मैं आपकी मदद कर सकता हूँ आपके खातों को 2FA से सुरक्षित करने में। 4 | ClickButton: शुरू करने के लिए नीचे दिए गए बटन पर क्लिक करें! 5 | -------------------------------------------------------------------------------- /bot/lang/pt.yml: -------------------------------------------------------------------------------- 1 | ButtonText: Abrir TeleOTP 2 | Welcome: '👋 Bem-vindo ao TeleOTP!' 3 | ICanHelp: Posso te ajudar a proteger suas contas com o 2FA. 4 | ClickButton: Clique no botão abaixo para começar! 5 | -------------------------------------------------------------------------------- /bot/lang/ru.yml: -------------------------------------------------------------------------------- 1 | ButtonText: Открыть TeleOTP 2 | Welcome: '👋 Добро пожаловать в TeleOTP!' 3 | ICanHelp: Я помогу защитить ваши аккаунты с помощью двухфакторной аутентификации. 4 | ClickButton: Нажмите на кнопку, чтобы начать! 5 | -------------------------------------------------------------------------------- /bot/lang/uk.yml: -------------------------------------------------------------------------------- 1 | ButtonText: Відкрити TeleOTP 2 | Welcome: '👋 Ласкаво просимо в TeleOTP!' 3 | ICanHelp: Я можу допомогти вам захистити ваші облікові записи в 2FA. 4 | ClickButton: Натисніть на кнопку нижче, щоб почати! 5 | -------------------------------------------------------------------------------- /bot/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import yaml 5 | from telegram import Update, WebAppInfo, InlineKeyboardMarkup, \ 6 | InlineKeyboardButton 7 | from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters 8 | 9 | logging.basicConfig( 10 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 11 | level=logging.WARN 12 | ) 13 | 14 | app_tg = os.environ['TG_APP'] 15 | app_url = os.environ['WEBAPP_URL'] 16 | 17 | translations = {} 18 | 19 | 20 | async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): 21 | lang = update.effective_user.language_code 22 | 23 | def l(code): 24 | if lang in translations and code in translations[lang]: 25 | return translations[lang][code] 26 | 27 | return translations["en"][code] 28 | 29 | keyboard = InlineKeyboardMarkup.from_button(InlineKeyboardButton( 30 | text=l("ButtonText"), 31 | web_app=WebAppInfo(url=f"{app_url}/"))) 32 | 33 | await context.bot.send_message(chat_id=update.effective_chat.id, 34 | text=f"{l('Welcome')}\n" 35 | f"{l('ICanHelp')}\n" 36 | f"{l('ClickButton')}", 37 | reply_markup=keyboard) 38 | 39 | 40 | if __name__ == '__main__': 41 | for lang in os.listdir("lang"): 42 | code = lang.replace(".yml", "") 43 | with open(os.path.join("lang", lang), encoding="utf-8") as f: 44 | translations[code] = yaml.safe_load(f) 45 | 46 | application = ApplicationBuilder().token(os.environ['TOKEN']).build() 47 | 48 | start_handler = MessageHandler(filters=filters.ALL, callback=start) 49 | application.add_handler(start_handler) 50 | 51 | application.run_polling() 52 | -------------------------------------------------------------------------------- /bot/requirements.txt: -------------------------------------------------------------------------------- 1 | python-telegram-bot~=22.1 2 | PyYAML~=6.0.2 3 | -------------------------------------------------------------------------------- /crowdin.yml: -------------------------------------------------------------------------------- 1 | files: 2 | - source: src/lang/en.ts 3 | translation: /src/lang/%two_letters_code%.ts 4 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TeleOTP 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "teleotp", 3 | "private": true, 4 | "version": "0.4.1", 5 | "type": "module", 6 | "homepage": "https://github.com/UselessStudio/TeleOTP", 7 | "scripts": { 8 | "dev": "vite --host", 9 | "build": "npm run generate && tsc && vite build", 10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "preview": "vite preview", 12 | "generate": "npx mkdirp src/migration/proto/generated && pbjs -t static-module -w es6 src/migration/proto/migration.proto -o src/migration/proto/generated/migration.js && pbts -o src/migration/proto/generated/migration.d.ts src/migration/proto/generated/migration.js", 13 | "test": "vitest" 14 | }, 15 | "dependencies": { 16 | "@emotion/react": "^11.14.0", 17 | "@emotion/styled": "^11.14.0", 18 | "@fontsource/inter": "^5.1.1", 19 | "@mui/icons-material": "^6.4.1", 20 | "@mui/material": "^6.4.1", 21 | "@uiw/color-convert": "^2.3.4", 22 | "@uiw/react-color-colorful": "^2.3.4", 23 | "copy-text-to-clipboard": "^3.2.0", 24 | "crypto-js": "4.1.1", 25 | "fuse.js": "^7.0.0", 26 | "lottie-react": "^2.4.1", 27 | "nanoid": "^5.0.9", 28 | "otpauth": "^9.3.6", 29 | "plausible-tracker": "^0.3.9", 30 | "protobufjs": "^7.4.0", 31 | "rdndmb-html5-to-touch": "^8.1.2", 32 | "react": "^18.0.0", 33 | "react-dnd": "^16.0.1", 34 | "react-dnd-html5-backend": "^16.0.1", 35 | "react-dnd-multi-backend": "^8.1.2", 36 | "react-dnd-preview": "^8.1.2", 37 | "react-dnd-touch-backend": "^16.0.1", 38 | "react-dom": "^18.0.0", 39 | "react-flip-toolkit": "7.2.4", 40 | "react-inlinesvg": "^4.1.8", 41 | "react-qrcode-logo": "^3.0.0", 42 | "react-router-dom": "^7.1.3", 43 | "use-debounce": "^10.0.4" 44 | }, 45 | "devDependencies": { 46 | "@testing-library/jest-dom": "^6.6.3", 47 | "@testing-library/react": "^16.2.0", 48 | "@twa-dev/types": "github:UselessStudio/twa-types", 49 | "@types/crypto-js": "^4.1.1", 50 | "@types/jest": "^29.5.14", 51 | "@types/node": "^22.10.7", 52 | "@types/react": "^19.0.7", 53 | "@types/react-dom": "^19.0.3", 54 | "@typescript-eslint/eslint-plugin": "^8.21.0", 55 | "@typescript-eslint/parser": "^8.21.0", 56 | "@vitejs/plugin-react": "^4.3.4", 57 | "eslint": "^9.18.0", 58 | "eslint-plugin-react": "^7.37.4", 59 | "eslint-plugin-react-hooks": "^5.1.0", 60 | "eslint-plugin-react-refresh": "^0.4.18", 61 | "jsdom": "^26.0.0", 62 | "protobufjs-cli": "^1.1.3", 63 | "typescript": "^5.7.3", 64 | "vite": "^6.0.11", 65 | "vite-plugin-svgr": "^4.3.0", 66 | "vitest": "^3.0.3" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UselessStudio/TeleOTP/f3a01dc3e6ae1af5a9c2ad01425945d96f3c409b/public/logo.png -------------------------------------------------------------------------------- /src/Root.tsx: -------------------------------------------------------------------------------- 1 | import {Box, CircularProgress, CssBaseline, Stack, ThemeProvider} from "@mui/material"; 2 | import {Outlet, useLocation} from "react-router-dom"; 3 | import {FC, lazy, useContext} from "react"; 4 | import useTelegramBackButton from "./hooks/telegram/useTelegramBackButton.ts"; 5 | import useTelegramTheme from "./hooks/telegram/useTelegramTheme.ts"; 6 | import {EncryptionManagerContext} from "./managers/encryption.tsx"; 7 | import {StorageManagerContext} from "./managers/storage/storage.tsx"; 8 | 9 | import Decrypt from "./pages/Decrypt.tsx"; 10 | 11 | const PasswordSetup = lazy(() => import("./pages/PasswordSetup.tsx")); 12 | 13 | export function LoadingIndicator() { 14 | return 17 | 18 | ; 19 | } 20 | 21 | const Root: FC = () => { 22 | useTelegramBackButton(); 23 | const theme = useTelegramTheme(); 24 | const encryptionManager = useContext(EncryptionManagerContext); 25 | const storageManager = useContext(StorageManagerContext); 26 | 27 | const { pathname } = useLocation(); 28 | 29 | return ( 30 | <> 31 | 32 | 33 | 34 | {!encryptionManager?.storageChecked ? : 35 | (!encryptionManager.passwordCreated ? : 36 | (encryptionManager.isLocked ? (pathname === "/reset" ? : ) : 37 | (storageManager?.ready ? : 38 | )))} 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export default Root 46 | -------------------------------------------------------------------------------- /src/components/AccountDragPreview.tsx: -------------------------------------------------------------------------------- 1 | import {FC, PropsWithChildren} from "react"; 2 | import {usePreview} from "react-dnd-preview"; 3 | import AccountSelectButton, {AccountSelectButtonProps} from "./AccountSelectButton.tsx"; 4 | import {Grid} from "@mui/material"; 5 | import {wobbleAnimation} from "../drag.ts"; 6 | 7 | const AccountDragPreview: FC = () => { 8 | const preview = usePreview(); 9 | if (!preview.display) { 10 | return <>; 11 | } 12 | const {item, style} = preview; 13 | 14 | return
15 | 16 | 17 | 18 | 19 | 20 |
21 | } 22 | 23 | export default AccountDragPreview; 24 | -------------------------------------------------------------------------------- /src/components/AccountSelectButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | ButtonBase, 4 | CircularProgress, 5 | Stack, 6 | SvgIcon, 7 | SxProps, 8 | Theme, 9 | TouchRippleActions, 10 | Typography 11 | } from "@mui/material"; 12 | import {FC, useContext, useEffect, useRef, useState} from "react"; 13 | import { icons } from "../globals"; 14 | import SVG from 'react-inlinesvg'; 15 | import useAccountTheme from "../hooks/useAccountTheme"; 16 | import { iconUrl } from "../icons/icons.ts"; 17 | import {DragSourceMonitor, useDrag, useDrop} from "react-dnd"; 18 | import {DragTypes, wobbleAnimation} from "../drag.ts"; 19 | import {getEmptyImage} from "react-dnd-html5-backend"; 20 | import {StorageManagerContext} from "../managers/storage/storage.tsx"; 21 | import useTelegramHaptics from "../hooks/telegram/useTelegramHaptics.ts"; 22 | 23 | export interface AccountSelectButtonProps { 24 | id: string; 25 | index: number; 26 | selected?: boolean, 27 | label: string, 28 | issuer?: string, 29 | icon: string, 30 | color: string, 31 | animating: boolean, 32 | onClick: () => void, 33 | } 34 | 35 | function createIconStyle(theme: Theme, selected: boolean): SxProps { 36 | return { height:35, width:35, color: selected ? theme.palette.primary.contrastText : theme.palette.primary.main }; 37 | } 38 | 39 | const AccountSelectButton: FC = (props) => { 40 | const { 41 | id, 42 | animating, 43 | index, 44 | selected = false, 45 | icon, 46 | label, 47 | issuer, 48 | onClick, 49 | color, 50 | } = props; 51 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 52 | const theme = useAccountTheme(color)!; 53 | const storageManager = useContext(StorageManagerContext); 54 | const { impactOccurred } = useTelegramHaptics(); 55 | 56 | const rippleRef = useRef(null); 57 | 58 | const [isHolding, setHolding] = useState(false); 59 | const [isTouching, setTouching] = useState(false); 60 | 61 | useEffect(() => { 62 | if(isTouching) { 63 | const timeout = setTimeout(() => { 64 | setHolding(true); 65 | rippleRef.current?.stop(); 66 | impactOccurred("heavy"); 67 | }, 300); 68 | 69 | return () => { 70 | clearTimeout(timeout); 71 | } 72 | } else { 73 | setHolding(false); 74 | rippleRef.current?.stop(); 75 | } 76 | }, [impactOccurred, isTouching]); 77 | 78 | const [{isDragging}, drag, preview] = useDrag({ 79 | type: DragTypes.AccountCard, 80 | item: props, 81 | canDrag: window.matchMedia("(pointer: fine)").matches || isHolding, 82 | collect: (monitor: DragSourceMonitor) => ({ 83 | isDragging: monitor.isDragging(), 84 | }), 85 | end: () => { 86 | storageManager?.saveAccounts(storageManager.accounts); 87 | setHolding(false); 88 | setTouching(false); 89 | rippleRef.current?.stop(); 90 | }, 91 | }); 92 | 93 | useEffect(() => { 94 | preview(getEmptyImage(), { captureDraggingState: true }) 95 | }, [preview]); 96 | 97 | const [, drop] = useDrop({ 98 | accept: DragTypes.AccountCard, 99 | drop: () => ({id}), 100 | hover: (draggedItem: AccountSelectButtonProps | null) => { 101 | if (draggedItem && !animating) { 102 | storageManager?.reorder(draggedItem.id, index); 103 | } 104 | }, 105 | }); 106 | const ref = useRef(null); 107 | drag(drop(ref)); 108 | 109 | return { 119 | if(!isHolding) { 120 | setTouching(false); 121 | rippleRef.current?.stop(); 122 | } 123 | }} 124 | onPointerDown={rippleRef.current?.start} 125 | onPointerUp={rippleRef.current?.stop} 126 | onTouchCancel={() => { setTouching(false); }} 127 | onTouchEnd={() => { setTouching(false); }} 128 | onTouchStart={() => { setTouching(true); }} 129 | > 130 | 132 | 133 | { 134 | Object.keys(icons).includes(icon) 135 | // shorthand for const Icon = icons[icon]; ; 136 | ? ((Icon) => )(icons[icon]) 137 | : 138 | } 143 | src={iconUrl(icon)}> 144 | 145 | 146 | } 147 | 148 | 156 | {issuer ? issuer : label} 157 | 158 | 159 | {issuer ? 167 | ({label}) 168 | : null} 169 | 170 | 171 | 172 | ; 173 | } 174 | 175 | export default AccountSelectButton; 176 | -------------------------------------------------------------------------------- /src/components/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import Colorful from "@uiw/react-color-colorful"; 3 | import type { ColorfulProps } from "@uiw/react-color-colorful"; 4 | import { IconButton, Popover } from "@mui/material"; 5 | import { Palette, PaletteOutlined } from "@mui/icons-material"; 6 | import { hsvaToHex, type HsvaColor } from "@uiw/color-convert"; 7 | 8 | interface ColorPickerProps extends ColorfulProps { 9 | selected: boolean; 10 | } 11 | 12 | const ColorPicker: FC = (props) => { 13 | const [anchorEl, setAnchorEl] = useState(null); 14 | 15 | const handleClick = (event: React.MouseEvent) => { 16 | setAnchorEl(event.currentTarget); 17 | }; 18 | 19 | const handleClose = () => { 20 | setAnchorEl(null); 21 | }; 22 | 23 | const open = Boolean(anchorEl); 24 | const id = open ? "color-popover" : undefined; 25 | 26 | return ( 27 | <> 28 | 37 | {props.selected ? ( 38 | 48 | ) : ( 49 | 50 | )} 51 | 52 | 66 | 75 | 76 | 77 | ); 78 | }; 79 | 80 | export default ColorPicker; 81 | -------------------------------------------------------------------------------- /src/components/FlatButton.tsx: -------------------------------------------------------------------------------- 1 | import {SvgIconComponent} from "@mui/icons-material"; 2 | import {FC} from "react"; 3 | import {ButtonBase, Stack, Typography, useTheme} from "@mui/material"; 4 | 5 | interface ButtonParams { 6 | onClick(): void; 7 | 8 | text: string; 9 | value?: string; 10 | disabled?: boolean; 11 | icon: SvgIconComponent; 12 | center?: boolean; 13 | } 14 | 15 | export const FlatButton: FC = ({ 16 | onClick, 17 | text, 18 | icon, 19 | value, 20 | disabled = false, 21 | center = false 22 | }) => { 23 | const theme = useTheme(); 24 | const Icon = icon; 25 | return 37 | 38 | 39 | 46 | {text} 47 | 48 | 49 | {value} 50 | 51 | 52 | ; 53 | } 54 | -------------------------------------------------------------------------------- /src/components/IconPicker.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useEffect, useState} from "react"; 2 | import { 3 | alpha, 4 | Grid, 5 | IconButton, 6 | Stack, 7 | useTheme, 8 | SvgIcon, 9 | CircularProgress, 10 | Typography, SxProps, Theme 11 | } from "@mui/material"; 12 | import CircleIcon from '@mui/icons-material/Circle'; 13 | import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked'; 14 | import {Icon, colors, icons} from "../globals.tsx"; 15 | import ColorPicker from "./ColorPicker.tsx"; 16 | import { useLocation, useNavigate } from "react-router-dom"; 17 | import { NewAccountState } from "../pages/CreateAccount.tsx"; 18 | import{ type ColorResult, hexToHsva } from "@uiw/color-convert"; 19 | import { EditAccountState } from "../pages/EditAccount.tsx"; 20 | import SVG from 'react-inlinesvg'; 21 | import { iconUrl } from "../icons/icons.ts"; 22 | import SearchIcon from "@mui/icons-material/Search"; 23 | import normalizeCustomColor from "../icons/normalizeCustomColor.ts"; 24 | import {useL10n} from "../hooks/useL10n.ts"; 25 | 26 | interface IconPickerProps { 27 | selectedIcon: Icon; 28 | setSelectedIcon(icon: Icon): void; 29 | selectedColor: string; 30 | setSelectedColor(color: string): void; 31 | } 32 | 33 | function buttonStyle(isSelected: boolean, color: string): SxProps { 34 | return { 35 | margin: 0.5, 36 | padding: 1, 37 | borderRadius: 100, 38 | outlineStyle: 'solid', 39 | outlineWidth: 1, 40 | outlineColor: alpha(color, 0.3), 41 | bgcolor: isSelected ? color: alpha(color, 0.15), 42 | } 43 | } 44 | 45 | const IconPicker: FC = ({ selectedIcon, setSelectedIcon, selectedColor, setSelectedColor }) => { 46 | const theme = useTheme(); 47 | const [mainColor, setMainColor] = useState('#fff'); 48 | const location = useLocation(); 49 | const state = location.state as NewAccountState | EditAccountState; 50 | const navigate = useNavigate(); 51 | const l10n = useL10n(); 52 | const [pickerColor, setPickerColor] = useState(); 53 | 54 | useEffect(() => { 55 | if (pickerColor) 56 | setSelectedColor(pickerColor.hex); 57 | }, [pickerColor]) 58 | 59 | useEffect(() => { 60 | setMainColor(alpha(selectedColor, 0.7)) 61 | }, [selectedColor]) 62 | 63 | const isCustom = !Object.keys(icons).includes(selectedIcon); 64 | selectedColor = normalizeCustomColor(selectedColor, theme); 65 | 66 | return 67 | 68 | {colors.map((color: string) => { 69 | return { setSelectedColor(color); }} 73 | > 74 | {selectedColor === color ? : } 75 | 76 | 77 | })} 78 | {setPickerColor(color)}} /> 81 | 82 | 83 | 84 | {Object.entries(icons).map(([key, Icon]) => { 85 | return 86 | { setSelectedIcon(key); }}> 89 | 90 | 91 | ; 92 | })} 93 | 94 | {navigate('/icons', { state })}} 98 | > 99 | 100 | {isCustom ? 101 | } 106 | src={iconUrl(selectedIcon)}> 107 | 108 | : } 109 | 110 | {isCustom ? l10n("ActionChangeMenu") : l10n("ActionMoreMenu")} 111 | 112 | 113 | 114 | 115 | 116 | 117 | ; 118 | }; 119 | 120 | export default IconPicker; 121 | -------------------------------------------------------------------------------- /src/components/LottieAnimation.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/display-name */ 2 | /* eslint-disable react/prop-types */ 3 | import {FC, useEffect, useRef} from "react"; 4 | import Lottie, {LottieRefCurrentProps} from "lottie-react"; 5 | 6 | interface LottieProps { 7 | animationData: unknown; 8 | initialSegment?: [number, number]; 9 | loop?: boolean 10 | speed?: number 11 | } 12 | 13 | const LottieAnimation: FC = ({ animationData, initialSegment, loop, speed }) => { 14 | const lottie = useRef(null); 15 | useEffect(() => { 16 | if (loop === false) { 17 | lottie.current?.goToAndStop(0) 18 | } 19 | else if (loop === true) { 20 | lottie.current?.goToAndPlay(0) 21 | } 22 | }, [loop]) 23 | useEffect(() => { 24 | lottie.current?.goToAndPlay(0); 25 | }, []); 26 | useEffect(() => { 27 | if (speed) 28 | lottie.current?.setSpeed(speed) 29 | }, [speed]) 30 | 31 | return { 33 | if (lottie.current?.animationItem?.isPaused) lottie.current.goToAndPlay(0); 34 | }} 35 | lottieRef={lottie} 36 | style={{width: '50%'}} 37 | initialSegment={initialSegment} 38 | animationData={animationData} 39 | autoplay={false} 40 | loop={loop ?? false} 41 | />; 42 | } 43 | 44 | export default LottieAnimation; 45 | -------------------------------------------------------------------------------- /src/components/NewAccountButton.tsx: -------------------------------------------------------------------------------- 1 | import {Box, ButtonBase, Stack, Typography, useTheme} from "@mui/material"; 2 | import AddIcon from '@mui/icons-material/Add'; 3 | import {useNavigate} from "react-router-dom"; 4 | import {useL10n} from "../hooks/useL10n.ts"; 5 | export default function NewAccountButton() { 6 | const navigate = useNavigate(); 7 | const theme = useTheme(); 8 | const l10n = useL10n(); 9 | return { navigate('/new'); }}> 10 | 11 | 12 | 13 | 14 | 20 | {l10n("ActionAddNewAccount")} 21 | 22 | 23 | 24 | 25 | ; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/NewUpdateDialog.tsx: -------------------------------------------------------------------------------- 1 | import {FC, PropsWithChildren, useEffect} from "react"; 2 | import {useL10n} from "../hooks/useL10n.ts"; 3 | 4 | const NewUpdateDialog: FC = function () { 5 | const l10n = useL10n(); 6 | useEffect(() => { 7 | 8 | window.Telegram.WebApp.CloudStorage.getItem("dialogSeen", (error, result) => { 9 | if(error) return; 10 | if (result !== "true") { 11 | window.Telegram.WebApp.showPopup({ 12 | title: l10n("NewUpdateTitle"), 13 | message: l10n("NewUpdateText"), 14 | buttons: [ 15 | {type: "default", text: l10n("ActionLearnMore"), id: "open"}, 16 | {type: "close"}, 17 | ] 18 | }, (id) => { 19 | if (id == "open") { 20 | window.Telegram.WebApp.openTelegramLink(import.meta.env.VITE_CHANNEL_LINK); 21 | } 22 | }); 23 | window.Telegram.WebApp.CloudStorage.setItem("dialogSeen", "true"); 24 | } 25 | }); 26 | }, []); 27 | 28 | return (<>); 29 | }; 30 | 31 | export default NewUpdateDialog; 32 | -------------------------------------------------------------------------------- /src/components/PlausibleAnalytics.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, FC, PropsWithChildren, useEffect, useMemo} from "react"; 2 | import Plausible, {EventOptions, PlausibleOptions} from "plausible-tracker"; 3 | 4 | /** 5 | * PlausibleAnalytics is responsible for tracking custom event goals. 6 | * 7 | * To get an instance of PlausibleAnalytics, you should use the useContext hook: 8 | * @example 9 | * const analytics = useContext(PlausibleAnalyticsContext); 10 | */ 11 | export interface PlausibleAnalytics { 12 | /** 13 | * Tracks a new event. 14 | * @param eventName - name of the event to track 15 | * @param options - callback and additional event props 16 | * @param eventData 17 | */ 18 | trackEvent: ( 19 | eventName: string, 20 | options?: EventOptions, 21 | eventData?: PlausibleOptions 22 | ) => void; 23 | } 24 | export const PlausibleAnalyticsContext = createContext(null); 25 | 26 | export interface PlausibleAnalyticsProps { 27 | domain: string; 28 | apiHost: string; 29 | } 30 | 31 | /** 32 | * PlausibleAnalytics is created using PlausibleAnalyticsProvider component 33 | */ 34 | export const PlausibleAnalyticsProvider: FC> = ({children, domain, apiHost}) => { 35 | const plausible = useMemo(() => { 36 | return Plausible({ 37 | domain, 38 | apiHost 39 | }) 40 | }, [domain, apiHost]); 41 | 42 | useEffect(() => { 43 | plausible.enableAutoPageviews(); 44 | }, [plausible]); 45 | 46 | return 47 | {children} 48 | 49 | }; 50 | -------------------------------------------------------------------------------- /src/components/TelegramTextField.tsx: -------------------------------------------------------------------------------- 1 | import {styled, TextField, TextFieldProps} from "@mui/material"; 2 | 3 | const TelegramTextField = styled((props: TextFieldProps) => ( 4 | {key === "Enter" ? props.onSubmit ? props.onSubmit() : void 0 : void 0}} 9 | onFocus={(e) => { 10 | e.target.scrollIntoView({behavior: "smooth"}); 11 | }} 12 | /> 13 | ))(({ theme }) => ({ 14 | 'label': { 15 | color: theme.palette.text.secondary, 16 | fontSize: "0.9em", 17 | lineHeight: "1em", 18 | }, 19 | 'label.MuiInputLabel-shrink': { 20 | fontSize: "1em", 21 | }, 22 | '& .MuiInput-root': { 23 | '--TextField-brandBorderColor': theme.palette.divider, 24 | '&:not(.Mui-disabled, .Mui-error):before': { 25 | borderBottom: '1px solid var(--TextField-brandBorderColor)', 26 | }, 27 | '&:hover:not(.Mui-disabled, .Mui-error):before': { 28 | borderBottom: '1px solid var(--TextField-brandBorderColor)', 29 | }, 30 | }, 31 | })); 32 | 33 | export default TelegramTextField; 34 | -------------------------------------------------------------------------------- /src/drag.ts: -------------------------------------------------------------------------------- 1 | import {HTML5Backend} from "react-dnd-html5-backend"; 2 | import {MouseTransition, MultiBackendOptions, TouchTransition} from "react-dnd-multi-backend"; 3 | import {TouchBackend} from "react-dnd-touch-backend"; 4 | import {SxProps, Theme} from "@mui/material"; 5 | 6 | export const wobbleAnimation: SxProps = { 7 | "@keyframes rotate": { 8 | "0%": { 9 | transform: "rotate(-5deg)", 10 | animationTimingFunction: "ease-in", 11 | }, 12 | "50%": { 13 | transform: "rotate(5deg)", 14 | animationTimingFunction: "ease-out", 15 | }, 16 | }, 17 | transformOrigin: "50% 10%", 18 | animation: "rotate 0.5s ease infinite", 19 | animationDirection: "alternate", 20 | }; 21 | 22 | export const HTML5toTouch: MultiBackendOptions = { 23 | backends: [ 24 | { 25 | id: 'html5', 26 | backend: HTML5Backend, 27 | transition: MouseTransition, 28 | }, 29 | { 30 | id: 'touch', 31 | backend: TouchBackend, 32 | options: { 33 | enableMouseEvents: false 34 | }, 35 | preview: true, 36 | transition: TouchTransition, 37 | }, 38 | ], 39 | } 40 | 41 | export enum DragTypes { 42 | AccountCard = "card", 43 | } 44 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | html, body, #root, #root>div { 2 | margin: 0; 3 | min-height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | flex-grow: 1; 7 | box-sizing: border-box; 8 | /* fix telegram sliding down on mobiles */ 9 | /* touch-action: none !important; */ 10 | } 11 | 12 | .outdated-container { 13 | align-self: center; 14 | justify-content: center; 15 | font-family: Inter, serif; 16 | text-align: center; 17 | } 18 | -------------------------------------------------------------------------------- /src/globals.tsx: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import {SvgIconComponent} from "@mui/icons-material"; 4 | import PaidIcon from "@mui/icons-material/Paid"; 5 | import AccountBalanceWalletIcon from "@mui/icons-material/AccountBalanceWallet"; 6 | import StoreIcon from "@mui/icons-material/Store"; 7 | import CommentIcon from "@mui/icons-material/Comment"; 8 | import EmailIcon from "@mui/icons-material/Email"; 9 | import KeyIcon from '@mui/icons-material/Key'; 10 | import CloudIcon from '@mui/icons-material/Cloud'; 11 | import StorageIcon from '@mui/icons-material/Storage'; 12 | import {Language, LanguageDescription} from "./managers/localization.tsx"; 13 | 14 | export const icons: Record = { 15 | "money": PaidIcon, 16 | "wallet": AccountBalanceWalletIcon, 17 | "store": StoreIcon, 18 | "comment": CommentIcon, 19 | "email": EmailIcon, 20 | "key": KeyIcon, 21 | "cloud": CloudIcon, 22 | "storage": StorageIcon, 23 | } as const; 24 | 25 | type Icons = keyof typeof icons; 26 | export type Icon = Icons[number]; 27 | 28 | export const colors: string[] = [ 29 | "#1c98e6", "#2e7d32", "#ed6c02", "#9c27b0", "#d32f2f", "#0288d1", 30 | ] as const; 31 | 32 | export type Color = string; 33 | 34 | export const languages = ["en", "ru", "uk", "de", "fr", "es", "hi", "pt"] as const; 35 | export const languageDescriptions: Record = { 36 | "en": { 37 | native: "English", 38 | default: "English" 39 | }, 40 | "ru": { 41 | native: "Русский", 42 | default: "Russian" 43 | }, 44 | "uk": { 45 | native: "Українська", 46 | default: "Ukrainian" 47 | }, 48 | "de": { 49 | native: "Deutsch", 50 | default: "German" 51 | }, 52 | "fr": { 53 | native: "Français", 54 | default: "French" 55 | }, 56 | "es": { 57 | native: "Español", 58 | default: "Spanish" 59 | }, 60 | "hi": { 61 | native: "हिन्दी", 62 | default: "Hindi" 63 | }, 64 | "pt": { 65 | native: "Português", 66 | default: "Portuguese", 67 | }, 68 | } as const; 69 | export const defaultLanguage: Language = "en"; 70 | -------------------------------------------------------------------------------- /src/hooks/telegram/useTelegramBackButton.ts: -------------------------------------------------------------------------------- 1 | import {useLocation, useNavigate} from "react-router-dom"; 2 | import {useCallback, useEffect} from "react"; 3 | 4 | /** 5 | * This hook sends a request to telegram to display button to navigate back in history. 6 | * It is used only once in Root. tsx. This hook automatically shows the button if the current route is not root (/). 7 | * To navigate back, useNavigate(-1) hook from React Router is used. 8 | */ 9 | export default function useTelegramBackButton() { 10 | const navigate = useNavigate(); 11 | const location = useLocation(); 12 | const goBack = useCallback(() => { 13 | navigate(-1); 14 | }, [navigate]); 15 | 16 | useEffect(() => { 17 | window.Telegram.WebApp.BackButton.onClick(goBack); 18 | 19 | return () => { 20 | window.Telegram.WebApp.BackButton.offClick(goBack); 21 | } 22 | }, [goBack]); 23 | 24 | useEffect(()=>{ 25 | if(location.key != "default" && location.pathname !== "/") { 26 | window.Telegram.WebApp.BackButton.show(); 27 | } else { 28 | window.Telegram.WebApp.BackButton.hide(); 29 | } 30 | }, [location]); 31 | } 32 | -------------------------------------------------------------------------------- /src/hooks/telegram/useTelegramHaptics.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This object controls haptic feedback. 3 | */ 4 | export interface TelegramHaptics { 5 | /** 6 | * A method tells that an impact occurred. 7 | * @param style - the style of haptic impact. 8 | */ 9 | impactOccurred: (style: "light" | "medium" | "heavy" | "rigid" | "soft") => void, 10 | /** 11 | * A method tells that a task or action has succeeded, failed, or produced a warning. 12 | * @param style - the action result style 13 | */ 14 | notificationOccurred: (style: "error" | "success" | "warning") => void, 15 | /** 16 | * A method tells that the user has changed a selection. 17 | */ 18 | selectionChanged: () => void, 19 | } 20 | 21 | /** 22 | * This hook wraps the Telegram HapticFeedback object. 23 | */ 24 | export default function useTelegramHaptics(): TelegramHaptics { 25 | return { 26 | impactOccurred: window.Telegram.WebApp.HapticFeedback.impactOccurred, 27 | notificationOccurred: window.Telegram.WebApp.HapticFeedback.notificationOccurred, 28 | selectionChanged: window.Telegram.WebApp.HapticFeedback.selectionChanged, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/hooks/telegram/useTelegramHeaderColor.ts: -------------------------------------------------------------------------------- 1 | import {useEffect} from "react"; 2 | 3 | /** 4 | * Hook, that allows to set the app's header color. 5 | * @param color - hex color, don't pass the color to set to default. 6 | */ 7 | export default function useTelegramHeaderColor(color?: `#${string}`) { 8 | useEffect(() => { 9 | window.Telegram.WebApp.setHeaderColor(color ?? "bg_color") 10 | 11 | return () => { 12 | window.Telegram.WebApp.setHeaderColor("bg_color"); 13 | } 14 | }, [color]); 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/telegram/useTelegramMainButton.ts: -------------------------------------------------------------------------------- 1 | import {useEffect} from "react"; 2 | import useTelegramTheme from "./useTelegramTheme"; 3 | 4 | /** 5 | * This hook shows a main button and adds the callback as the listener for clicks. 6 | * The button is automatically hidden if the component using this hook is disposed. 7 | * @param onClick - a callback that is executed when user presses the button. 8 | * If the callback returns true, the button will be hidden. 9 | * @param text - a string which contains the text that should be displayed on the button. 10 | * @param [disabled = false] - a boolean flag that indicates, whether the button should be disabled or not. 11 | */ 12 | export default function useTelegramMainButton(onClick: () => boolean, text: string, disabled = false) { 13 | const { palette } = useTelegramTheme(); 14 | 15 | useEffect(() => { 16 | window.Telegram.WebApp.MainButton.setText(text); 17 | window.Telegram.WebApp.MainButton.show(); 18 | return () => { 19 | window.Telegram.WebApp.MainButton.hide(); 20 | } 21 | }, [text]); 22 | 23 | useEffect(() => { 24 | function handler() { 25 | if(onClick()) { 26 | window.Telegram.WebApp.MainButton.hide(); 27 | } 28 | } 29 | 30 | window.Telegram.WebApp.MainButton.onClick(handler); 31 | return () => { 32 | window.Telegram.WebApp.MainButton.offClick(handler); 33 | } 34 | }, [onClick]); 35 | 36 | useEffect(() => { 37 | if (disabled) { 38 | window.Telegram.WebApp.MainButton.disable(); 39 | window.Telegram.WebApp.MainButton.color = palette.mode === "light" 40 | ? palette.action.disabled as `#${string}` 41 | : '#858585' as `#${string}`; 42 | } else { 43 | window.Telegram.WebApp.MainButton.enable(); 44 | window.Telegram.WebApp.MainButton.color = palette.primary.main as `#${string}`; 45 | } 46 | }, [disabled]); 47 | } 48 | -------------------------------------------------------------------------------- /src/hooks/telegram/useTelegramQrScanner.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Telegram QR scanner hook. 3 | * @param callback - a function that is executed after a successful scan. 4 | * @return A function to open the QR-code scanner. 5 | * Optionally, accepts `text` string as an argument. 6 | * The text to be displayed under the 'Scan QR' heading, 0-64 characters. 7 | */ 8 | export default function useTelegramQrScanner(callback: (scanned: string) => void): (text?: string) => void { 9 | return (text) => { 10 | window.Telegram.WebApp.showScanQrPopup({ text }, result => { 11 | callback(result); 12 | return true; 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/telegram/useTelegramTheme.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { createTheme, Theme } from "@mui/material"; 3 | import { ThemeParams } from "@twa-dev/types"; 4 | 5 | function materialThemeFromTelegramTheme( 6 | mode: "light" | "dark", 7 | themeParams?: ThemeParams 8 | ): Theme { 9 | // Create a default theme if not supplied by Telegram (i.e. when testing in a browser) 10 | if (themeParams?.button_color == undefined) { 11 | return createTheme(); 12 | } 13 | 14 | return createTheme({ 15 | palette: { 16 | primary: { 17 | main: themeParams.button_color, 18 | contrastText: themeParams.button_text_color, 19 | }, 20 | error: { 21 | main: themeParams.destructive_text_color, 22 | }, 23 | background: { 24 | default: themeParams.secondary_bg_color, 25 | paper: themeParams.section_bg_color, 26 | }, 27 | action: { 28 | disabled: "#9E9E9E", 29 | }, 30 | text: { 31 | primary: themeParams.text_color, 32 | secondary: themeParams.hint_color, 33 | }, 34 | divider: themeParams.hint_color, 35 | mode, 36 | }, 37 | typography: { 38 | fontFamily: [ 39 | "Inter", 40 | '"Helvetica Neue"', 41 | "Arial", 42 | "sans-serif", 43 | ].join(","), 44 | allVariants: { 45 | color: themeParams.text_color, 46 | }, 47 | subtitle1: { 48 | color: themeParams.hint_color, 49 | }, 50 | subtitle2: { 51 | color: themeParams.hint_color, 52 | }, 53 | }, 54 | }); 55 | } 56 | 57 | /** 58 | * Creates a Material UI theme from Telegram-provided color palette. This hook automatically listens for theme change events. 59 | * @returns A Material UI theme, to be used with ThemeProvider 60 | */ 61 | export default function useTelegramTheme() { 62 | const [theme, setTheme] = useState( 63 | materialThemeFromTelegramTheme( 64 | window.Telegram.WebApp.colorScheme, 65 | window.Telegram.WebApp.themeParams 66 | ) 67 | ); 68 | useEffect(() => { 69 | function themeChanged() { 70 | setTheme( 71 | materialThemeFromTelegramTheme( 72 | window.Telegram.WebApp.colorScheme, 73 | window.Telegram.WebApp.themeParams 74 | ) 75 | ); 76 | } 77 | 78 | window.Telegram.WebApp.onEvent("themeChanged", themeChanged); 79 | 80 | return () => { 81 | window.Telegram.WebApp.offEvent("themeChanged", themeChanged); 82 | }; 83 | }); 84 | 85 | return theme; 86 | } 87 | -------------------------------------------------------------------------------- /src/hooks/useAccount.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from "react"; 2 | import {HOTP, TOTP, URI} from "otpauth"; 3 | 4 | /** 5 | * This hook generates the actual 2FA code. 6 | * Progress is updated every 300ms. If accountUri is not provided or invalid, the code returned is "N/A". 7 | * 8 | * Generation of codes is implemented in the [otpauth library](https://github.com/hectorm/otpauth). 9 | * @param {string} accountUri - a string which contains a [key URI](https://github.com/google/google-authenticator/wiki/Key-Uri-Format). 10 | * @returns - `code` is the generated code string. 11 | * - `period` is the token time-to-live duration in seconds. 12 | * - `progress` is the current token lifespan progress. A number between 0 (fresh) and 1 (expired). 13 | */ 14 | export default function useAccount(accountUri?: string): { code: string, period: number, progress: number } { 15 | const [code, setCode] = useState("N/A"); 16 | const [period, setPeriod] = useState(30); 17 | useEffect(() => { 18 | if (!accountUri) return; 19 | let otp: HOTP | TOTP; 20 | try { 21 | otp = URI.parse(accountUri); 22 | } catch (e) { 23 | console.error("weird uri!", accountUri); 24 | setCode("N/A"); 25 | return; 26 | } 27 | if (otp instanceof HOTP) { 28 | throw new Error("HOTP is not supported"); 29 | } 30 | 31 | setPeriod(otp.period); 32 | let timeout: NodeJS.Timeout | null = null; 33 | 34 | function cycle() { 35 | setCode(otp.generate()); 36 | const untilNext = period - (Math.floor(Date.now() / 1000) % period); 37 | timeout = setTimeout(cycle, untilNext * 1000); 38 | } 39 | cycle(); 40 | 41 | return () => { 42 | if (timeout) clearTimeout(timeout); 43 | } 44 | }, [accountUri, period]); 45 | 46 | 47 | const [progress, setProgress] = useState(0); 48 | useEffect(() => { 49 | if (!accountUri) return; 50 | const timer = setInterval(()=>{ 51 | setProgress(((Date.now() / 1000) % period) / period); 52 | }, 300); 53 | 54 | return () => { 55 | clearInterval(timer); 56 | }; 57 | }, [accountUri, period]); 58 | return {code, period, progress}; 59 | } 60 | -------------------------------------------------------------------------------- /src/hooks/useAccountTheme.ts: -------------------------------------------------------------------------------- 1 | import { useTheme } from "@mui/material/styles"; 2 | import {Theme, createTheme} from "@mui/material"; 3 | import normalizeCustomColor from "../icons/normalizeCustomColor.ts"; 4 | 5 | /** 6 | * Creates a Material UI theme for the provided account color. 7 | * @param {?string} color - account primary color 8 | */ 9 | export default function useAccountTheme( 10 | color: string | undefined 11 | ): Theme | null { 12 | const theme = useTheme(); 13 | 14 | if (!color) return null; 15 | 16 | color = normalizeCustomColor(color, theme); 17 | 18 | return createTheme(theme, { 19 | palette: { 20 | primary: theme.palette.augmentColor({ 21 | color: { 22 | main: color, 23 | }, 24 | }) 25 | // primary: { 26 | // main: colorMain, 27 | // light: alpha(color, 0.5), 28 | // dark: alpha(color, 0.9), 29 | // contrastText: 30 | // getContrastRatio(colorMain, "#fff") > 4.5 ? "#fff" : "#111", 31 | // }, 32 | }, 33 | } as Theme); 34 | } 35 | -------------------------------------------------------------------------------- /src/hooks/useL10n.ts: -------------------------------------------------------------------------------- 1 | import {useContext} from "react"; 2 | import {LocalizationManagerContext} from "../managers/localization.tsx"; 3 | 4 | export function useL10n() { 5 | const localizationManager = useContext(LocalizationManagerContext); 6 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 7 | return localizationManager!.l10n.bind(localizationManager); 8 | } 9 | -------------------------------------------------------------------------------- /src/icons/icons.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { titleToIconSlug } from "./icons.ts"; 3 | 4 | describe("titleToIconSlug", () => { 5 | const url = 6 | "https://raw.githubusercontent.com/simple-icons/simple-icons/master/slugs.md"; 7 | 8 | test("should convert all titles to slugs", async () => { 9 | // Load convert table from simpleicons 10 | const response = await fetch(url); 11 | expect(response).toHaveProperty("ok", true) 12 | 13 | const text = await response.text(); 14 | const table = text 15 | .split("\n") 16 | .filter((line) => line.includes("|")) 17 | .map((line) => line.split("|").map((cell) => cell.trim().replace(/`/gi, ''))) 18 | .map(line => line.filter(cell => cell.length > 0)) 19 | // skip header of table 20 | .slice(2); 21 | 22 | // more than 2000 icons at @v11 23 | expect(table.length).toBeGreaterThan(2000) 24 | 25 | table.forEach(([brandName, brandSlug], i) => { 26 | // Skip duplicate values 27 | if (i-1 >= 0 && table[i-1][0] == brandName) return; 28 | 29 | expect(titleToIconSlug(brandName)).toBe(brandSlug); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/icons/icons.ts: -------------------------------------------------------------------------------- 1 | export const ICONS_CDN_BASE = "https://cdn.jsdelivr.net/npm/simple-icons@13.0.0"; 2 | export const ICONS_CDN = ICONS_CDN_BASE + "/icons"; 3 | export const ICONS_DATA_URL = ICONS_CDN_BASE + "/_data/simple-icons.json"; 4 | 5 | // from https://github.com/simple-icons/simple-icons/blob/master/sdk.mjs 6 | const TITLE_TO_SLUG_REPLACEMENTS: Record = { 7 | "+": "plus", 8 | ".": "dot", 9 | "&": "and", 10 | "#": "sharp", 11 | "đ": "d", 12 | "ħ": "h", 13 | "ı": "i", 14 | "ĸ": "k", 15 | "ŀ": "l", 16 | "ł": "l", 17 | "ß": "ss", 18 | "ŧ": "t", 19 | }; 20 | 21 | const TITLE_TO_SLUG_CHARS_REGEX = new RegExp( 22 | `[${Object.keys(TITLE_TO_SLUG_REPLACEMENTS).join("")}]`, 23 | "g" 24 | ); 25 | const TITLE_TO_SLUG_RANGE_REGEX = /[^a-z\d]/g; 26 | 27 | export function titleToIconSlug(title: string): string { 28 | const exceptions: Record = { 29 | "Amazon Identity Access Management": "amazoniam", 30 | "del.icio.us": "delicious", 31 | "Ferrari N.V.": "ferrarinv", 32 | "OWASP Dependency-Check": "dependencycheck", 33 | "Sat.1": "sat1", 34 | "Sphere Online Judge": "spoj", 35 | "Tata Consultancy Services": "tcs", 36 | "Warner Bros.": "warnerbros", 37 | }; 38 | if (exceptions[title]) return exceptions[title]; 39 | return title 40 | .toLowerCase() 41 | .replace( 42 | TITLE_TO_SLUG_CHARS_REGEX, 43 | (char) => TITLE_TO_SLUG_REPLACEMENTS[char] 44 | ) 45 | .normalize("NFD") 46 | .replace(TITLE_TO_SLUG_RANGE_REGEX, ""); 47 | } 48 | 49 | export function iconUrl(slug: string) { 50 | return `${ICONS_CDN}/${slug}.svg`; 51 | } 52 | -------------------------------------------------------------------------------- /src/icons/normalizeCustomColor.ts: -------------------------------------------------------------------------------- 1 | import {darken, getContrastRatio, lighten, Theme} from "@mui/material"; 2 | 3 | /** 4 | * Adjust color to be visible on the theme background. 5 | * @param color - the color to be normalized 6 | * @param theme - the app's theme 7 | */ 8 | export default function normalizeCustomColor(color: string, theme: Theme) { 9 | if(getContrastRatio(color, theme.palette.background.paper) >= 2) return color; 10 | if (theme.palette.mode === "dark") { 11 | color = lighten(color, 0.4); 12 | } else { 13 | color = darken(color, 0.4); 14 | } 15 | return color; 16 | } 17 | -------------------------------------------------------------------------------- /src/lang/de.ts: -------------------------------------------------------------------------------- 1 | export const lang = { 2 | "Settings.General": "Allgemein", 3 | "Language": "Sprache", 4 | "NewsChannel": "TeleOTP-Nachrichten", 5 | "ActionOpen": "Öffnen", 6 | "Settings.Security": "Sicherheit", 7 | "Password": "Passwort", 8 | "ActionChange": "Ändern", 9 | "KeepUnlocked": "Entsperrt bleiben", 10 | "Enabled": "Aktiviert", 11 | "Disabled": "Deaktiviert", 12 | "UseBiometrics": "Biometrie verwenden", 13 | "NotAvailable": "Nicht verfügbar", 14 | "LockAccounts": "Konten sperren", 15 | "Settings.Accounts": "Konten", 16 | "Accounts": "Konten", 17 | "ActionExportAccounts": "Konten exportieren", 18 | "ActionRemoveAccounts": "Alle Konten entfernen", 19 | "Version": "Version", 20 | "StarUs": "Star us on GitHub", 21 | "HelpTranslating": "Hilfe bei der Übersetzung", 22 | "DevTools": "Entwicklerwerkzeuge", 23 | "ActionOpenSettings": "Einstellungen öffnen", 24 | "ActionChangeMenu": "Ändern...", 25 | "ActionMoreMenu": "Mehr...", 26 | "ActionAddNewAccount": "Neues Konto hinzufügen", 27 | "NewUpdateTitle": "Gefällt TeleOTP?", 28 | "NewUpdateText": "Bleiben Sie auf dem Laufenden für Ankündigungen und neue Versionen in unserem Kanal", 29 | "ActionLearnMore": "Mehr erfahren", 30 | "ExportAccountsTitle": "Konten exportieren", 31 | "ExportAccountsText": "Sie können Konten per Link zu TeleOTP exportieren oder mit einem QR-Code in Google Authenticator (oder ähnlichem).", 32 | "GoBackAction": "Zurück", 33 | "CopyLinkAction": "Link kopieren", 34 | "Export.ViaLink": "Via Link", 35 | "Export.ViaQR": "Über QR", 36 | "LinkExportTitle": "Link exportieren", 37 | "LinkExportDescription": "Kopieren Sie den Link, um Konten zu einem anderen Telegramm-Benutzer zu verschieben. Oder halten Sie ihn fest, um ein Backup zu erstellen.", 38 | "LinkExportSecretWarning": "Achten Sie darauf, dass Sie es geheim halten! Jeder kann über diesen Link auf Ihre Codes zugreifen.", 39 | "QRExportDescription": "Sie können Ihre Konten in Google Authenticator oder TeleOTP importieren, indem Sie den QR-Code scannen.", 40 | "EmptyLabelAlert": "Label-Feld darf nicht leer sein!", 41 | "CreateAction": "Anlegen", 42 | "NewAccountTitle": "Neues Konto hinzufügen", 43 | "AdditionalInfo": "Zusätzliche Kontoinformationen eingeben", 44 | "LabelLabel": "Label", 45 | "RequiredLabel": "(erforderlich)", 46 | "IssuerLabel": "Service", 47 | "DecryptAction": "Entschlüsseln", 48 | "DecryptTitle": "Entschlüssele deine Konten", 49 | "DecryptDescription": "Geben Sie Ihr Entschlüsselungspasswort ein, um Zugriff auf Ihre Konten zu erhalten", 50 | "PasswordLabel": "Passwort", 51 | "WrongPasswordError": "Falsches Passwort", 52 | "ResetPasswordAction": "Passwort zurücksetzen...", 53 | "SaveAction": "Speichern", 54 | "EditTitle": "Konto-Info bearbeiten", 55 | "EditDescription": "Kontoinformationen ändern", 56 | "DeleteConfirmation": "Sind Sie sicher, dass Sie {account} löschen möchten?", 57 | "Confirm": "Ja", 58 | "DeleteAccountAction": "Konto löschen", 59 | "IconsFetchError": "Fehler beim Abrufen der Icon-Daten, bitte später erneut versuchen", 60 | "BrowseIconsTitle": "Icons durchsuchen", 61 | "SearchPatternLabel": "Muster zum Suchen", 62 | "SearchHelper": "Mindestens zwei Zeichen eingeben", 63 | "StartTyping": "Tippen zum Suchen starten", 64 | "IconsProvidedBy": "Symbole bereitgestellt von ", 65 | "NextStepAction": "Nächste", 66 | "AddManualTitle": "Konto manuell hinzufügen", 67 | "AddManualDescription": "Geliefertes Kontogeheimnis eingeben", 68 | "SecretLabel": "Geheimnis", 69 | "InvalidSecretError": "Ungültiges Geheimnis", 70 | "InvalidQRCodeAlert": "Invalid QR code", 71 | "HOTPUnimplementedAlert": "HOTP-Unterstützung ist noch nicht implementiert :(", 72 | "NewAccountDescription": "Schützen Sie Ihr Konto mit Zwei-Faktor-Authentifizierung (Import von Google Authenticator wird ebenfalls unterstützt)", 73 | "ScanQRText": "Scan QR code", 74 | "EnterManuallyAction": "Manuell eingeben", 75 | "ChangePasswordAction": "Passwort ändern", 76 | "CreatePasswordAction": "Passwort erstellen", 77 | "ChangePasswordTitle": "Neues Passwort festlegen", 78 | "CreatePasswordTitle": "Passwort einrichten", 79 | "PasswordSetupDescription": "Geben Sie ein neues Verschlüsselungspasswort ein, um Ihre Konten sicher zu speichern", 80 | "PasswordRequirementError": "Das Passwort muss mindestens 3 sein", 81 | "RepeatPasswordLabel": "Passwort wiederholen", 82 | "PasswordRepeatIncorrectError": "Passwörter stimmen nicht überein", 83 | "RemovePermanentlyAction": "PERMANENTLY entfernen", 84 | "PasswordResetTitle": "Passwort zurücksetzen", 85 | "DeleteWarning": "Du bist dabei alle deine Konten zu löschen PERMANENTLY. Du wirst sie nicht wiederherstellen können.", 86 | "TypeDeleteConfirmationPhrase": "Wenn Sie absolut sicher sind, geben Sie den Ausdruck ein", 87 | "DeleteConfirmationPhrase": "Ja, alles löschen", 88 | "DeleteConfirmationLabel": "Ihre Konten löschen und das Passwort zurücksetzen?", 89 | "DeleteConfirmationPhraseError": "Geben Sie \"{phrase} \" ein", 90 | "BiometricsRequestReason": "Erlaube Zugriff auf biometrische Daten, um Ihre Konten entschlüsseln zu können", 91 | "BiometricsAuthenticateReason": "Authentifizieren, um Ihre Konten zu entschlüsseln" 92 | }; -------------------------------------------------------------------------------- /src/lang/en.ts: -------------------------------------------------------------------------------- 1 | export const lang = { 2 | "Settings.General": "General", 3 | "Language": "Language", 4 | "NewsChannel": "TeleOTP News", 5 | "ActionOpen": "Open", 6 | "Settings.Security": "Security", 7 | "Password": "Password", 8 | "ActionChange": "Change", 9 | "KeepUnlocked": "Keep unlocked", 10 | "Enabled": "Enabled", 11 | "Disabled": "Disabled", 12 | "UseBiometrics": "Use biometrics", 13 | "NotAvailable": "Not available", 14 | "LockAccounts": "Lock accounts", 15 | "Settings.Accounts": "Accounts", 16 | "Accounts": "Accounts", 17 | "ActionExportAccounts": "Export accounts", 18 | "ActionRemoveAccounts": "Remove all accounts", 19 | "Version": "Version", 20 | "StarUs": "Star us on GitHub", 21 | "HelpTranslating": "Help with translation", 22 | "DevTools": "Dev tools", 23 | 24 | "ActionOpenSettings": "Open settings", 25 | 26 | "ActionChangeMenu": "Change...", 27 | "ActionMoreMenu": "More...", 28 | 29 | "ActionAddNewAccount": "Add new account", 30 | 31 | "NewUpdateTitle": "Like TeleOTP?", 32 | "NewUpdateText": "Stay tuned for announcements and new releases in our channel", 33 | "ActionLearnMore": "Learn more", 34 | 35 | "ExportAccountsTitle": "Export accounts", 36 | "ExportAccountsText": "You can export accounts to TeleOTP using a link, or to Google Authenticator (or similar) using a QR code.", 37 | "GoBackAction": "Go back", 38 | "CopyLinkAction": "Copy link", 39 | "Export.ViaLink": "Via link", 40 | "Export.ViaQR": "Via QR", 41 | 42 | "LinkExportTitle": "Export link", 43 | "LinkExportDescription": "Copy the link to move accounts to another Telegram user. Or just keep it, to have a backup.", 44 | "LinkExportSecretWarning": "Make sure to keep it secret! Anyone can get access to your codes using this link.", 45 | 46 | "QRExportDescription": "You can import your accounts into Google Authenticator or TeleOTP by scanning the QR code.", 47 | 48 | "EmptyLabelAlert": "Label field cannot be empty!", 49 | "CreateAction": "Create", 50 | "NewAccountTitle": "Add new account", 51 | "AdditionalInfo": "Enter additional account information", 52 | "LabelLabel": "Label", 53 | "RequiredLabel": "(required)", 54 | "IssuerLabel": "Service", 55 | 56 | "DecryptAction": "Decrypt", 57 | "DecryptTitle": "Decrypt your accounts", 58 | "DecryptDescription": "Enter your decryption password to get access to your accounts", 59 | "PasswordLabel": "Password", 60 | "WrongPasswordError": "Wrong password", 61 | "ResetPasswordAction": "Reset password...", 62 | 63 | "SaveAction": "Save", 64 | "EditTitle": "Edit account info", 65 | "EditDescription": "Modify account information", 66 | "DeleteConfirmation": "Are you sure you want to delete {account}?", 67 | "Confirm": "Yes", 68 | "DeleteAccountAction": "Delete account", 69 | 70 | "IconsFetchError": "Error when fetching icons data, please retry later", 71 | "BrowseIconsTitle": "Browse icons", 72 | "SearchPatternLabel": "Pattern to search", 73 | "SearchHelper": "Enter at least two symbols", 74 | "StartTyping": "Start typing to search", 75 | "IconsProvidedBy": "Icons provided by ", 76 | 77 | "NextStepAction": "Next", 78 | "AddManualTitle": "Add account manually", 79 | "AddManualDescription": "Enter provided account secret", 80 | "SecretLabel": "Secret", 81 | "InvalidSecretError": "Invalid secret", 82 | 83 | "InvalidQRCodeAlert": "Invalid QR code", 84 | "HOTPUnimplementedAlert": "HOTP support is not implemented yet :(", 85 | "NewAccountDescription": "Protect your account with two-factor authentication (Google Authenticator import is also supported)", 86 | "ScanQRText": "Scan QR code", 87 | "EnterManuallyAction": "Enter manually", 88 | 89 | "ChangePasswordAction": "Change password", 90 | "CreatePasswordAction": "Create password", 91 | "ChangePasswordTitle": "Set new password", 92 | "CreatePasswordTitle": "Password setup", 93 | "PasswordSetupDescription": "Enter a new encryption password to safely store your accounts", 94 | "PasswordRequirementError": "The password length must be 3 or more", 95 | "RepeatPasswordLabel": "Repeat password", 96 | "PasswordRepeatIncorrectError": "Passwords do not match", 97 | 98 | "RemovePermanentlyAction": "Remove PERMANENTLY", 99 | "PasswordResetTitle": "Password reset", 100 | "DeleteWarning": "You are about to delete all your accounts PERMANENTLY. You won't be able to restore them.", 101 | "TypeDeleteConfirmationPhrase": "If you are absolutely sure, type the phrase", 102 | "DeleteConfirmationPhrase": "Yes, delete everything", 103 | "DeleteConfirmationLabel": "Delete your accounts and reset the password?", 104 | "DeleteConfirmationPhraseError": "Type \"{phrase}\"", 105 | 106 | "BiometricsRequestReason": "Allow access to biometrics to be able to decrypt your accounts", 107 | "BiometricsAuthenticateReason": "Authenticate to decrypt your accounts", 108 | }; 109 | -------------------------------------------------------------------------------- /src/lang/es.ts: -------------------------------------------------------------------------------- 1 | export const lang = { 2 | "Settings.General": "General", 3 | "Language": "Idioma", 4 | "NewsChannel": "Noticias de TeleOTP", 5 | "ActionOpen": "Abrir", 6 | "Settings.Security": "Seguridad", 7 | "Password": "Contraseña", 8 | "ActionChange": "Cambiar", 9 | "KeepUnlocked": "Mantener desbloqueado", 10 | "Enabled": "Activado", 11 | "Disabled": "Deshabilitado", 12 | "UseBiometrics": "Usar biométricos", 13 | "NotAvailable": "No disponible", 14 | "LockAccounts": "Bloquear cuentas", 15 | "Settings.Accounts": "Cuentas", 16 | "Accounts": "Cuentas", 17 | "ActionExportAccounts": "Exportar cuentas", 18 | "ActionRemoveAccounts": "Eliminar todas las cuentas", 19 | "Version": "Versión", 20 | "StarUs": "Star us on GitHub", 21 | "HelpTranslating": "Ayuda con la traducción", 22 | "DevTools": "Herramientas Dev", 23 | "ActionOpenSettings": "Abrir ajustes", 24 | "ActionChangeMenu": "Cambiar...", 25 | "ActionMoreMenu": "Más...", 26 | "ActionAddNewAccount": "Añadir nueva cuenta", 27 | "NewUpdateTitle": "¿Te gusta TeleOTP?", 28 | "NewUpdateText": "Mantente atento a los anuncios y nuevos lanzamientos en nuestro canal", 29 | "ActionLearnMore": "Aprende más", 30 | "ExportAccountsTitle": "Exportar cuentas", 31 | "ExportAccountsText": "Puedes exportar cuentas a TeleOTP usando un enlace, o a Google Authenticator (o similar) usando un código QR.", 32 | "GoBackAction": "Volver", 33 | "CopyLinkAction": "Copiar enlace", 34 | "Export.ViaLink": "Vía enlace", 35 | "Export.ViaQR": "Vía QR", 36 | "LinkExportTitle": "Exportar enlace", 37 | "LinkExportDescription": "Copie el enlace para mover cuentas a otro usuario de Telegram. O simplemente manténgalo, para tener una copia de seguridad.", 38 | "LinkExportSecretWarning": "¡Asegúrate de mantenerlo en secreto! Cualquiera puede tener acceso a tus códigos usando este enlace.", 39 | "QRExportDescription": "Puedes importar tus cuentas en Google Authenticator o TeleOTP escaneando el código QR.", 40 | "EmptyLabelAlert": "¡El campo de etiqueta no puede estar vacío!", 41 | "CreateAction": "Crear", 42 | "NewAccountTitle": "Añadir nueva cuenta", 43 | "AdditionalInfo": "Introduzca información adicional de la cuenta", 44 | "LabelLabel": "Etiqueta", 45 | "RequiredLabel": "(requerido)", 46 | "IssuerLabel": "Servicio", 47 | "DecryptAction": "Descifrar", 48 | "DecryptTitle": "Descifrar tus cuentas", 49 | "DecryptDescription": "Introduzca su contraseña de descifrado para acceder a sus cuentas", 50 | "PasswordLabel": "Contraseña", 51 | "WrongPasswordError": "Contraseña incorrecta", 52 | "ResetPasswordAction": "Restablecer contraseña...", 53 | "SaveAction": "Guardar", 54 | "EditTitle": "Editar información de cuenta", 55 | "EditDescription": "Modificar la información de la cuenta", 56 | "DeleteConfirmation": "¿Está seguro que desea eliminar {account}?", 57 | "Confirm": "Sí", 58 | "DeleteAccountAction": "Eliminar cuenta", 59 | "IconsFetchError": "Error al recuperar los datos de iconos, inténtalo de nuevo más tarde", 60 | "BrowseIconsTitle": "Navegar iconos", 61 | "SearchPatternLabel": "Patrón a buscar", 62 | "SearchHelper": "Introduzca al menos dos símbolos", 63 | "StartTyping": "Empezar a escribir para buscar", 64 | "IconsProvidedBy": "Iconos proporcionados por ", 65 | "NextStepAction": "Siguiente", 66 | "AddManualTitle": "Añadir cuenta manualmente", 67 | "AddManualDescription": "Introduzca el secreto de la cuenta proporcionada", 68 | "SecretLabel": "Secreto", 69 | "InvalidSecretError": "Secreto inválido", 70 | "InvalidQRCodeAlert": "Invalid QR code", 71 | "HOTPUnimplementedAlert": "El soporte del HOTP aún no está implementado :(", 72 | "NewAccountDescription": "Protege tu cuenta con autenticación de doble factor (también se admite la importación de Google Authenticator)", 73 | "ScanQRText": "Scan QR code", 74 | "EnterManuallyAction": "Introducir manualmente", 75 | "ChangePasswordAction": "Cambiar contraseña", 76 | "CreatePasswordAction": "Crear contraseña", 77 | "ChangePasswordTitle": "Establecer nueva contraseña", 78 | "CreatePasswordTitle": "Configuración de contraseña", 79 | "PasswordSetupDescription": "Introduzca una nueva contraseña de cifrado para almacenar sus cuentas de forma segura", 80 | "PasswordRequirementError": "La longitud de la contraseña debe ser 3 o más", 81 | "RepeatPasswordLabel": "Repetir contraseña", 82 | "PasswordRepeatIncorrectError": "Las contraseñas no coinciden", 83 | "RemovePermanentlyAction": "Eliminar PERMANENTAMENTE", 84 | "PasswordResetTitle": "Resetear contraseña", 85 | "DeleteWarning": "Estás a punto de eliminar todas tus cuentas PERMANENTAMENTE. No podrás restaurarlas.", 86 | "TypeDeleteConfirmationPhrase": "Si está absolutamente seguro, escriba la frase", 87 | "DeleteConfirmationPhrase": "Sí, borrar todo", 88 | "DeleteConfirmationLabel": "¿Eliminar tus cuentas y restablecer la contraseña?", 89 | "DeleteConfirmationPhraseError": "Escriba \"{phrase}\"", 90 | "BiometricsRequestReason": "Permitir el acceso a los biométricos, para poder descifrar tus cuentas", 91 | "BiometricsAuthenticateReason": "Autenticar para descifrar tus cuentas" 92 | }; -------------------------------------------------------------------------------- /src/lang/fr.ts: -------------------------------------------------------------------------------- 1 | export const lang = { 2 | "Settings.General": "Généraux", 3 | "Language": "Langue", 4 | "NewsChannel": "Actualités TeleOTP", 5 | "ActionOpen": "Ouvert", 6 | "Settings.Security": "Sécurité", 7 | "Password": "Mot de passe", 8 | "ActionChange": "Changement", 9 | "KeepUnlocked": "Garder déverrouillé", 10 | "Enabled": "Activé", 11 | "Disabled": "Désactivé", 12 | "UseBiometrics": "Utiliser la biométrie", 13 | "NotAvailable": "Indisponible", 14 | "LockAccounts": "Verrouiller les comptes", 15 | "Settings.Accounts": "Comptes", 16 | "Accounts": "Comptes", 17 | "ActionExportAccounts": "Exporter les comptes", 18 | "ActionRemoveAccounts": "Supprimer tous les comptes", 19 | "Version": "Version", 20 | "StarUs": "Star us on GitHub", 21 | "HelpTranslating": "Aide à la traduction", 22 | "DevTools": "Outils de développement", 23 | "ActionOpenSettings": "Ouvrir les paramètres", 24 | "ActionChangeMenu": "Changement...", 25 | "ActionMoreMenu": "Plus...", 26 | "ActionAddNewAccount": "Ajouter un nouveau compte", 27 | "NewUpdateTitle": "Vous aimez TeleOTP?", 28 | "NewUpdateText": "Restez à l'écoute des annonces et des nouvelles versions dans notre chaîne", 29 | "ActionLearnMore": "En savoir plus", 30 | "ExportAccountsTitle": "Exporter les comptes", 31 | "ExportAccountsText": "Vous pouvez exporter des comptes vers TeleOTP en utilisant un lien, ou vers Google Authenticator (ou similaire) en utilisant un code QR.", 32 | "GoBackAction": "Revenir en arrière", 33 | "CopyLinkAction": "Copier le lien", 34 | "Export.ViaLink": "Par lien", 35 | "Export.ViaQR": "Par QR", 36 | "LinkExportTitle": "Exporter le lien", 37 | "LinkExportDescription": "Copiez le lien pour déplacer des comptes vers un autre utilisateur de Telegram. Ou conservez-le, pour avoir une sauvegarde.", 38 | "LinkExportSecretWarning": "Assurez-vous de le garder secret! N'importe qui peut accéder à vos codes en utilisant ce lien.", 39 | "QRExportDescription": "Vous pouvez importer vos comptes dans Google Authenticator ou TeleOTP en scannant le code QR.", 40 | "EmptyLabelAlert": "Le champ étiquette ne peut pas être vide!", 41 | "CreateAction": "Créer", 42 | "NewAccountTitle": "Ajouter un nouveau compte", 43 | "AdditionalInfo": "Entrez des informations supplémentaires sur le compte", 44 | "LabelLabel": "Étiquette", 45 | "RequiredLabel": "(obligatoire)", 46 | "IssuerLabel": "Service", 47 | "DecryptAction": "Déchiffrer", 48 | "DecryptTitle": "Déchiffrez vos comptes", 49 | "DecryptDescription": "Entrez votre mot de passe de décryptage pour accéder à vos comptes", 50 | "PasswordLabel": "Mot de passe", 51 | "WrongPasswordError": "Mauvais mot de passe", 52 | "ResetPasswordAction": "Réinitialiser le mot de passe...", 53 | "SaveAction": "Enregistrer", 54 | "EditTitle": "Modifier les informations du compte", 55 | "EditDescription": "Modifier les informations du compte", 56 | "DeleteConfirmation": "Êtes-vous sûr de vouloir supprimer {account}?", 57 | "Confirm": "Oui", 58 | "DeleteAccountAction": "Supprimer le compte", 59 | "IconsFetchError": "Erreur lors de la récupération des données d'icônes, veuillez réessayer plus tard", 60 | "BrowseIconsTitle": "Parcourir les icônes", 61 | "SearchPatternLabel": "Schéma à rechercher", 62 | "SearchHelper": "Entrez au moins deux symboles", 63 | "StartTyping": "Commencez à taper pour rechercher", 64 | "IconsProvidedBy": "Icônes fournies par ", 65 | "NextStepAction": "Suivant", 66 | "AddManualTitle": "Ajouter un compte manuellement", 67 | "AddManualDescription": "Entrez le secret du compte fourni", 68 | "SecretLabel": "Secrète", 69 | "InvalidSecretError": "Secret invalide", 70 | "InvalidQRCodeAlert": "Invalid QR code", 71 | "HOTPUnimplementedAlert": "Le support HOTP n'est pas encore implémenté :(", 72 | "NewAccountDescription": "Protégez votre compte avec l'authentification à deux facteurs (l'import Google Authenticator est également pris en charge)", 73 | "ScanQRText": "Scan QR code", 74 | "EnterManuallyAction": "Entrer manuellement", 75 | "ChangePasswordAction": "Changer le mot de passe", 76 | "CreatePasswordAction": "Créer un mot de passe", 77 | "ChangePasswordTitle": "Définir un nouveau mot de passe", 78 | "CreatePasswordTitle": "Configuration du mot de passe", 79 | "PasswordSetupDescription": "Entrez un nouveau mot de passe de cryptage pour stocker vos comptes en toute sécurité", 80 | "PasswordRequirementError": "La longueur du mot de passe doit être de 3 ou plus", 81 | "RepeatPasswordLabel": "Répéter le mot de passe", 82 | "PasswordRepeatIncorrectError": "Les mots de passe ne correspondent pas", 83 | "RemovePermanentlyAction": "Supprimer PERMANENTEMENT", 84 | "PasswordResetTitle": "Réinitialisation du mot de passe", 85 | "DeleteWarning": "Vous êtes sur le point de supprimer tous vos comptes de manière permanente. Vous ne pourrez pas les restaurer.", 86 | "TypeDeleteConfirmationPhrase": "Si vous êtes absolument sûr, tapez la phrase", 87 | "DeleteConfirmationPhrase": "Oui, supprimer tout", 88 | "DeleteConfirmationLabel": "Supprimer vos comptes et réinitialiser le mot de passe ?", 89 | "DeleteConfirmationPhraseError": "Tapez \"{phrase}\"", 90 | "BiometricsRequestReason": "Autoriser l'accès à la biométrie pour pouvoir déchiffrer vos comptes", 91 | "BiometricsAuthenticateReason": "Authentifiez-vous pour déchiffrer vos comptes" 92 | }; -------------------------------------------------------------------------------- /src/lang/hi.ts: -------------------------------------------------------------------------------- 1 | export const lang = { 2 | "Settings.General": "सामान्य", 3 | "Language": "भाषा", 4 | "NewsChannel": "TeleOTP समाचार", 5 | "ActionOpen": "खोलें", 6 | "Settings.Security": "सुरक्षा", 7 | "Password": "पासवर्ड", 8 | "ActionChange": "बदलें", 9 | "KeepUnlocked": "अनलॉक रखें", 10 | "Enabled": "सक्षम", 11 | "Disabled": "अक्षम", 12 | "UseBiometrics": "बायोमेट्रिक्स का उपयोग करें", 13 | "NotAvailable": "उपलब्ध नहीं", 14 | "LockAccounts": "खाते लॉक करें", 15 | "Settings.Accounts": "खाते", 16 | "Accounts": "खाते", 17 | "ActionExportAccounts": "खाते निर्यात करें", 18 | "ActionRemoveAccounts": "सभी खाते हटाएं", 19 | "Version": "संस्करण", 20 | "StarUs": "GitHub पर हमें स्टार दें", 21 | "HelpTranslating": "अनुवाद में मदद करें", 22 | "DevTools": "डेव टूल्स", 23 | "ActionOpenSettings": "सेटिंग्स खोलें", 24 | "ActionChangeMenu": "बदलें...", 25 | "ActionMoreMenu": "अधिक...", 26 | "ActionAddNewAccount": "नया खाता जोड़ें", 27 | "NewUpdateTitle": "क्या आपको TeleOTP पसंद है?", 28 | "NewUpdateText": "घोषणाओं और नए रिलीज़ के लिए हमारे चैनल से जुड़े रहें", 29 | "ActionLearnMore": "अधिक जानें", 30 | "ExportAccountsTitle": "खाते निर्यात करें", 31 | "ExportAccountsText": "आप TeleOTP का उपयोग करके खातों को लिंक से निर्यात कर सकते हैं, या Google Authenticator (या इसी तरह) में QR कोड का उपयोग कर सकते हैं।", 32 | "GoBackAction": "वापस जाएं", 33 | "CopyLinkAction": "लिंक कॉपी करें", 34 | "Export.ViaLink": "लिंक के माध्यम से", 35 | "Export.ViaQR": "QR के माध्यम से", 36 | "LinkExportTitle": "निर्यात लिंक", 37 | "LinkExportDescription": "खातों को अन्य Telegram उपयोगकर्ता में स्थानांतरित करने के लिए लिंक कॉपी करें। या बस इसे बैकअप के लिए रखें।", 38 | "LinkExportSecretWarning": "इसे गोपनीय रखें! कोई भी इस लिंक का उपयोग करके आपके कोड्स तक पहुंच सकता है।", 39 | "QRExportDescription": "आप Google Authenticator या TeleOTP में अपने खातों को QR कोड स्कैन करके आयात कर सकते हैं।", 40 | "EmptyLabelAlert": "लेबल फ़ील्ड खाली नहीं हो सकता!", 41 | "CreateAction": "बनाएं", 42 | "NewAccountTitle": "नया खाता जोड़ें", 43 | "AdditionalInfo": "अतिरिक्त खाता जानकारी दर्ज करें", 44 | "LabelLabel": "लेबल", 45 | "RequiredLabel": "(आवश्यक)", 46 | "IssuerLabel": "सेवा", 47 | "DecryptAction": "डिक्रिप्ट करें", 48 | "DecryptTitle": "अपने खातों को डिक्रिप्ट करें", 49 | "DecryptDescription": "अपने खातों तक पहुंच प्राप्त करने के लिए अपना डिक्रिप्शन पासवर्ड दर्ज करें", 50 | "PasswordLabel": "पासवर्ड", 51 | "WrongPasswordError": "गलत पासवर्ड", 52 | "ResetPasswordAction": "पासवर्ड रीसेट करें...", 53 | "SaveAction": "सहेजें", 54 | "EditTitle": "खाता जानकारी संपादित करें", 55 | "EditDescription": "खाता जानकारी संशोधित करें", 56 | "DeleteConfirmation": "क्या आप वाकई {account} को हटाना चाहते हैं?", 57 | "Confirm": "हाँ", 58 | "DeleteAccountAction": "खाता हटाएं", 59 | "IconsFetchError": "आइकन डेटा प्राप्त करते समय त्रुटि, कृपया बाद में पुनः प्रयास करें", 60 | "BrowseIconsTitle": "आइकन ब्राउज़ करें", 61 | "SearchPatternLabel": "खोजने के लिए पैटर्न", 62 | "SearchHelper": "कम से कम दो प्रतीक दर्ज करें", 63 | "StartTyping": "खोजने के लिए टाइप करना प्रारंभ करें", 64 | "IconsProvidedBy": "आइकन प्रदान किया गया है ", 65 | "NextStepAction": "अगला", 66 | "AddManualTitle": "मैन्युअल रूप से खाता जोड़ें", 67 | "AddManualDescription": "प्रदान किए गए खाता गुप्त दर्ज करें", 68 | "SecretLabel": "गुप्त", 69 | "InvalidSecretError": "अवैध गुप्त", 70 | "InvalidQRCodeAlert": "अमान्य QR कोड", 71 | "HOTPUnimplementedAlert": "HOTP समर्थन अभी तक लागू नहीं किया गया है :(", 72 | "NewAccountDescription": "अपने खाते को दो-कारक प्रमाणीकरण के साथ सुरक्षित करें (Google Authenticator आयात भी समर्थित है)", 73 | "ScanQRText": "QR कोड स्कैन करें", 74 | "EnterManuallyAction": "मैन्युअल रूप से दर्ज करें", 75 | "ChangePasswordAction": "पासवर्ड बदलें", 76 | "CreatePasswordAction": "पासवर्ड सेट करें", 77 | "ChangePasswordTitle": "नया पासवर्ड सेट करें", 78 | "CreatePasswordTitle": "पासवर्ड सेटअप", 79 | "PasswordSetupDescription": "अपने खातों को सुरक्षित रूप से संग्रहीत करने के लिए एक नया एन्क्रिप्शन पासवर्ड दर्ज करें", 80 | "PasswordRequirementError": "पासवर्ड की लंबाई 3 या अधिक होनी चाहिए", 81 | "RepeatPasswordLabel": "पासवर्ड दोहराएं", 82 | "PasswordRepeatIncorrectError": "पासवर्ड मेल नहीं खाता", 83 | "RemovePermanentlyAction": "स्थायी रूप से हटाएं", 84 | "PasswordResetTitle": "पासवर्ड रीसेट", 85 | "DeleteWarning": "आप स्थायी रूप से अपने सभी खातों को हटाने वाले हैं। आप उन्हें पुनर्स्थापित नहीं कर पाएंगे।", 86 | "TypeDeleteConfirmationPhrase": "यदि आप पूरी तरह से सुनिश्चित हैं, तो वाक्यांश टाइप करें", 87 | "DeleteConfirmationPhrase": "हाँ, सब कुछ हटाएं", 88 | "DeleteConfirmationLabel": "अपने खातों को हटाएं और पासवर्ड रीसेट करें?", 89 | "DeleteConfirmationPhraseError": "\"{phrase}\" टाइप करें", 90 | "BiometricsRequestReason": "अपने खातों को डिक्रिप्ट करने के लिए बायोमेट्रिक्स तक पहुंच की अनुमति दें", 91 | "BiometricsAuthenticateReason": "अपने खातों को डिक्रिप्ट करने के लिए प्रमाणित करें" 92 | }; -------------------------------------------------------------------------------- /src/lang/pt.ts: -------------------------------------------------------------------------------- 1 | export const lang = { 2 | "Settings.General": "Gerais", 3 | "Language": "Idioma", 4 | "NewsChannel": "Notícias TeleOTP", 5 | "ActionOpen": "Abrir", 6 | "Settings.Security": "Segurança", 7 | "Password": "Senha", 8 | "ActionChange": "Alterar", 9 | "KeepUnlocked": "Manter desbloqueado", 10 | "Enabled": "Ativado", 11 | "Disabled": "Desabilitado", 12 | "UseBiometrics": "Usar biometria", 13 | "NotAvailable": "Não disponível", 14 | "LockAccounts": "Bloquear contas", 15 | "Settings.Accounts": "Contas", 16 | "Accounts": "Contas", 17 | "ActionExportAccounts": "Exportar contas", 18 | "ActionRemoveAccounts": "Remover todas as contas", 19 | "Version": "Versão", 20 | "StarUs": "Nos dê uma estrelinha no GitHub", 21 | "HelpTranslating": "Ajuda com tradução", 22 | "DevTools": "Ferramentas de Desenvolvedor", 23 | "ActionOpenSettings": "Abrir configurações", 24 | "ActionChangeMenu": "Alterar...", 25 | "ActionMoreMenu": "Mais...", 26 | "ActionAddNewAccount": "Adicionar conta", 27 | "NewUpdateTitle": "Gostou do TeleOTP?", 28 | "NewUpdateText": "Fique atento para anúncios e novas versões em nosso canal", 29 | "ActionLearnMore": "Mais informações", 30 | "ExportAccountsTitle": "Exportar contas", 31 | "ExportAccountsText": "Você pode exportar contas para o TeleOTP usando um link, ou para o Google Authenticator (ou similar) usando um código QR.", 32 | "GoBackAction": "Voltar", 33 | "CopyLinkAction": "Copiar link", 34 | "Export.ViaLink": "Via link", 35 | "Export.ViaQR": "Via QR", 36 | "LinkExportTitle": "Exportar link", 37 | "LinkExportDescription": "Copie o link para mover contas para outro usuário do Telegram. Ou apenas o guarde, para ter uma cópia de segurança.", 38 | "LinkExportSecretWarning": "Certifique-se de mantê-lo em segredo! Qualquer um pode acessar seus códigos usando este link.", 39 | "QRExportDescription": "Você pode importar suas contas no Google Authenticator ou TeleOTP escaneando o código QR.", 40 | "EmptyLabelAlert": "O campo descrição não pode estar vazio!", 41 | "CreateAction": "Criar", 42 | "NewAccountTitle": "Adicionar conta", 43 | "AdditionalInfo": "Insira informações adicionais da conta", 44 | "LabelLabel": "Descrição", 45 | "RequiredLabel": "(obrigatório)", 46 | "IssuerLabel": "Serviço", 47 | "DecryptAction": "Descriptografar", 48 | "DecryptTitle": "Descriptografar suas contas", 49 | "DecryptDescription": "Digite sua senha de descriptografia para obter acesso às suas contas", 50 | "PasswordLabel": "Senha", 51 | "WrongPasswordError": "Senha incorreta", 52 | "ResetPasswordAction": "Redefinir senha...", 53 | "SaveAction": "Salvar", 54 | "EditTitle": "Editar informações da conta", 55 | "EditDescription": "Modificar informações da conta", 56 | "DeleteConfirmation": "Tem certeza que deseja excluir {account}?", 57 | "Confirm": "Sim", 58 | "DeleteAccountAction": "Apagar conta", 59 | "IconsFetchError": "Erro ao obter dados de ícones, tente novamente mais tarde", 60 | "BrowseIconsTitle": "Procurar ícones", 61 | "SearchPatternLabel": "Padrão a pesquisar", 62 | "SearchHelper": "Digite pelo menos dois símbolos", 63 | "StartTyping": "Comece a digitar para pesquisar", 64 | "IconsProvidedBy": "Ícones fornecidos por ", 65 | "NextStepAction": "Próximo", 66 | "AddManualTitle": "Adicionar conta manualmente", 67 | "AddManualDescription": "Digite o segredo da conta fornecido", 68 | "SecretLabel": "Segredo", 69 | "InvalidSecretError": "Segredo inválido", 70 | "InvalidQRCodeAlert": "QR code inválido", 71 | "HOTPUnimplementedAlert": "Suporte a HOTP ainda não foi implementado :(", 72 | "NewAccountDescription": "Proteja sua conta com autenticação de dois fatores (a importação do Google Authenticator também é suportada)", 73 | "ScanQRText": "Escanear QR code", 74 | "EnterManuallyAction": "Inserir manualmente", 75 | "ChangePasswordAction": "Mudar senha", 76 | "CreatePasswordAction": "Criar senha", 77 | "ChangePasswordTitle": "Definir nova senha", 78 | "CreatePasswordTitle": "Configuração de senha", 79 | "PasswordSetupDescription": "Digite uma nova senha de criptografia para armazenar suas contas com segurança", 80 | "PasswordRequirementError": "A senha deve ser de 3 ou mais", 81 | "RepeatPasswordLabel": "Repita a senha", 82 | "PasswordRepeatIncorrectError": "As senhas não coincidem", 83 | "RemovePermanentlyAction": "Remover PERMANENTEMENTE", 84 | "PasswordResetTitle": "Redefinição de senha", 85 | "DeleteWarning": "Você está prestes a excluir todas as suas contas PERMANENTEMENTE. Você não será capaz de restaurá-las.", 86 | "TypeDeleteConfirmationPhrase": "Se você tem certeza absoluta, digite a frase", 87 | "DeleteConfirmationPhrase": "Sim, excluir tudo", 88 | "DeleteConfirmationLabel": "Excluir suas contas e redefinir a senha?", 89 | "DeleteConfirmationPhraseError": "Digite \"{phrase}\"", 90 | "BiometricsRequestReason": "Permita acesso a dados biométricos para poder descriptografar suas contas", 91 | "BiometricsAuthenticateReason": "Autentique-se para descriptografar suas contas" 92 | }; -------------------------------------------------------------------------------- /src/lang/ru.ts: -------------------------------------------------------------------------------- 1 | export const lang = { 2 | "Settings.General": "Общее", 3 | "Language": "Язык", 4 | "NewsChannel": "Новости TeleOTP", 5 | "ActionOpen": "Открыть", 6 | "Settings.Security": "Безопасность", 7 | "Password": "Пароль", 8 | "ActionChange": "Изменить", 9 | "KeepUnlocked": "Не блокировать", 10 | "Enabled": "Включено", 11 | "Disabled": "Выключено", 12 | "UseBiometrics": "Вход по биометрии", 13 | "NotAvailable": "Недоступно", 14 | "LockAccounts": "Заблокировать", 15 | "Settings.Accounts": "Аккаунты", 16 | "Accounts": "Аккаунты", 17 | "ActionExportAccounts": "Экспорт аккаунтов", 18 | "ActionRemoveAccounts": "Удалить все аккаунты", 19 | "Version": "Версия", 20 | "StarUs": "Оставьте звезду на GitHub", 21 | "HelpTranslating": "Помочь с переводом", 22 | "DevTools": "Инструменты разработчика", 23 | "ActionOpenSettings": "Открыть настройки", 24 | "ActionChangeMenu": "Изменить...", 25 | "ActionMoreMenu": "Ещё...", 26 | "ActionAddNewAccount": "Добавить аккаунт", 27 | "NewUpdateTitle": "Нравится TeleOTP?", 28 | "NewUpdateText": "Следите за объявлениями и новыми релизами в нашем канале", 29 | "ActionLearnMore": "Подробнее", 30 | "ExportAccountsTitle": "Экспорт аккаунтов", 31 | "ExportAccountsText": "Вы можете экспортировать аккаунты в TeleOTP с помощью ссылки, либо в Google Authenticator (или подобные) с помощью QR-кода.", 32 | "GoBackAction": "Назад", 33 | "CopyLinkAction": "Скопировать ссылку", 34 | "Export.ViaLink": "Через ссылку", 35 | "Export.ViaQR": "Через QR-код", 36 | "LinkExportTitle": "Экспортировать ссылку", 37 | "LinkExportDescription": "Скопируйте ссылку для перемещения аккаунтов другому пользователю Telegram. Или просто сохраните её, чтобы создать резервную копию.", 38 | "LinkExportSecretWarning": "Храните ссылку в секрете! Любой может получить доступ к вашим кодам, используя её.", 39 | "QRExportDescription": "Вы можете импортировать ваши аккаунты в Google Authenticator или TeleOTP, отсканировав этот QR-код.", 40 | "EmptyLabelAlert": "Название не может быть пустым!", 41 | "CreateAction": "Создать", 42 | "NewAccountTitle": "Добавить аккаунт", 43 | "AdditionalInfo": "Введите дополнительную информацию об аккаунте", 44 | "LabelLabel": "Название", 45 | "RequiredLabel": "(обязательно)", 46 | "IssuerLabel": "Сервис", 47 | "DecryptAction": "Разблокировать", 48 | "DecryptTitle": "Разблокировать аккаунты", 49 | "DecryptDescription": "Введите пароль шифрования, чтобы получить доступ к вашим аккаунтам", 50 | "PasswordLabel": "Пароль", 51 | "WrongPasswordError": "Неверный пароль", 52 | "ResetPasswordAction": "Сбросить пароль...", 53 | "SaveAction": "Сохранить", 54 | "EditTitle": "Редактировать аккаунт", 55 | "EditDescription": "Измените информацию об аккаунте", 56 | "DeleteConfirmation": "Вы уверены, что хотите удалить {account}?", 57 | "Confirm": "Да", 58 | "DeleteAccountAction": "Удалить аккаунт", 59 | "IconsFetchError": "Ошибка при получении иконок, повторите попытку позже", 60 | "BrowseIconsTitle": "Обзор иконок", 61 | "SearchPatternLabel": "Введите запрос", 62 | "SearchHelper": "Введите не менее двух символов", 63 | "StartTyping": "Начните вводить слово для поиска", 64 | "IconsProvidedBy": "Иконки предоставлены ", 65 | "NextStepAction": "Далее", 66 | "AddManualTitle": "Добавить аккаунт вручную", 67 | "AddManualDescription": "Введите предоставленный ключ аккаунта", 68 | "SecretLabel": "Ключ", 69 | "InvalidSecretError": "Некорректный ключ", 70 | "InvalidQRCodeAlert": "Некорректный QR-код", 71 | "HOTPUnimplementedAlert": "Поддержка HOTP еще не реализована :(", 72 | "NewAccountDescription": "Защитите свой аккаунт с помощью двухфакторной аутентификации (поддерживается импорт Google Authenticator)", 73 | "ScanQRText": "Сканировать QR-код", 74 | "EnterManuallyAction": "Ввести вручную", 75 | "ChangePasswordAction": "Изменить пароль", 76 | "CreatePasswordAction": "Создать пароль", 77 | "ChangePasswordTitle": "Задать новый пароль", 78 | "CreatePasswordTitle": "Настройки пароля", 79 | "PasswordSetupDescription": "Введите новый пароль шифрования для безопасного хранения ваших аккаунтов", 80 | "PasswordRequirementError": "Длина пароля должна быть не менее 3", 81 | "RepeatPasswordLabel": "Повторите пароль", 82 | "PasswordRepeatIncorrectError": "Пароли не совпадают", 83 | "RemovePermanentlyAction": "Удалить НАВСЕГДА", 84 | "PasswordResetTitle": "Сброс пароля", 85 | "DeleteWarning": "Вы собираетесь удалить все ваши аккаунты НАВСЕГДА. Вы не сможете их восстановить.", 86 | "TypeDeleteConfirmationPhrase": "Если Вы абсолютно уверены, введите фразу", 87 | "DeleteConfirmationPhrase": "Да, удалить всё", 88 | "DeleteConfirmationLabel": "Удалить аккаунты, и сбросить пароль?", 89 | "DeleteConfirmationPhraseError": "Введите \"{phrase}\"", 90 | "BiometricsRequestReason": "Предоставьте доступ к биометрическим данным для расшифрования Ваших аккаунтов", 91 | "BiometricsAuthenticateReason": "Авторизуйтесь для расшифровки ваших аккаунтов" 92 | }; -------------------------------------------------------------------------------- /src/lang/uk.ts: -------------------------------------------------------------------------------- 1 | export const lang = { 2 | "Settings.General": "Загальні налаштування", 3 | "Language": "Мова", 4 | "NewsChannel": "Новини TeleOTP", 5 | "ActionOpen": "Відкриті", 6 | "Settings.Security": "Безпека", 7 | "Password": "Пароль", 8 | "ActionChange": "Змінити", 9 | "KeepUnlocked": "Залишати розблокованим", 10 | "Enabled": "Увімкнено", 11 | "Disabled": "Вимкнено", 12 | "UseBiometrics": "Використовувати біометричні дані", 13 | "NotAvailable": "Недоступний", 14 | "LockAccounts": "Блокування облікового запису", 15 | "Settings.Accounts": "Облікові записи", 16 | "Accounts": "Облікові записи", 17 | "ActionExportAccounts": "Експортувати облікові записи", 18 | "ActionRemoveAccounts": "Видалити всі облікові записи", 19 | "Version": "Версія", 20 | "StarUs": "Star us on GitHub", 21 | "HelpTranslating": "Допомога з перекладом", 22 | "DevTools": "Інструменти розробника", 23 | "ActionOpenSettings": "Відкрити налаштування", 24 | "ActionChangeMenu": "Змінити...", 25 | "ActionMoreMenu": "Детальніше...", 26 | "ActionAddNewAccount": "Додати акаунт", 27 | "NewUpdateTitle": "Наприклад, TeleOTP?", 28 | "NewUpdateText": "Слідкуйте за повідомленнями та новими релізами в нашому каналі", 29 | "ActionLearnMore": "Дізнатися більше", 30 | "ExportAccountsTitle": "Експортувати облікові записи", 31 | "ExportAccountsText": "Ви можете експортувати облікові записи в TeleOTP за допомогою посилання або до Google Authenticator (або схожого) використовуючи QR-код.", 32 | "GoBackAction": "Повернутися назад", 33 | "CopyLinkAction": "Скопіювати посилання", 34 | "Export.ViaLink": "Через посилання", 35 | "Export.ViaQR": "Через QR-код", 36 | "LinkExportTitle": "Експортувати посилання", 37 | "LinkExportDescription": "Скопіюйте посилання для переміщення акаунтів до іншого користувача Telegram. Або просто збережіть його, щоб мати резервну копію.", 38 | "LinkExportSecretWarning": "Переконайтеся, що він тримає в секреті! Будь-хто може отримати доступ до ваших кодів за допомогою цього посилання.", 39 | "QRExportDescription": "Ви можете імпортувати свої облікові записи в Google Authenticator або TeleOTP шляхом сканування QR-коду.", 40 | "EmptyLabelAlert": "Поле для ярлика не може бути порожнім!", 41 | "CreateAction": "Створити", 42 | "NewAccountTitle": "Додати акаунт", 43 | "AdditionalInfo": "Введіть додаткову інформацію облікового запису", 44 | "LabelLabel": "Мітка", 45 | "RequiredLabel": "(обов'язково)", 46 | "IssuerLabel": "Послуга", 47 | "DecryptAction": "Дешифрувати", 48 | "DecryptTitle": "Розшифровуйте свої облікові записи", 49 | "DecryptDescription": "Введіть пароль розшифрування, щоб отримати доступ до ваших облікових записів", 50 | "PasswordLabel": "Пароль", 51 | "WrongPasswordError": "Хибний пароль", 52 | "ResetPasswordAction": "Скинути пароль...", 53 | "SaveAction": "Зберегти", 54 | "EditTitle": "Редагувати обліковий запис", 55 | "EditDescription": "Змінити дані облікового запису", 56 | "DeleteConfirmation": "Ви дійсно бажаєте видалити {account}?", 57 | "Confirm": "Так", 58 | "DeleteAccountAction": "Видалити обліковий запис", 59 | "IconsFetchError": "Помилка при отриманні даних із значків, будь ласка, спробуйте пізніше", 60 | "BrowseIconsTitle": "Огляд значків", 61 | "SearchPatternLabel": "Шаблон для пошуку", 62 | "SearchHelper": "Введіть принаймні два символи", 63 | "StartTyping": "Почніть вводити для пошуку", 64 | "IconsProvidedBy": "Значки, надані ", 65 | "NextStepAction": "Уперед", 66 | "AddManualTitle": "Додати акаунт вручну", 67 | "AddManualDescription": "Введіть наданий ключ облікового запису", 68 | "SecretLabel": "Секрет", 69 | "InvalidSecretError": "Невірний ключ", 70 | "InvalidQRCodeAlert": "Invalid QR code", 71 | "HOTPUnimplementedAlert": "Підтримка HOTP ще не реалізована :(", 72 | "NewAccountDescription": "Захистіть свій обліковий запис за допомогою двофакторної автентифікації (підтримується імпорт кодів Google)", 73 | "ScanQRText": "Scan QR code", 74 | "EnterManuallyAction": "Ввести вручну", 75 | "ChangePasswordAction": "Змінити пароль", 76 | "CreatePasswordAction": "Створити пароль", 77 | "ChangePasswordTitle": "Встановити новий пароль", 78 | "CreatePasswordTitle": "Налаштування пароля", 79 | "PasswordSetupDescription": "Введіть новий пароль шифрування, щоб безпечно зберегти ваші облікові записи", 80 | "PasswordRequirementError": "Пароль має складатися з 3-х або більше", 81 | "RepeatPasswordLabel": "Повторіть пароль", 82 | "PasswordRepeatIncorrectError": "Паролі не збігаються", 83 | "RemovePermanentlyAction": "Видалити PERMANENTL", 84 | "PasswordResetTitle": "Скидання пароля", 85 | "DeleteWarning": "Ви збираєтесь видалити всі свої облікові записи PERMANENTLY. Ви не зможете їх відновити.", 86 | "TypeDeleteConfirmationPhrase": "Якщо ви впевнені, введіть фразу", 87 | "DeleteConfirmationPhrase": "Так, видалити все", 88 | "DeleteConfirmationLabel": "Видалити ваші облікові записи і відновити пароль?", 89 | "DeleteConfirmationPhraseError": "Введіть \"{phrase}\"", 90 | "BiometricsRequestReason": "Дозволити доступ до біометрики, щоб розшифрувати свої облікові записи", 91 | "BiometricsAuthenticateReason": "Автентифікуйтесь для дешифрування ваших облікових записів" 92 | }; -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-key */ 2 | import { lazy, Suspense } from "react"; 3 | import ReactDOM from "react-dom/client"; 4 | import Root, { LoadingIndicator } from "./Root.tsx"; 5 | import "./global.css"; 6 | import "@fontsource/inter"; 7 | import "@fontsource/inter/500.css"; 8 | import "@fontsource/inter/700.css"; 9 | import { 10 | createBrowserRouter, 11 | createRoutesFromElements, 12 | Route, 13 | RouterProvider, 14 | } from "react-router-dom"; 15 | import { Telegram } from "@twa-dev/types"; 16 | 17 | import { EncryptionManagerProvider } from "./managers/encryption.tsx"; 18 | import { StorageManagerProvider } from "./managers/storage/storage.tsx"; 19 | import { SettingsManagerProvider } from "./managers/settings.tsx"; 20 | import { PlausibleAnalyticsProvider } from "./components/PlausibleAnalytics.tsx"; 21 | import { BiometricsManagerProvider } from "./managers/biometrics.tsx"; 22 | import CacheProvider from "react-inlinesvg/provider"; 23 | 24 | // always loaded pages 25 | import Accounts from "./pages/Accounts.tsx"; 26 | import EditAccount from "./pages/EditAccount.tsx"; 27 | import Settings from "./pages/Settings.tsx"; 28 | import {LocalizationManagerProvider} from "./managers/localization.tsx"; 29 | 30 | // lazy loaded pages 31 | const CreateAccount = lazy(() => import("./pages/CreateAccount.tsx")); 32 | const NewAccount = lazy(() => import("./pages/NewAccount.tsx")); 33 | const ManualAccount = lazy(() => import("./pages/ManualAccount.tsx")); 34 | const PasswordSetup = lazy(() => import("./pages/PasswordSetup.tsx")); 35 | const ResetAccounts = lazy(() => import("./pages/ResetAccounts.tsx")); 36 | const DevToolsPage = lazy(() => import("./pages/DevToolsPage.tsx")); 37 | const IconBrowser = lazy(() => import("./pages/IconBrowser.tsx")); 38 | const ExportAccounts = lazy(() => import("./pages/export/ExportAccounts.tsx")); 39 | const QrExport = lazy(() => import("./pages/export/QrExport.tsx")); 40 | const LinkExport = lazy(() => import("./pages/export/LinkExport.tsx")); 41 | const SelectLanguage = lazy(() => import("./pages/SelectLanguage.tsx")); 42 | const UserErrorPage = lazy(() => import("./pages/UserErrorPage.tsx")); 43 | 44 | 45 | declare global { 46 | interface Window { 47 | Telegram: Telegram; 48 | } 49 | } 50 | 51 | export const IS_TELEGRAM_APP_SUPPORTED = window.Telegram.WebApp.isVersionAtLeast("6.9"); 52 | 53 | const router = createBrowserRouter( 54 | createRoutesFromElements( 55 | : } 58 | element={} 59 | > 60 | } /> 61 | } /> 62 | } /> 63 | } /> 64 | } /> 65 | } /> 66 | } /> 67 | } /> 68 | } /> 69 | } /> 70 | } /> 71 | } /> 72 | } /> 73 | {import.meta.env.DEV && ( 74 | } /> 75 | )} 76 | 77 | ), 78 | { 79 | basename: import.meta.env.BASE_URL, 80 | } 81 | ); 82 | 83 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 84 | ReactDOM.createRoot(document.getElementById("root")!).render( 85 | !IS_TELEGRAM_APP_SUPPORTED ?
86 |

Your Telegram app is outdated!

87 |
: 88 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | }> 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | ); 109 | -------------------------------------------------------------------------------- /src/managers/biometrics.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, FC, PropsWithChildren, useContext, useEffect, useState} from "react"; 2 | import {SettingsManagerContext} from "./settings.tsx"; 3 | import {useL10n} from "../hooks/useL10n.ts"; 4 | 5 | export const BiometricsManagerContext = createContext(null); 6 | 7 | /** 8 | * BiometricsManager is used as an interface to Telegram's `WebApp.BiometricManager`. 9 | * It allows to store the encryption key inside secure storage on device, locked by a biometric lock. 10 | * 11 | * To get an instance of BiometricsManager, you should use the useContext hook: 12 | * @example 13 | * const biometricsManager = useContext(BiometricsManagerContext); 14 | */ 15 | export interface BiometricsManager { 16 | /** 17 | * Boolean flag indicating whether biometric storage is available on the current device. 18 | */ 19 | isAvailable: boolean; 20 | /** 21 | * Boolean flag indicating whether the encryption key is saved inside the storage. This flag is stored using the SettingsManager. 22 | */ 23 | isSaved: boolean; 24 | 25 | /** 26 | * This method saves the key inside secure storage. It may ask the user for necessary permissions. 27 | * @param token - token to be saved. To delete the stored key, pass empty string. 28 | */ 29 | updateToken(token: string): void; 30 | 31 | /** 32 | * This method requests a token from the storage. 33 | * @param callback If a request is successful, 34 | * the token is passed in the token parameter inside a callback. 35 | * In case of a failure, callback is called with empty token. 36 | */ 37 | getToken(callback: (token?: string) => void): void; 38 | } 39 | 40 | /** 41 | * BiometricsManager is created using BiometricsManagerProvider component 42 | * 43 | * @note BiometricsManagerProvider must be used inside the SettingsManagerProvider 44 | */ 45 | export const BiometricsManagerProvider: FC = ({ children }) => { 46 | const [isAvailable, setIsAvailable] = useState(false); 47 | const [isRequested, setIsRequested] = useState(false); 48 | const settingsManager = useContext(SettingsManagerContext); 49 | const l10n = useL10n(); 50 | 51 | useEffect(() => { 52 | window.Telegram.WebApp.BiometricManager.init(() => { 53 | setIsAvailable(window.Telegram.WebApp.BiometricManager.isInited && 54 | window.Telegram.WebApp.BiometricManager.isBiometricAvailable); 55 | if (!window.Telegram.WebApp.BiometricManager.isAccessGranted) { 56 | settingsManager?.setBiometricsEnabled(false); 57 | } 58 | }); 59 | }, [settingsManager]); 60 | 61 | const isSaved = settingsManager?.biometricsEnabled ?? false; 62 | const biometricsManager: BiometricsManager = { 63 | isAvailable, 64 | isSaved, 65 | updateToken: (token: string) => { 66 | if(!isAvailable) return; 67 | if(!window.Telegram.WebApp.BiometricManager.isAccessGranted) { 68 | window.Telegram.WebApp.BiometricManager.requestAccess({ 69 | reason: l10n("BiometricsRequestReason") 70 | }, (success) => { 71 | if (!success) { 72 | window.Telegram.WebApp.BiometricManager.openSettings(); 73 | } 74 | window.Telegram.WebApp.BiometricManager.updateBiometricToken(token, () => { 75 | settingsManager?.setBiometricsEnabled(token !== '' && success); 76 | }); 77 | }); 78 | } else { 79 | window.Telegram.WebApp.BiometricManager.updateBiometricToken(token, (success) => { 80 | if(!success) { 81 | window.Telegram.WebApp.BiometricManager.openSettings(); 82 | } 83 | settingsManager?.setBiometricsEnabled(token !== '' && success); 84 | }); 85 | } 86 | }, 87 | getToken: (callback: (token?: string) => void) => { 88 | if(!isAvailable || !isSaved || isRequested) return; 89 | setIsRequested(true); 90 | try { 91 | window.Telegram.WebApp.BiometricManager.authenticate({ 92 | reason: l10n("BiometricsAuthenticateReason"), 93 | }, (_success, token?: string) => { 94 | setIsRequested(false); 95 | callback(token); 96 | }); 97 | } catch (e) { 98 | // ignore for react strict mode compatibility 99 | } 100 | 101 | } 102 | }; 103 | return 104 | {children} 105 | ; 106 | } 107 | -------------------------------------------------------------------------------- /src/managers/encryption.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, FC, PropsWithChildren, useContext, useEffect, useState} from "react"; 2 | import * as crypto from "crypto-js"; 3 | import {SettingsManagerContext} from "./settings.tsx"; 4 | import {BiometricsManagerContext} from "./biometrics.tsx"; 5 | 6 | const kdfOptions = {keySize: 256 / 8}; 7 | const saltBytes = 128 / 8; 8 | const ivBytes = 128 / 8; 9 | const keyCheckValuePlaintext = "key-check-value"; 10 | 11 | /** 12 | * EncryptionManager is used to handle everything related to encryption. 13 | * It is responsible for unlocking the storage, checking passwords, and encrypting/decrypting data. 14 | * Currently, AES-128 encryption and PBKDF2 key derive function are implemented. 15 | * 16 | * The actual encryption methods used are implemented in the CryptoJS library. 17 | * 18 | * User's password is not stored anywhere outside the device itself. 19 | * Instead, Key Checksum Value is used to verify the validity of the entered key. 20 | * After a successful password entry, the derived key is stored in the localStorage (if not disabled in settings). 21 | * 22 | * To get an instance of EncryptionManager, you should use the useContext hook: 23 | * @example 24 | * const encryptionManager = useContext(EncryptionManagerContext); 25 | */ 26 | export interface EncryptionManager { 27 | /** 28 | * This is a boolean indicating if KCV and password salt were read from the storage. 29 | * The app should wait for this value to be true to try to unlock the EncryptionManager or use encrypt/decrypt methods. 30 | */ 31 | storageChecked: boolean; 32 | /** 33 | * This flag indicates that the password exists, and it's salt and KCV are in the storage. 34 | * Its value is null when EncryptionManager haven't checked the storage yet. 35 | */ 36 | passwordCreated: boolean | null; 37 | 38 | /** 39 | * This method is used to create a new password or change the existing one. 40 | * If this method is called with the EncryptionManager being unlocked, the previous key is stored in the oldKey variable. 41 | * @param password - The new password. It should be provided in the plaintext form. 42 | */ 43 | createPassword(password: string): void; 44 | 45 | /** 46 | * This method removes the salt and KCV from the storage. After it is called, passwordCreated would become false. 47 | */ 48 | removePassword(): void; 49 | 50 | /** 51 | * This method saves the current key as a biometric token to the storage on device. 52 | */ 53 | saveBiometricToken(): void; 54 | 55 | /** 56 | * This method removes the current key from the biometric storage on device. 57 | */ 58 | removeBiometricToken(): void; 59 | 60 | /** 61 | * This flag indicates whether EncryptionManager is locked or not. 62 | * If it is equal to true, the user should unlock the storage using the unlock method 63 | */ 64 | isLocked: boolean; 65 | 66 | /** 67 | * This method takes in the plaintext password from the user, verifies the validity using KCV, 68 | * and stores the key locally in case of success. After the successful execution of this method, 69 | * isLocked would change to false. 70 | * @param password - a boolean indicating whether the provided password is correct. 71 | */ 72 | unlock(password: string): boolean; 73 | 74 | /** 75 | * This method tries to unlock the storage by using the biometric token. The behaviour is the same as `unlock`. 76 | */ 77 | unlockBiometrics(): void; 78 | 79 | /** 80 | * This method removes the stored key from localStorage After the execution of this method, 81 | * isLocked would change to true. 82 | */ 83 | lock(): void; 84 | 85 | /** 86 | * This variable contains the previous password's key. 87 | * It is used to indicate that the password was changed to re-encrypt the accounts with the correct new key. 88 | */ 89 | oldKey: crypto.lib.WordArray | null; 90 | 91 | /** 92 | * This method encrypts the `data` string with the stored key and returns the corresponding ciphertext. 93 | * This method will return null if the EncryptionManager is locked. 94 | * @param data - string to be encrypted 95 | */ 96 | encrypt(data: string): string | null; 97 | 98 | /** 99 | * This method decrypts the data string with the stored key and returns the corresponding plaintext. 100 | * This method will return null if the EncryptionManager is locked. 101 | * @param data - string to be decrypted 102 | */ 103 | decrypt(data: string): string | null; 104 | } 105 | 106 | export interface EncryptedData { 107 | iv: string, 108 | cipher: string, 109 | } 110 | 111 | export const EncryptionManagerContext = createContext(null); 112 | 113 | function checkKey(key: crypto.lib.WordArray, salt: crypto.lib.WordArray, keyCheckValue: string): boolean { 114 | const kcv = crypto.AES.encrypt(keyCheckValuePlaintext, key, {iv: salt}).toString(crypto.format.OpenSSL); 115 | return kcv === keyCheckValue; 116 | } 117 | 118 | function getStoredKey(): crypto.lib.WordArray | null { 119 | const key = localStorage.getItem("key"); 120 | return key !== null ? crypto.enc.Base64.parse(key) : null; 121 | } 122 | 123 | /** 124 | * EncryptionManager is created using EncryptionManagerProvider component. 125 | * 126 | * @note EncryptionManagerProvider must be used inside the SettingsManagerProvider 127 | */ 128 | export const EncryptionManagerProvider: FC = ({ children }) => { 129 | const [key, setKey] = useState(getStoredKey); 130 | const [storageChecked, setStorageChecked] = useState(false); 131 | const [salt, setSalt] = useState(null); 132 | const [keyCheckValue, setKeyCheckValue] = useState(null); 133 | const [oldKey, setOldKey] = useState(null); 134 | 135 | const biometricsManager = useContext(BiometricsManagerContext); 136 | 137 | const settingsManager = useContext(SettingsManagerContext); 138 | 139 | useEffect(() => { 140 | if(settingsManager?.shouldKeepUnlocked) { 141 | if(key !== null) { 142 | localStorage.setItem("key", crypto.enc.Base64.stringify(key)); 143 | } 144 | } else { 145 | localStorage.removeItem("key"); 146 | } 147 | }, [key, settingsManager?.shouldKeepUnlocked]); 148 | 149 | useEffect(() => { 150 | window.Telegram.WebApp.CloudStorage.getItems(["salt", "kcv"], (error, result) => { 151 | if (error) { 152 | window.Telegram.WebApp.showAlert(`Failed to get salt: ${error}`); 153 | return; 154 | } 155 | const salt = result?.salt ? crypto.enc.Base64.parse(result.salt) : null; 156 | const kcv = result?.kcv ?? null; 157 | setSalt(salt); 158 | setKeyCheckValue(kcv); 159 | const key = getStoredKey(); 160 | if (salt === null || kcv === null || key === null || !checkKey(key, salt, kcv)) { 161 | setKey(null); 162 | } 163 | setStorageChecked(true); 164 | }); 165 | }, []); 166 | 167 | const encryptionManager: EncryptionManager = { 168 | oldKey, 169 | storageChecked, 170 | passwordCreated: storageChecked ? salt !== null : null, 171 | createPassword(password: string) { 172 | setOldKey(key); 173 | const salt = crypto.lib.WordArray.random(saltBytes); 174 | const newKey = crypto.PBKDF2(password, salt, kdfOptions); 175 | const kcv = crypto.AES.encrypt(keyCheckValuePlaintext, newKey, {iv: salt}).toString(crypto.format.OpenSSL); 176 | 177 | setSalt(salt); 178 | window.Telegram.WebApp.CloudStorage.setItem("salt", crypto.enc.Base64.stringify(salt)); 179 | 180 | setKeyCheckValue(kcv); 181 | window.Telegram.WebApp.CloudStorage.setItem("kcv", kcv); 182 | 183 | setKey(newKey); 184 | }, 185 | removePassword() { 186 | window.Telegram.WebApp.CloudStorage.removeItems(["kcv", "salt"]); 187 | localStorage.removeItem("key"); 188 | setKey(null); 189 | setSalt(null); 190 | setKeyCheckValue(null); 191 | }, 192 | saveBiometricToken() { 193 | if (key === null) return; 194 | biometricsManager?.updateToken(crypto.enc.Base64.stringify(key)); 195 | }, 196 | removeBiometricToken() { 197 | biometricsManager?.updateToken(""); 198 | }, 199 | 200 | isLocked: !storageChecked || key === null, 201 | unlock(enteredPassword) { 202 | if (salt === null || keyCheckValue === null) { 203 | return false; 204 | } 205 | const key = crypto.PBKDF2(enteredPassword, salt, kdfOptions); 206 | 207 | if(checkKey(key, salt, keyCheckValue)) { 208 | setKey(key); 209 | return true; 210 | } 211 | 212 | return false; 213 | }, 214 | lock() { 215 | setKey(null); 216 | localStorage.removeItem("key"); 217 | }, 218 | unlockBiometrics() { 219 | if (salt === null || keyCheckValue === null) { 220 | return; 221 | } 222 | 223 | biometricsManager?.getToken((token?) => { 224 | if (token) { 225 | const key = crypto.enc.Base64.parse(token); 226 | if(checkKey(key, salt, keyCheckValue)) { 227 | setKey(key); 228 | } 229 | } 230 | }); 231 | 232 | }, 233 | 234 | encrypt(data) { 235 | if(key === null) return null; 236 | 237 | const iv = crypto.lib.WordArray.random(ivBytes); 238 | return JSON.stringify({ 239 | iv: crypto.enc.Base64.stringify(iv), 240 | cipher: crypto.AES.encrypt(crypto.enc.Utf8.parse(data), key, {iv}).toString() 241 | } as EncryptedData); 242 | }, 243 | decrypt(data) { 244 | if(key === null) return null; 245 | try { 246 | const {iv, cipher}: EncryptedData = JSON.parse(data) as EncryptedData; 247 | 248 | return crypto.enc.Utf8.stringify(crypto.AES.decrypt(cipher, key, { 249 | iv: crypto.enc.Base64.parse(iv) 250 | })); 251 | } catch (e) { 252 | console.error(e); 253 | return null; 254 | } 255 | }, 256 | }; 257 | 258 | return 259 | {children} 260 | ; 261 | } 262 | -------------------------------------------------------------------------------- /src/managers/localization.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, FC, PropsWithChildren, useContext, useEffect, useState} from "react"; 2 | import { lang } from "../lang/en.ts"; 3 | import {defaultLanguage, languages} from "../globals.tsx"; 4 | import {SettingsManagerContext} from "./settings.tsx"; 5 | 6 | export type LangKey = keyof typeof lang; 7 | export type Translations = Partial>; 8 | export type Language = typeof languages[number]; 9 | export interface LanguageDescription { 10 | native: string; 11 | default: string; 12 | } 13 | 14 | import { lang as defaultTranslations } from "../lang/en.ts"; 15 | 16 | /** 17 | * LocalizationManager is used to provide strings for multiple languages. 18 | * 19 | * To get an instance of LocalizationManager, you should use the useContext hook: 20 | * @example 21 | * const localizationManager = useContext(LocalizationManagerContext); 22 | */ 23 | export interface LocalizationManager { 24 | /** 25 | * Get the translation for the string in the current language. 26 | * @param key - the lang key, defined in the default language (`en.ts`) 27 | * @param args - optional variables to be replaced in the string 28 | */ 29 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 30 | l10n(key: LangKey, args?: Record): string; 31 | } 32 | 33 | export const LocalizationManagerContext = createContext(null); 34 | 35 | /** 36 | * LocalizationManager is created using LocalizationManagerProvider component. 37 | * 38 | * @note LocalizationManagerProvider must be used inside the SettingsManagerProvider 39 | */ 40 | export const LocalizationManagerProvider: FC = ({ children }) => { 41 | const settingsManager = useContext(SettingsManagerContext); 42 | const lang = settingsManager?.selectedLanguage ?? defaultLanguage; 43 | const [translations, setTranslations] = useState(defaultTranslations); 44 | useEffect(() => { 45 | if(!languages.includes(lang)) return; 46 | void import(`../lang/${lang}.ts`).then(translations => { 47 | setTranslations(translations.lang); 48 | }); 49 | }, [lang]); 50 | const localizationManager: LocalizationManager = { 51 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 52 | l10n(key: LangKey, args?: Record): string { 53 | let template: string; 54 | if(key in translations) { 55 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 56 | template = translations[key]!; 57 | } else { 58 | template = defaultTranslations[key]; 59 | } 60 | 61 | if(!args) return template; 62 | for (const placeholder in args) { 63 | template = template.replace(new RegExp("\\{" + placeholder + "\\}", "gi"), args[placeholder]); 64 | } 65 | 66 | return template; 67 | } 68 | }; 69 | 70 | return 71 | {children} 72 | ; 73 | }; 74 | -------------------------------------------------------------------------------- /src/managers/settings.tsx: -------------------------------------------------------------------------------- 1 | import {createContext, FC, PropsWithChildren, useState} from "react"; 2 | import {Language} from "./localization.tsx"; 3 | import {defaultLanguage, languages} from "../globals.tsx"; 4 | 5 | /** 6 | * SettingsManager is used to provide the app with user's preferences. 7 | * Currently, SettingsManager stores settings in the localStorage, so the state is NOT persistent between devices, 8 | * even on the same Telegram account. 9 | * 10 | * To get an instance of SettingsManager, you should use the useContext hook: 11 | * @example 12 | * const settingsManager = useContext(SettingsManagerContext); 13 | */ 14 | export interface SettingsManager { 15 | /** 16 | * This flag indicates whether the app should stay in the unlocked state between restarts. 17 | */ 18 | shouldKeepUnlocked: boolean; 19 | 20 | /** 21 | * This method changes the value of the shouldKeepUnlocked flag. 22 | * @param keep - the new value 23 | */ 24 | setKeepUnlocked(keep: boolean): void; 25 | 26 | /** 27 | * This value contains the id of the account that was previously selected. 28 | * If this value is missing in the storage, returns null. 29 | * @note The id returned in this method is NOT checked to be a valid account id. 30 | */ 31 | lastSelectedAccount: string | null; 32 | 33 | /** 34 | * This method updates the last selected account value in the storage. 35 | * @param id - the account id 36 | */ 37 | setLastSelectedAccount(id: string): void; 38 | 39 | /** 40 | * This flag indicates whether the user has enabled biometric unlock and saved the token. 41 | */ 42 | biometricsEnabled: boolean; 43 | 44 | /** 45 | * This method updates the state of biometric unlock flag. 46 | * @param enabled - the state 47 | */ 48 | setBiometricsEnabled(enabled: boolean): void; 49 | 50 | /** 51 | * This value contains the current user's language. 52 | */ 53 | selectedLanguage: Language; 54 | 55 | /** 56 | * This value updates the user's preferred language. 57 | * @param {Language} language - new preferred language. 58 | */ 59 | setLanguage(language: Language): void; 60 | } 61 | 62 | export const SettingsManagerContext = createContext(null); 63 | 64 | /** 65 | * SettingsManager is created using SettingsManagerProvider component. 66 | */ 67 | export const SettingsManagerProvider: FC = ({ children }) => { 68 | const [shouldKeepUnlocked, setKeepUnlocked] = useState(() => { 69 | const item = localStorage.getItem("keepUnlocked"); 70 | return item ? JSON.parse(item) as boolean : true; 71 | }); 72 | const [biometricsEnabled, setBiometricsEnabled] = useState(() => { 73 | const item = localStorage.getItem("biometricsEnabled"); 74 | return item ? JSON.parse(item) as boolean : false; 75 | }); 76 | const [lastSelectedAccount, setLastSelectedAccount] = useState(() => { 77 | return localStorage.getItem("lastSelectedAccount"); 78 | }); 79 | const [selectedLanguage, setLanguage] = useState(() => { 80 | const userLang = window.Telegram.WebApp.initDataUnsafe.user?.language_code as Language | undefined; 81 | const fallbackLang = (userLang && languages.includes(userLang)) ? userLang : defaultLanguage; 82 | return localStorage.getItem("selectedLanguage") as Language | null ?? fallbackLang; 83 | }); 84 | 85 | const settingsManager: SettingsManager = { 86 | lastSelectedAccount, 87 | setLastSelectedAccount(id: string) { 88 | setLastSelectedAccount(id); 89 | localStorage.setItem("lastSelectedAccount", id); 90 | }, 91 | shouldKeepUnlocked, 92 | setKeepUnlocked(keep: boolean) { 93 | setKeepUnlocked(keep); 94 | localStorage.setItem("keepUnlocked", JSON.stringify(keep)); 95 | }, 96 | biometricsEnabled, 97 | setBiometricsEnabled(enable: boolean) { 98 | setBiometricsEnabled(enable); 99 | localStorage.setItem("biometricsEnabled", JSON.stringify(enable)); 100 | }, 101 | selectedLanguage, 102 | setLanguage(language: Language) { 103 | setLanguage(language); 104 | localStorage.setItem("selectedLanguage", language); 105 | } 106 | }; 107 | 108 | return 109 | {children} 110 | 111 | }; 112 | -------------------------------------------------------------------------------- /src/managers/storage/migrate.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { 3 | migrationDirection, 4 | migrate, 5 | MigrationDirection, 6 | } from "./migrate"; 7 | import { AccountV1, AccountV2 } from "./storage"; 8 | import { MIGRATIONS_SCHEMA } from "./migrations"; 9 | 10 | describe("migrationDirection", () => { 11 | it("should return MigrationDirection.up if fromVersion is less than toVersion", () => { 12 | const fromVersion = "1"; 13 | const toVersion = "2"; 14 | const expected: MigrationDirection = MigrationDirection.up; 15 | 16 | const actual = migrationDirection(fromVersion, toVersion); 17 | expect(actual).toEqual(expected); 18 | }); 19 | 20 | it("should return MigrationDirection.same if fromVersion is equal to toVersion", () => { 21 | const fromVersion = "1"; 22 | const toVersion = "1"; 23 | const expected = MigrationDirection.same; 24 | 25 | const actual = migrationDirection(fromVersion, toVersion); 26 | expect(actual).toEqual(expected); 27 | }); 28 | 29 | it("should return MigrationDirection.down if fromVersion is greater than toVersion", () => { 30 | const fromVersion = "2"; 31 | const toVersion = "1"; 32 | const expected = MigrationDirection.down; 33 | const actual = migrationDirection(fromVersion, toVersion); 34 | expect(actual).toEqual(expected); 35 | }); 36 | }); 37 | 38 | describe("migrate", () => { 39 | it("should return the same schema if fromVersion is equal to toVersion", () => { 40 | const fromVersion = "1"; 41 | const schema: AccountV1 = { 42 | id: "test", 43 | label: "label", 44 | uri: "uri", 45 | issuer: "issuer", 46 | icon: "icon", 47 | color: "error", 48 | }; 49 | 50 | const toVersion = "1"; 51 | const expected = schema; 52 | 53 | const actual = migrate(MIGRATIONS_SCHEMA, schema, fromVersion, toVersion); 54 | expect(actual).toEqual(expected); 55 | }); 56 | 57 | it("should migrate the schema to the latest version if toVersion is greater than fromVersion", () => { 58 | const fromVersion = "1"; 59 | const accounts: AccountV1[] = [ 60 | { 61 | id: "test1", 62 | label: "label", 63 | uri: "uri", 64 | issuer: "issuer", 65 | 66 | icon: "comment", 67 | color: "error", 68 | }, 69 | { 70 | id: "test2", 71 | label: "label", 72 | uri: "uri", 73 | issuer: "issuer", 74 | 75 | icon: "discord", 76 | color: "warning", 77 | }, 78 | // all the other colors 79 | { 80 | id: "test3", 81 | label: "label", 82 | uri: "uri", 83 | icon: ".net", 84 | color: "success", 85 | }, 86 | ]; 87 | const toVersion = "2"; 88 | const expected: AccountV2[] = [ 89 | { 90 | id: "test1", 91 | label: "label", 92 | uri: "uri", 93 | issuer: "issuer", 94 | 95 | icon: "comment", 96 | color: "#d32f2f", 97 | order: -1, 98 | }, 99 | { 100 | id: "test2", 101 | label: "label", 102 | uri: "uri", 103 | issuer: "issuer", 104 | 105 | icon: 'discord', 106 | color: "#ed6c02", 107 | order: -1, 108 | }, 109 | { 110 | id: "test3", 111 | label: "label", 112 | uri: "uri", 113 | icon: "dotnet", 114 | color: "#2e7d32", 115 | order: -1, 116 | }, 117 | ]; 118 | 119 | accounts.forEach((account, i) => { 120 | const actual = migrate(MIGRATIONS_SCHEMA, account, fromVersion, toVersion); 121 | expect(actual).toEqual(expected[i]); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /src/managers/storage/migrate.ts: -------------------------------------------------------------------------------- 1 | import { AccountVersions } from "./storage"; 2 | 3 | export enum MigrationDirection { 4 | up, 5 | same, 6 | down, 7 | } 8 | 9 | export type Version = "1" | "2"; 10 | 11 | export interface AccountStorage { 12 | version: T; 13 | account: AccountVersions[T]; 14 | } 15 | 16 | export interface Migration { 17 | from: From; 18 | to: To; 19 | up: MigrateUpFunc; 20 | } 21 | 22 | export type MigrateUpFunc = ( 23 | schema: AccountVersions[From] 24 | ) => AccountVersions[To]; 25 | 26 | export type Migrations = [ 27 | Migration<"1", "2"> 28 | ]; 29 | 30 | export function migrationDirection( 31 | fromVersion: string, 32 | toVersion: string 33 | ): MigrationDirection { 34 | const fromNumber = Number(fromVersion); 35 | const toNumber = Number(toVersion); 36 | if (fromNumber < toNumber) return MigrationDirection.up; 37 | else if (fromNumber > toNumber) return MigrationDirection.down; 38 | else return MigrationDirection.same; 39 | } 40 | 41 | export function migrate( 42 | migrations: Migrations, 43 | account: AccountVersions[From], 44 | fromVersion: From, 45 | toVersion: To 46 | ): AccountVersions[To] { 47 | const direction = migrationDirection(fromVersion, toVersion); 48 | if (direction === MigrationDirection.same) { 49 | return account as AccountVersions[To]; 50 | } 51 | 52 | let migratedAccount: object = {}; 53 | let accVer: Version = fromVersion; 54 | while (accVer !== toVersion) { 55 | const currentMigration = migrations.find( 56 | (migration) => migration.from === accVer 57 | ); 58 | if (!currentMigration) { 59 | // if there is no migration means nothing changed 60 | throw new Error( 61 | `Could not find migration path from ${accVer} to ${toVersion}` 62 | ); 63 | } 64 | migratedAccount = currentMigration.up(account); 65 | accVer = currentMigration.to; 66 | } 67 | 68 | return migratedAccount as AccountVersions[To]; 69 | } 70 | -------------------------------------------------------------------------------- /src/managers/storage/migrations.ts: -------------------------------------------------------------------------------- 1 | import { Migrations } from "./migrate"; 2 | import { icons } from "../../globals"; 3 | import {ICONS_CDN, titleToIconSlug} from "../../icons/icons.ts"; 4 | 5 | type V1Color = [ 6 | "primary", "success", "warning", "secondary", "error", "info", 7 | ][number]; 8 | 9 | export const MIGRATIONS_SCHEMA: Migrations = [ 10 | { 11 | from: "1", 12 | to: "2", 13 | up: (account) => { 14 | function convertV1Icon(icon: string): string { 15 | if (Object.keys(icons).includes(icon)) return icon; 16 | if (icon == "twitter") icon = "x"; // thanks, Elon 17 | 18 | // prevent not simpleicons urls 19 | if (icon.startsWith("https://") && !icon.includes(ICONS_CDN)) { 20 | return "store"; 21 | } 22 | 23 | return titleToIconSlug(icon); 24 | } 25 | function convertV1Color(color: V1Color): string { 26 | switch (color) { 27 | case "primary": 28 | return "#1976d2"; 29 | case "secondary": 30 | return "#9c27b0"; 31 | case "info": 32 | return "#0288d1"; 33 | case "success": 34 | return "#2e7d32"; 35 | case "warning": 36 | return "#ed6c02"; 37 | case "error": 38 | return "#d32f2f"; 39 | default: 40 | return color; 41 | } 42 | } 43 | import.meta.env.DEV && 44 | console.log( 45 | "converted color from", 46 | account.color, 47 | "to", 48 | convertV1Color(account.color as V1Color), 49 | { 50 | ...account, 51 | 52 | color: convertV1Color(account.color as V1Color), 53 | icon: convertV1Icon(account.icon), 54 | order: -1, 55 | } 56 | ); 57 | return { 58 | ...account, 59 | 60 | color: convertV1Color(account.color as V1Color), 61 | icon: convertV1Icon(account.icon), 62 | order: -1, 63 | }; 64 | }, 65 | }, 66 | ]; 67 | -------------------------------------------------------------------------------- /src/migration/export.ts: -------------------------------------------------------------------------------- 1 | import { Payload } from "./proto/generated/migration.js"; 2 | import {AccountBase} from "../managers/storage/storage.tsx"; 3 | import {URI} from "otpauth"; 4 | 5 | export default function exportGoogleAuthenticator(accounts: AccountBase[]): string { 6 | const otpParameters: Payload.OtpParameters[] = []; 7 | for (const account of accounts) { 8 | let otp; 9 | try { 10 | otp = URI.parse(account.uri); 11 | } catch (e) { 12 | console.log("weird uri!", otp); 13 | continue; 14 | } 15 | otpParameters.push(new Payload.OtpParameters({ 16 | secret: new Uint8Array(otp.secret.buffer), 17 | name: account.issuer ? `${account.issuer}:${account.label}` : account.label, 18 | issuer: account.issuer, 19 | algorithm: ({ 20 | "SHA1": Payload.OtpParameters.Algorithm.ALGORITHM_SHA1, 21 | "SHA256": Payload.OtpParameters.Algorithm.ALGORITHM_SHA256, 22 | "SHA512": Payload.OtpParameters.Algorithm.ALGORITHM_SHA512, 23 | "MD5": Payload.OtpParameters.Algorithm.ALGORITHM_MD5, 24 | })[otp.algorithm], 25 | digits: otp.digits === 8 ? Payload.OtpParameters.DigitCount.DIGIT_COUNT_EIGHT : 26 | Payload.OtpParameters.DigitCount.DIGIT_COUNT_SIX, 27 | type: Payload.OtpParameters.OtpType.OTP_TYPE_TOTP, 28 | })); 29 | } 30 | 31 | const payload = new Payload({ 32 | otpParameters, 33 | version: 1, 34 | batchSize: 1, 35 | batchIndex: 0, 36 | batchId: null, 37 | }); 38 | 39 | const data = Payload.encode(payload).finish(); 40 | 41 | return btoa(String.fromCharCode(...data)); 42 | } 43 | -------------------------------------------------------------------------------- /src/migration/import.ts: -------------------------------------------------------------------------------- 1 | import { Payload } from "./proto/generated/migration.js"; 2 | import {Account} from "../managers/storage/storage.tsx"; 3 | import {Secret, TOTP} from "otpauth"; 4 | import {nanoid} from "nanoid"; 5 | 6 | export default function decodeGoogleAuthenticator(uri: string): Account[] | null { 7 | if (!uri.startsWith("otpauth-migration://offline")) return null; 8 | 9 | const url = new URL(uri); 10 | let dataParam = url.searchParams.get("data"); 11 | if (!dataParam) return null; 12 | 13 | // Convert from base64url 14 | dataParam = dataParam 15 | .replace(/-/g, '+') 16 | .replace(/_/g, '/'); 17 | 18 | if (dataParam.length % 4 !== 0) { 19 | const pad = 4 - (dataParam.length % 4); 20 | dataParam += "=".repeat(pad); 21 | } 22 | 23 | const buffer = Uint8Array.from(atob(dataParam), (c) => c.charCodeAt(0)); 24 | 25 | let payload; 26 | try { 27 | payload = Payload.decode(buffer); 28 | } catch (e) { 29 | return null; 30 | } 31 | 32 | const accounts: Account[] = []; 33 | 34 | for (const otp of payload.otpParameters) { 35 | if (otp.type !== Payload.OtpParameters.OtpType.OTP_TYPE_TOTP) continue; 36 | if (!otp.secret || !otp.name) continue; 37 | 38 | const totp = new TOTP({ 39 | issuer: otp.issuer ?? undefined, 40 | label: otp.name ? otp.name.split(":").pop() : undefined, 41 | secret: new Secret({buffer: otp.secret}), 42 | digits: otp.digits === Payload.OtpParameters.DigitCount.DIGIT_COUNT_EIGHT ? 8 : 6, 43 | algorithm: otp.algorithm ? ({ 44 | [Payload.OtpParameters.Algorithm.ALGORITHM_SHA1]: "SHA1", 45 | [Payload.OtpParameters.Algorithm.ALGORITHM_SHA256]: "SHA256", 46 | [Payload.OtpParameters.Algorithm.ALGORITHM_SHA512]: "SHA512", 47 | [Payload.OtpParameters.Algorithm.ALGORITHM_MD5]: "MD5", 48 | [Payload.OtpParameters.Algorithm.ALGORITHM_UNSPECIFIED]: undefined, 49 | })[otp.algorithm] : undefined, 50 | }); 51 | accounts.push({ 52 | id: nanoid(), 53 | label: totp.label, 54 | issuer: otp.issuer ?? undefined, 55 | color: "#1976d2", // primary 56 | icon: "key", 57 | uri: totp.toString(), 58 | order: -1, 59 | }); 60 | } 61 | 62 | return accounts; 63 | } 64 | -------------------------------------------------------------------------------- /src/migration/proto/migration.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | // https://github.com/dim13/otpauth/blob/master/migration/migration.proto 3 | message Payload { 4 | message OtpParameters { 5 | enum Algorithm { 6 | ALGORITHM_UNSPECIFIED = 0; 7 | ALGORITHM_SHA1 = 1; 8 | ALGORITHM_SHA256 = 2; 9 | ALGORITHM_SHA512 = 3; 10 | ALGORITHM_MD5 = 4; 11 | } 12 | enum DigitCount { 13 | DIGIT_COUNT_UNSPECIFIED = 0; 14 | DIGIT_COUNT_SIX = 1; 15 | DIGIT_COUNT_EIGHT = 2; 16 | } 17 | enum OtpType { 18 | OTP_TYPE_UNSPECIFIED = 0; 19 | OTP_TYPE_HOTP = 1; 20 | OTP_TYPE_TOTP = 2; 21 | } 22 | bytes secret = 1; 23 | string name = 2; 24 | string issuer = 3; 25 | Algorithm algorithm = 4; 26 | DigitCount digits = 5; 27 | OtpType type = 6; 28 | uint64 counter = 7; 29 | } 30 | repeated OtpParameters otp_parameters = 1; 31 | int32 version = 2; 32 | int32 batch_size = 3; 33 | int32 batch_index = 4; 34 | int32 batch_id = 5; 35 | } -------------------------------------------------------------------------------- /src/pages/Accounts.tsx: -------------------------------------------------------------------------------- 1 | import {FC, lazy, useContext, useEffect, useState} from "react"; 2 | import { 3 | LinearProgress, 4 | Stack, 5 | Typography, 6 | IconButton, 7 | Container, 8 | Grid, 9 | useTheme, 10 | ThemeProvider, 11 | } from "@mui/material"; 12 | import copy from 'copy-text-to-clipboard'; 13 | import ContentCopyIcon from '@mui/icons-material/ContentCopy'; 14 | import SettingsIcon from "@mui/icons-material/Settings"; 15 | import {useNavigate} from "react-router-dom"; 16 | import useAccount from "../hooks/useAccount.ts"; 17 | import EditIcon from '@mui/icons-material/Edit'; 18 | import AccountSelectButton from "../components/AccountSelectButton.tsx"; 19 | import NewAccountButton from "../components/NewAccountButton.tsx"; 20 | import {Account, StorageManagerContext} from "../managers/storage/storage.tsx"; 21 | import {EditAccountState} from "./EditAccount.tsx"; 22 | import useTelegramHaptics from "../hooks/telegram/useTelegramHaptics.ts"; 23 | import {SettingsManagerContext} from "../managers/settings.tsx"; 24 | import useAccountTheme from "../hooks/useAccountTheme.ts"; 25 | import { DndProvider } from "react-dnd-multi-backend"; 26 | import AccountDragPreview from "../components/AccountDragPreview.tsx"; 27 | import {Flipped, Flipper} from "react-flip-toolkit"; 28 | import {HTML5toTouch} from "../drag.ts"; 29 | import {FlatButton} from "../components/FlatButton.tsx"; 30 | import {useL10n} from "../hooks/useL10n.ts"; 31 | 32 | const NewAccount = lazy(() => import("./NewAccount.tsx")); 33 | const NewUpdateDialog = lazy(() => import("../components/NewUpdateDialog.tsx")); 34 | 35 | const Accounts: FC = () => { 36 | const navigate = useNavigate(); 37 | const l10n = useL10n(); 38 | const theme = useTheme(); 39 | const { selectionChanged, } = useTelegramHaptics(); 40 | 41 | const storageManager = useContext(StorageManagerContext); 42 | const settingsManager = useContext(SettingsManagerContext); 43 | 44 | const [ 45 | selectedAccountId, 46 | setSelectedAccountId 47 | ] = useState(settingsManager?.lastSelectedAccount ?? null); 48 | const [ 49 | selectedAccount, 50 | setSelectedAccount 51 | ] = useState(null); 52 | 53 | useEffect(() => { 54 | if(!storageManager?.accounts || storageManager.accounts.length < 1) return; 55 | if(selectedAccountId !== null && 56 | storageManager.accounts.find(acc => acc.id === selectedAccountId)) return; 57 | const accounts = storageManager.accounts; 58 | setSelectedAccountId(accounts[accounts.length - 1].id); 59 | }, [selectedAccountId, storageManager?.accounts, settingsManager?.lastSelectedAccount]); 60 | 61 | useEffect(() => { 62 | setSelectedAccount(storageManager?.accounts.find(acc => acc.id === selectedAccountId) ?? null); 63 | }, [selectedAccountId, storageManager?.accounts]); 64 | 65 | const accountTheme = useAccountTheme(selectedAccount?.color) ?? theme 66 | 67 | const {code, progress} = useAccount(selectedAccount?.uri); 68 | const [ 69 | animating, 70 | setAnimating 71 | ] = useState>({}); 72 | 73 | if (storageManager === null || Object.keys(storageManager.accounts).length < 1) { 74 | return ; 75 | } 76 | 77 | return 78 | 79 | 80 | 81 | 82 | 83 | {selectedAccount?.issuer ? 84 | `${selectedAccount.issuer} (${selectedAccount.label})` : 85 | selectedAccount?.label} 86 | 87 | { 88 | navigate('/edit', {state: { 89 | account: selectedAccount 90 | } as EditAccountState}); 91 | }}> 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | {code.match(/.{1,3}/g)?.join(" ")} 100 | 101 | { 102 | copy(code); 103 | }}> 104 | 105 | 106 | 107 | 113 | 114 | 115 | 116 | 117 | a.id).join("")}> 118 | 119 | {storageManager.accounts.map((account, index) => ( 120 | { 122 | setAnimating(anim => ({...anim, [account.id]: true})) 123 | }} 124 | onComplete={() => { 125 | setAnimating(anim => ({...anim, [account.id]: false})) 126 | }}> 127 | 128 | { 137 | settingsManager?.setLastSelectedAccount(account.id); 138 | setSelectedAccountId(account.id); 139 | selectionChanged(); 140 | }} 141 | color={account.color}/> 142 | 143 | 144 | ))} 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | { navigate("/settings"); }} text={l10n("ActionOpenSettings")} 159 | icon={SettingsIcon} center={true}/> 160 | ; 161 | } 162 | 163 | export default Accounts; 164 | -------------------------------------------------------------------------------- /src/pages/CreateAccount.tsx: -------------------------------------------------------------------------------- 1 | import {Stack, Typography} from "@mui/material"; 2 | import {Ref, createRef, useContext, useState} from "react"; 3 | import CreateAnimation from "../assets/create_lottie.json"; 4 | import useTelegramMainButton from "../hooks/telegram/useTelegramMainButton.ts"; 5 | import {TOTP} from "otpauth"; 6 | import {useLocation, useNavigate} from "react-router-dom"; 7 | import IconPicker from "../components/IconPicker.tsx"; 8 | import TelegramTextField from "../components/TelegramTextField.tsx"; 9 | import {StorageManagerContext} from "../managers/storage/storage.tsx"; 10 | import {nanoid} from "nanoid"; 11 | import {Icon} from "../globals.tsx"; 12 | import LottieAnimation from "../components/LottieAnimation.tsx"; 13 | import {SettingsManagerContext} from "../managers/settings.tsx"; 14 | import {PlausibleAnalyticsContext} from "../components/PlausibleAnalytics.tsx"; 15 | import {useL10n} from "../hooks/useL10n.ts"; 16 | 17 | export interface NewAccountState { 18 | otp: TOTP, 19 | icon?: string, 20 | color?: string, 21 | } 22 | 23 | export default function CreateAccount() { 24 | const navigate = useNavigate(); 25 | const location = useLocation(); 26 | const state = location.state as NewAccountState; 27 | const storageManager = useContext(StorageManagerContext); 28 | const settingsManager = useContext(SettingsManagerContext); 29 | const analytics = useContext(PlausibleAnalyticsContext); 30 | 31 | const [id] = useState(nanoid()); 32 | const [issuer, setIssuer] = useState(state.otp.issuer); 33 | const [label, setLabel] = useState(state.otp.label); 34 | const [selectedIcon, setSelectedIcon] = useState(state.icon ?? "key"); 35 | const [selectedColor, setSelectedColor] = useState(state.color ?? "#1c98e6"); 36 | const labelInput: Ref = createRef(); 37 | const l10n = useL10n(); 38 | 39 | useTelegramMainButton(() => { 40 | if (!label && !labelInput.current?.checkValidity()) { 41 | window.Telegram.WebApp.showAlert(l10n("EmptyLabelAlert")); 42 | return false; 43 | } 44 | analytics?.trackEvent("New account"); 45 | storageManager?.saveAccount({ 46 | id, 47 | color: selectedColor, 48 | icon: selectedIcon, 49 | issuer, 50 | label, 51 | uri: state.otp.toString(), 52 | order: storageManager.lastOrder() + 1, 53 | }); 54 | import.meta.env.DEV && console.log("order", storageManager?.lastOrder()) 55 | settingsManager?.setLastSelectedAccount(id); 56 | navigate("/"); 57 | return true; 58 | }, l10n("CreateAction")); 59 | 60 | return 61 | 62 | 63 | {l10n("NewAccountTitle")} 64 | 65 | 66 | {l10n("AdditionalInfo")} 67 | 68 | { 73 | state.otp.label = e.target.value; 74 | setLabel(e.target.value); 75 | }} 76 | /> 77 | { 82 | state.otp.issuer = e.target.value; 83 | setIssuer(e.target.value); 84 | }} 85 | /> 86 | 87 | ; 88 | } 89 | -------------------------------------------------------------------------------- /src/pages/Decrypt.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useContext, useEffect, useState} from "react"; 2 | import {Button, Stack, Typography} from "@mui/material"; 3 | import PasswordAnimation from "../assets/unlock_lottie.json"; 4 | import useTelegramMainButton from "../hooks/telegram/useTelegramMainButton.ts"; 5 | import {EncryptionManagerContext} from "../managers/encryption.tsx"; 6 | import TelegramTextField from "../components/TelegramTextField.tsx"; 7 | import LottieAnimation from "../components/LottieAnimation.tsx"; 8 | import ClearIcon from '@mui/icons-material/Clear'; 9 | import {useNavigate} from "react-router-dom"; 10 | import {BiometricsManagerContext} from "../managers/biometrics.tsx"; 11 | import { Fingerprint } from "@mui/icons-material"; 12 | import {useL10n} from "../hooks/useL10n.ts"; 13 | 14 | const Decrypt: FC = () => { 15 | const [password, setPassword] = useState(""); 16 | const [wrongPassword, setWrongPassword] = useState(false); 17 | const encryptionManager = useContext(EncryptionManagerContext); 18 | const biometricsManager = useContext(BiometricsManagerContext); 19 | const l10n = useL10n(); 20 | 21 | const decryptAccounts = () => { 22 | if(encryptionManager?.unlock(password)) { 23 | return true; 24 | } else { 25 | setWrongPassword(true); 26 | return false; 27 | } 28 | } 29 | 30 | useTelegramMainButton(decryptAccounts, l10n("DecryptAction")); 31 | 32 | const [biometricsRequested, setBiometricsRequested] = useState(false); 33 | useEffect(() => { 34 | if(!biometricsManager?.isSaved || biometricsRequested) return; 35 | setBiometricsRequested(true); 36 | encryptionManager?.unlockBiometrics(); 37 | }, [biometricsManager, biometricsManager?.isSaved, encryptionManager, biometricsRequested]); 38 | 39 | const navigate = useNavigate(); 40 | 41 | return <> 42 | 43 | 44 | 45 | {l10n("DecryptTitle")} 46 | 47 | 48 | {l10n("DecryptDescription")} 49 | 50 | { 59 | setPassword(e.target.value); 60 | setWrongPassword(false); 61 | }} 62 | onSubmit={decryptAccounts} 63 | /> 64 | {biometricsManager?.isSaved && 65 | 76 | } 77 | {wrongPassword ? 78 | 89 | : null} 90 | 91 | ; 92 | } 93 | 94 | export default Decrypt; 95 | -------------------------------------------------------------------------------- /src/pages/EditAccount.tsx: -------------------------------------------------------------------------------- 1 | import {useLocation, useNavigate} from "react-router-dom"; 2 | import {Ref, createRef, useContext, useState} from "react"; 3 | import {Account, StorageManagerContext} from "../managers/storage/storage.tsx"; 4 | import {Icon} from "../globals.tsx"; 5 | import useTelegramMainButton from "../hooks/telegram/useTelegramMainButton.ts"; 6 | import {Button, Stack, Typography} from "@mui/material"; 7 | import LottieAnimation from "../components/LottieAnimation.tsx"; 8 | import CreateAnimation from "../assets/create_lottie.json"; 9 | import TelegramTextField from "../components/TelegramTextField.tsx"; 10 | import IconPicker from "../components/IconPicker.tsx"; 11 | import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; 12 | import useTelegramHaptics from "../hooks/telegram/useTelegramHaptics.ts"; 13 | import {useL10n} from "../hooks/useL10n.ts"; 14 | 15 | export interface EditAccountState { 16 | account: Account; 17 | } 18 | 19 | export default function EditAccount() { 20 | const navigate = useNavigate(); 21 | const location = useLocation(); 22 | const l10n = useL10n(); 23 | const { notificationOccurred } = useTelegramHaptics(); 24 | const state = location.state as EditAccountState; 25 | const storageManager = useContext(StorageManagerContext); 26 | 27 | const [issuer, setIssuer] = useState(state.account.issuer); 28 | const [label, setLabel] = useState(state.account.label); 29 | const [selectedIcon, setSelectedIcon] = useState(state.account.icon); 30 | const [selectedColor, setSelectedColor] = useState(state.account.color); 31 | const labelInput: Ref = createRef(); 32 | 33 | useTelegramMainButton(() => { 34 | if (!label || !labelInput.current?.checkValidity()) { 35 | window.Telegram.WebApp.showAlert(l10n("EmptyLabelAlert")); 36 | return false; 37 | } 38 | storageManager?.saveAccount({ 39 | ...state.account, 40 | color: selectedColor, 41 | icon: selectedIcon, 42 | issuer, 43 | label, 44 | }); 45 | navigate("/"); 46 | return true; 47 | }, l10n("SaveAction")); 48 | 49 | return 50 | 51 | 52 | {l10n("EditTitle")} 53 | 54 | 55 | {l10n("EditDescription")} 56 | 57 | { 64 | setLabel(e.target.value); 65 | }} 66 | /> 67 | { 72 | setIssuer(e.target.value); 73 | }} 74 | /> 75 | 80 | 81 | 101 | ; 102 | } 103 | -------------------------------------------------------------------------------- /src/pages/IconBrowser.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useCallback, useEffect, useMemo, useState} from "react"; 2 | import { 3 | CircularProgress, 4 | Link, 5 | List, 6 | ListItemButton, 7 | ListItemIcon, 8 | ListItemText, 9 | Stack, 10 | Typography, 11 | } from "@mui/material"; 12 | import LottieAnimation from "../components/LottieAnimation.tsx"; 13 | import TelegramTextField from "../components/TelegramTextField.tsx"; 14 | import MagnificationGlass from "../assets/magnification_glass_lottie.json"; 15 | import { useLocation, useNavigate } from "react-router-dom"; 16 | import SVG from "react-inlinesvg"; 17 | import Fuse from "fuse.js/min-basic"; 18 | import { useDebounce } from "use-debounce"; 19 | import { EditAccountState } from "./EditAccount.tsx"; 20 | import {ICONS_DATA_URL, iconUrl, titleToIconSlug} from "../icons/icons.ts"; 21 | import {NewAccountState} from "./CreateAccount.tsx"; 22 | import normalizeCustomColor from "../icons/normalizeCustomColor.ts"; 23 | import {useTheme} from "@mui/material/styles"; 24 | import {useL10n} from "../hooks/useL10n.ts"; 25 | 26 | interface IconData { 27 | title: string; 28 | hex: string; 29 | source: string; 30 | aliases?: { 31 | aka?: string[]; 32 | loc?: Record; 33 | old?: string[]; 34 | dup?: { 35 | title: string; 36 | hex?: string; 37 | source?: string; 38 | loc?: Record; 39 | }[]; 40 | }; 41 | guidelines?: string; 42 | license?: { 43 | type: string; 44 | url?: string; 45 | }; 46 | slug?: string; 47 | } 48 | 49 | interface IconsData { 50 | icons: IconData[]; 51 | } 52 | 53 | const IconsList: FC & { searchQuery: string }> = ({ 54 | icons, 55 | searchQuery, 56 | }) => { 57 | const navigate = useNavigate(); 58 | const location = useLocation(); 59 | const isFromEditing = location.state.account !== undefined; 60 | const state = useCallback((icon: string, color: string): EditAccountState | NewAccountState => { 61 | if(isFromEditing) { 62 | const state = location.state as EditAccountState; 63 | return { 64 | account: { 65 | ...state.account, 66 | ...{ 67 | icon, 68 | color, 69 | }, 70 | }, 71 | }; 72 | } else { 73 | const state = location.state as NewAccountState; 74 | return { 75 | ...state, 76 | icon, color 77 | }; 78 | } 79 | }, [isFromEditing]); 80 | const fuse = useMemo( 81 | () => 82 | new Fuse(icons, { 83 | keys: ["title", "slug", "aliases.aka", "aliases.old", "aliases.loc", "aliases.dup"], 84 | shouldSort: true, 85 | }), 86 | [icons] 87 | ); 88 | const filtered = fuse.search(searchQuery, { limit: 10 }); 89 | 90 | const theme = useTheme(); 91 | 92 | return ( 93 | 94 | {filtered.map(({ item }) => ( 95 | { 98 | navigate(isFromEditing ? "/edit" : "/create", { 99 | state: state(item.slug ?? titleToIconSlug(item.title), `#${item.hex}`), 100 | }); 101 | }} 102 | > 103 | 104 | } 107 | src={iconUrl(item.slug ?? titleToIconSlug(item.title))} 108 | fill={normalizeCustomColor(`#${item.hex}`, theme)} 109 | > 110 | 111 | 112 | 113 | ))} 114 | 115 | ); 116 | }; 117 | 118 | const IconBrowser: FC = () => { 119 | const [phrase, setPhrase] = useState(""); 120 | const [query] = useDebounce(phrase, 1000); 121 | const [verified, setVerified] = useState(true); 122 | const [searching, setSearching] = useState(false); 123 | const [iconsData, setIconsData] = useState(); 124 | const l10n = useL10n(); 125 | 126 | useEffect(() => { 127 | void caches.open("teleotp").then(async (cache) => { 128 | const cachedData = await cache.match(ICONS_DATA_URL); 129 | if (cachedData?.ok) { 130 | setIconsData(await cachedData.json()); 131 | } else { 132 | await cache.add(ICONS_DATA_URL); 133 | const _cachedData = await cache.match(ICONS_DATA_URL); 134 | if (_cachedData?.ok) setIconsData(await _cachedData.json()); 135 | else { 136 | window.Telegram.WebApp.showAlert( 137 | l10n("IconsFetchError") 138 | ); 139 | } 140 | } 141 | }); 142 | }, []); 143 | 144 | useEffect(() => { 145 | if (verified && phrase.length > 2) { 146 | setSearching(true); 147 | } else { 148 | setSearching(false); 149 | } 150 | }, [phrase]); 151 | 152 | return ( 153 | <> 154 | 155 | 156 | {l10n("BrowseIconsTitle")} 157 | 158 | { 168 | const value = e.target.value; 169 | setPhrase(value); 170 | setVerified(value.trim().length >= 2); 171 | }} 172 | /> 173 | {!searching && ( 174 | <> 175 | 182 | {l10n("StartTyping")} 183 | 184 | 189 | 190 | )} 191 | {searching && iconsData && ( 192 | 193 | )} 194 | 195 | {l10n("IconsProvidedBy")} 196 | 197 | @simpleicons 198 | 199 | 200 | 201 | 202 | ); 203 | }; 204 | 205 | export default IconBrowser; 206 | -------------------------------------------------------------------------------- /src/pages/ManualAccount.tsx: -------------------------------------------------------------------------------- 1 | import {Stack, Typography} from "@mui/material"; 2 | import {useState} from "react"; 3 | import ManualAnimation from "../assets/manual_lottie.json"; 4 | import useTelegramMainButton from "../hooks/telegram/useTelegramMainButton.ts"; 5 | import {useNavigate} from "react-router-dom"; 6 | import {NewAccountState} from "./CreateAccount.tsx"; 7 | import {Secret, TOTP} from "otpauth"; 8 | import TelegramTextField from "../components/TelegramTextField.tsx"; 9 | import LottieAnimation from "../components/LottieAnimation.tsx"; 10 | import {useL10n} from "../hooks/useL10n.ts"; 11 | 12 | export default function ManualAccount() { 13 | const [secret, setSecret] = useState(""); 14 | const [invalid, setInvalid] = useState(false); 15 | const navigate = useNavigate(); 16 | const l10n = useL10n(); 17 | 18 | function createAccount() { 19 | if(!/^[2-7A-Z]+=*$/i.test(secret) || Secret.fromBase32(secret).buffer.byteLength < 1) { 20 | setInvalid(true); 21 | return false; 22 | } 23 | try { 24 | navigate("/create", {state: { 25 | otp: new TOTP({ 26 | label: "TeleOTP", 27 | secret 28 | }), 29 | } as NewAccountState}); 30 | return true; 31 | } catch (e) { 32 | setInvalid(true); 33 | return false; 34 | } 35 | 36 | } 37 | 38 | useTelegramMainButton(createAccount, l10n("NextStepAction")); 39 | 40 | return 41 | 42 | 43 | {l10n("AddManualTitle")} 44 | 45 | 46 | {l10n("AddManualDescription")} 47 | 48 | { 56 | setSecret(e.target.value); 57 | setInvalid(false); 58 | }} 59 | onSubmit={createAccount} 60 | /> 61 | ; 62 | } 63 | -------------------------------------------------------------------------------- /src/pages/NewAccount.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useCallback, useContext} from "react"; 2 | import {Button, Stack, Typography} from "@mui/material"; 3 | import NewAccountAnimation from "../assets/new_account_lottie.json"; 4 | import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner'; 5 | import useTelegramQrScanner from "../hooks/telegram/useTelegramQrScanner.ts"; 6 | import {useNavigate} from "react-router-dom"; 7 | import {NewAccountState} from "./CreateAccount.tsx"; 8 | import {HOTP, URI} from "otpauth"; 9 | import LottieAnimation from "../components/LottieAnimation.tsx"; 10 | import decodeGoogleAuthenticator from "../migration/import.ts"; 11 | import useTelegramHaptics from "../hooks/telegram/useTelegramHaptics.ts"; 12 | import {StorageManagerContext} from "../managers/storage/storage.tsx"; 13 | import {PlausibleAnalyticsContext} from "../components/PlausibleAnalytics.tsx"; 14 | import {FlatButton} from "../components/FlatButton.tsx"; 15 | import {useL10n} from "../hooks/useL10n.ts"; 16 | 17 | const NewAccount: FC = () => { 18 | const navigate = useNavigate(); 19 | const { notificationOccurred } = useTelegramHaptics(); 20 | const storageManager = useContext(StorageManagerContext); 21 | const analytics = useContext(PlausibleAnalyticsContext); 22 | const l10n = useL10n(); 23 | 24 | const scan = useTelegramQrScanner(useCallback((scanned) => { 25 | function invalidPopup() { 26 | window.Telegram.WebApp.showAlert(l10n("InvalidQRCodeAlert")); 27 | notificationOccurred("error"); 28 | } 29 | 30 | if (scanned.startsWith("otpauth://")) { 31 | let otp; 32 | try { 33 | otp = URI.parse(scanned); 34 | } catch (e) { 35 | invalidPopup(); 36 | return; 37 | } 38 | 39 | if (otp instanceof HOTP) { 40 | // TODO implement HOTP 41 | window.Telegram.WebApp.showAlert(l10n("HOTPUnimplementedAlert")); 42 | notificationOccurred("error"); 43 | return; 44 | } 45 | navigate("/create", {state: { 46 | otp, 47 | } as NewAccountState}); 48 | } else if (scanned.startsWith("otpauth-migration://offline")) { 49 | const accounts = decodeGoogleAuthenticator(scanned); 50 | if (accounts === null) { 51 | invalidPopup(); 52 | return; 53 | } 54 | 55 | storageManager?.saveAccounts(accounts); 56 | analytics?.trackEvent("Accounts imported from QR"); 57 | navigate("/"); 58 | } else { 59 | invalidPopup(); 60 | } 61 | 62 | }, [navigate, notificationOccurred])); 63 | 64 | return <> 65 | 66 | 67 | 68 | {l10n("NewAccountTitle")} 69 | 70 | 71 | {l10n("NewAccountDescription")} 72 | 73 | { 74 | scan() 75 | }}/> 76 | 81 | 82 | ; 83 | } 84 | 85 | export default NewAccount; 86 | -------------------------------------------------------------------------------- /src/pages/PasswordSetup.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useContext, useState} from "react"; 2 | import {Stack, Typography} from "@mui/material"; 3 | import NewPasswordAnimation from "../assets/password_lottie.json"; 4 | import ChangePasswordAnimation from "../assets/change_password_lottie.json"; 5 | import useTelegramMainButton from "../hooks/telegram/useTelegramMainButton.ts"; 6 | import {EncryptionManagerContext} from "../managers/encryption.tsx"; 7 | import TelegramTextField from "../components/TelegramTextField.tsx"; 8 | import LottieAnimation from "../components/LottieAnimation.tsx"; 9 | import {useNavigate} from "react-router-dom"; 10 | import {useL10n} from "../hooks/useL10n.ts"; 11 | 12 | 13 | const PasswordSetup: FC<{change?: boolean}> = ({change = false}) => { 14 | const [password, setPassword] = useState(""); 15 | const [passwordRepeat, setPasswordRepeat] = useState(""); 16 | const [notMatches, setNotMatches] = useState(false); 17 | const [badLength, setBadLength] = useState(false); 18 | 19 | const navigate = useNavigate(); 20 | const l10n = useL10n(); 21 | 22 | const encryptionManager = useContext(EncryptionManagerContext); 23 | useTelegramMainButton(() => { 24 | if (password !== passwordRepeat) { 25 | setNotMatches(true); 26 | return false; 27 | } 28 | 29 | if (password.length < 3) { 30 | setBadLength(true); 31 | return false; 32 | } 33 | 34 | encryptionManager?.createPassword(password); 35 | if (change) { 36 | navigate("/"); 37 | } 38 | 39 | return true; 40 | }, change ? l10n("ChangePasswordAction") : l10n("CreatePasswordAction")); 41 | 42 | return <> 43 | 44 | 48 | 49 | {change ? l10n("ChangePasswordTitle") : l10n("CreatePasswordTitle")} 50 | 51 | 52 | {l10n("PasswordSetupDescription")} 53 | 54 | { 62 | setPassword(e.target.value); 63 | setNotMatches(false); 64 | setBadLength(false); 65 | }} 66 | /> 67 | { 75 | setPasswordRepeat(e.target.value); 76 | setNotMatches(false); 77 | setBadLength(false); 78 | }} 79 | /> 80 | 81 | ; 82 | } 83 | 84 | export default PasswordSetup; 85 | -------------------------------------------------------------------------------- /src/pages/ResetAccounts.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useContext, useState} from "react"; 2 | import {Stack, Typography} from "@mui/material"; 3 | import {StorageManagerContext} from "../managers/storage/storage.tsx"; 4 | import useTelegramMainButton from "../hooks/telegram/useTelegramMainButton.ts"; 5 | import LottieAnimation from "../components/LottieAnimation.tsx"; 6 | import TelegramTextField from "../components/TelegramTextField.tsx"; 7 | import PasswordResetAnimation from "../assets/password_reset_lottie.json"; 8 | import {useNavigate} from "react-router-dom"; 9 | import {useL10n} from "../hooks/useL10n.ts"; 10 | 11 | const ResetAccounts: FC = () => { 12 | const [phrase, setPhrase] = useState(""); 13 | const [verified, setVerified] = useState(false); 14 | const storageManager = useContext(StorageManagerContext); 15 | const navigate = useNavigate(); 16 | const l10n = useL10n(); 17 | 18 | useTelegramMainButton(() => { 19 | if (!verified) return false; 20 | storageManager?.clearStorage(); 21 | navigate("/"); 22 | return true; 23 | }, l10n("RemovePermanentlyAction"), !verified); 24 | 25 | return <> 26 | 27 | 28 | 29 | {l10n("PasswordResetTitle")} 30 | 31 | 32 | 33 | {l10n("DeleteWarning")} 34 | 35 | 36 | {l10n("TypeDeleteConfirmationPhrase")} 37 | 38 | 39 | "{l10n("DeleteConfirmationPhrase")}": 40 | 41 | 42 | { 52 | const value = e.target.value; 53 | setPhrase(value); 54 | setVerified(value.trim().toLowerCase() === l10n("DeleteConfirmationPhrase").toLowerCase()); 55 | }} 56 | /> 57 | 58 | ; 59 | } 60 | 61 | export default ResetAccounts; 62 | -------------------------------------------------------------------------------- /src/pages/SelectLanguage.tsx: -------------------------------------------------------------------------------- 1 | import {useContext} from "react"; 2 | import {SettingsManagerContext} from "../managers/settings.tsx"; 3 | import {Divider, List, ListItemButton, ListItemIcon, ListItemText, Radio, Stack} from "@mui/material"; 4 | import {languageDescriptions} from "../globals.tsx"; 5 | import {Language} from "../managers/localization.tsx"; 6 | import {useNavigate} from "react-router-dom"; 7 | import {FlatButton} from "../components/FlatButton.tsx"; 8 | import {useL10n} from "../hooks/useL10n.ts"; 9 | import TranslateIcon from '@mui/icons-material/Translate'; 10 | 11 | export default function SelectLanguage() { 12 | const settingsManager = useContext(SettingsManagerContext); 13 | const navigate = useNavigate(); 14 | const l10n = useL10n(); 15 | 16 | return ( 17 | 18 | 19 | {Object.entries(languageDescriptions).map(([key, language], index) =>
20 | {index === 0 || } 21 | { 22 | settingsManager?.setLanguage(key as Language); 23 | navigate(-1); 24 | }}> 25 | 26 | 33 | 34 | 35 | 36 |
)} 37 |
38 | { 39 | window.Telegram.WebApp.openLink(import.meta.env.VITE_TRANSLATE_LINK); 40 | }} text={l10n("HelpTranslating")} icon={TranslateIcon}/> 41 |
); 42 | } 43 | -------------------------------------------------------------------------------- /src/pages/Settings.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useContext} from "react"; 2 | import {Link, Stack, Typography, useTheme} from "@mui/material"; 3 | import {StorageManagerContext} from "../managers/storage/storage.tsx"; 4 | import {EncryptionManagerContext} from "../managers/encryption.tsx"; 5 | import LockOutlinedIcon from "@mui/icons-material/LockOutlined"; 6 | import KeyOutlinedIcon from "@mui/icons-material/KeyOutlined"; 7 | import LogoutOutlinedIcon from "@mui/icons-material/LogoutOutlined"; 8 | import PersonOutlineOutlinedIcon from "@mui/icons-material/PersonOutlineOutlined"; 9 | import CloseOutlinedIcon from "@mui/icons-material/CloseOutlined"; 10 | import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined"; 11 | import FingerprintIcon from '@mui/icons-material/Fingerprint'; 12 | import {Newspaper, Language} from "@mui/icons-material"; 13 | import {Link as RouterLink, useNavigate} from "react-router-dom"; 14 | import {SettingsManagerContext} from "../managers/settings.tsx"; 15 | import useTelegramHaptics from "../hooks/telegram/useTelegramHaptics.ts"; 16 | import {BiometricsManagerContext} from "../managers/biometrics.tsx"; 17 | import {PlausibleAnalyticsContext} from "../components/PlausibleAnalytics.tsx"; 18 | import {FlatButton} from "../components/FlatButton.tsx"; 19 | import {useL10n} from "../hooks/useL10n.ts"; 20 | import {defaultLanguage, languageDescriptions} from "../globals.tsx"; 21 | 22 | const Settings: FC = () => { 23 | const theme = useTheme(); 24 | const navigate = useNavigate(); 25 | const { impactOccurred, notificationOccurred } = useTelegramHaptics(); 26 | const biometricsManager = useContext(BiometricsManagerContext); 27 | const storageManager = useContext(StorageManagerContext); 28 | const encryptionManager = useContext(EncryptionManagerContext); 29 | const settingsManager = useContext(SettingsManagerContext); 30 | const analytics = useContext(PlausibleAnalyticsContext); 31 | const l10n = useL10n(); 32 | 33 | return ( 34 | 35 | 41 | {l10n("Settings.General")} 42 | 43 | { 45 | navigate("lang"); 46 | }} 47 | text={l10n("Language")} 48 | icon={Language} 49 | value={languageDescriptions[settingsManager?.selectedLanguage ?? defaultLanguage].native} 50 | /> 51 | { 53 | window.Telegram.WebApp.openTelegramLink(import.meta.env.VITE_CHANNEL_LINK); 54 | }} 55 | text={l10n("NewsChannel")} 56 | value={l10n("ActionOpen")} 57 | icon={Newspaper} 58 | /> 59 | 60 | 66 | {l10n("Settings.Security")} 67 | 68 | { 70 | navigate("/changePassword"); 71 | }} 72 | text={l10n("Password")} 73 | value={l10n("ActionChange")} 74 | icon={LockOutlinedIcon} 75 | /> 76 | 77 | { 79 | impactOccurred("light"); 80 | settingsManager?.setKeepUnlocked(!settingsManager.shouldKeepUnlocked); 81 | }} 82 | text={l10n("KeepUnlocked")} 83 | value={settingsManager?.shouldKeepUnlocked ? l10n("Enabled") : l10n("Disabled")} 84 | icon={KeyOutlinedIcon}/> 85 | 86 | { 88 | if(!biometricsManager?.isAvailable) { 89 | notificationOccurred("error"); 90 | return; 91 | } 92 | impactOccurred("light"); 93 | if(biometricsManager.isSaved) { 94 | encryptionManager?.removeBiometricToken(); 95 | } else { 96 | encryptionManager?.saveBiometricToken(); 97 | analytics?.trackEvent("Biometrics enabled"); 98 | } 99 | }} 100 | text={l10n("UseBiometrics")} 101 | value={ 102 | biometricsManager?.isAvailable ? (biometricsManager.isSaved ? l10n("Enabled") : l10n("Disabled")) : l10n("NotAvailable") 103 | } 104 | disabled={!biometricsManager?.isAvailable} 105 | icon={FingerprintIcon}/> 106 | 107 | { 109 | encryptionManager?.lock(); 110 | }} 111 | text={l10n("LockAccounts")} 112 | icon={LogoutOutlinedIcon} 113 | /> 114 | 115 | 121 | {l10n("Settings.Accounts")} 122 | 123 | { 125 | navigate("/"); 126 | }} 127 | text={l10n("Accounts")} 128 | value={ 129 | storageManager 130 | ? storageManager.accounts.length.toString() 131 | : "0" 132 | } 133 | icon={PersonOutlineOutlinedIcon} 134 | /> 135 | 136 | { 138 | navigate("/export"); 139 | // window.Telegram.WebApp.openTelegramLink( 140 | // `https://t.me/${ 141 | // import.meta.env.VITE_BOT_USERNAME 142 | // }?start=export` 143 | // ); 144 | }} 145 | text={l10n("ActionExportAccounts")} 146 | icon={FileDownloadOutlinedIcon} 147 | /> 148 | 149 | { 151 | notificationOccurred("warning"); 152 | navigate("/reset"); 153 | }} 154 | text={l10n("ActionRemoveAccounts")} 155 | icon={CloseOutlinedIcon} 156 | /> 157 | 158 | 164 | TeleOTP 165 |
166 | {l10n("Version")}: {APP_VERSION} 167 |
168 | 174 | {l10n("StarUs")} 175 | 176 |
177 | 183 | {l10n("HelpTranslating")} 184 | 185 | {import.meta.env.DEV && ( 186 | <> 187 |
188 | 189 | {l10n("DevTools")} 190 | 191 | 192 | )} 193 |
194 |
195 | ); 196 | }; 197 | 198 | export default Settings; 199 | -------------------------------------------------------------------------------- /src/pages/UserErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import {FC, useContext, useEffect, useState} from "react"; 2 | import {useLocation, useRouteError} from "react-router-dom"; 3 | import {CssBaseline, Stack, ThemeProvider, Typography} from "@mui/material"; 4 | import LottieAnimation from "../components/LottieAnimation.tsx"; 5 | import CrashAnimation from "../assets/crash_lottie.json"; 6 | import {FlatButton} from "../components/FlatButton.tsx"; 7 | import BugReportIcon from '@mui/icons-material/BugReport'; 8 | import ReplyIcon from '@mui/icons-material/Reply'; 9 | import useTelegramTheme from "../hooks/telegram/useTelegramTheme.ts"; 10 | import {EncryptionManagerContext} from "../managers/encryption.tsx"; 11 | import copyTextToClipboard from "copy-text-to-clipboard"; 12 | import {StorageManagerContext} from "../managers/storage/storage.tsx"; 13 | import {SettingsManagerContext} from "../managers/settings.tsx"; 14 | import {LocalizationManagerContext} from "../managers/localization.tsx"; 15 | 16 | async function uploadPaste(content: string) { 17 | const paste = await window.fetch("https://api.pastes.dev/post", { 18 | method: "POST", 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | body: content, 23 | }); 24 | 25 | return `https://pastes.dev/${(await paste.json()).key}`; 26 | } 27 | 28 | const UserErrorPage: FC = () => { 29 | const error = useRouteError() as Error | undefined; 30 | const tgTheme = useTelegramTheme(); 31 | const location = useLocation(); 32 | 33 | const encryption = useContext(EncryptionManagerContext); 34 | const storage = useContext(StorageManagerContext); 35 | const settings = useContext(SettingsManagerContext); 36 | const localization = useContext(LocalizationManagerContext); 37 | const [time, setTime] = useState(0); 38 | useEffect(() => { 39 | setTime(performance.now()) 40 | }, []); 41 | 42 | return 43 | 44 | 45 | 46 | 47 | Oops! TeleOTP has crashed 48 | 49 | 50 | Please send us debug information so we can work on a fix. 51 | 52 |
53 | { 54 | void uploadPaste(JSON.stringify({ 55 | error: error ? JSON.parse(JSON.stringify(error, Object.getOwnPropertyNames(error))) : error, 56 | telegram: { 57 | version: window.Telegram.WebApp.version, 58 | userId: window.Telegram.WebApp.initDataUnsafe.user?.id 59 | }, 60 | time, 61 | path: location.pathname, 62 | href: window.location.href, 63 | managers: { 64 | encryption: encryption ? { 65 | isLocked: encryption.isLocked, 66 | storageChecked: encryption.storageChecked, 67 | passwordCreated: encryption.passwordCreated, 68 | } : null, 69 | storage: storage ? { 70 | ready: storage.ready, 71 | accounts: storage.accounts.length, 72 | } : null, 73 | settings: settings ? { 74 | language: settings.selectedLanguage, 75 | lastAccount: settings.lastSelectedAccount, 76 | } : null, 77 | localization: !!localization, 78 | } 79 | }, null, 4)).then(paste => { 80 | copyTextToClipboard(paste); 81 | }); 82 | }} text={"Copy debug information"} icon={BugReportIcon}/> 83 | 84 | { 85 | window.Telegram.WebApp.openTelegramLink(import.meta.env.VITE_CHANNEL_LINK); 86 | }} text={"Open channel"} icon={ReplyIcon}/> 87 |
88 |
; 89 | } 90 | 91 | export default UserErrorPage; 92 | -------------------------------------------------------------------------------- /src/pages/export/ExportAccounts.tsx: -------------------------------------------------------------------------------- 1 | import {Stack, Typography} from "@mui/material"; 2 | import LottieAnimation from "../../components/LottieAnimation.tsx"; 3 | import ExportAnimation from "../../assets/export_lottie.json"; 4 | import {FlatButton} from "../../components/FlatButton.tsx"; 5 | import {LinkOutlined, QrCode} from "@mui/icons-material"; 6 | import {useNavigate} from "react-router-dom"; 7 | import {useL10n} from "../../hooks/useL10n.ts"; 8 | 9 | export default function ExportAccounts() { 10 | const navigate = useNavigate(); 11 | const l10n = useL10n(); 12 | 13 | return 14 | 15 | 16 | {l10n("ExportAccountsTitle")} 17 | 18 | 19 | {l10n("ExportAccountsText")} 20 | 21 | 22 | { navigate("link"); }} text={l10n("Export.ViaLink")} icon={LinkOutlined}/> 23 | { navigate("qr"); }} text={l10n("Export.ViaQR")} icon={QrCode}/> 24 | 25 | ; 26 | } 27 | -------------------------------------------------------------------------------- /src/pages/export/LinkExport.tsx: -------------------------------------------------------------------------------- 1 | import {Stack, Typography} from "@mui/material"; 2 | import ContentCopyIcon from "@mui/icons-material/ContentCopy"; 3 | import copyTextToClipboard from "copy-text-to-clipboard"; 4 | import LottieAnimation from "../../components/LottieAnimation.tsx"; 5 | import ExportAnimation from "../../assets/export_link_lottie.json"; 6 | import {useContext, useEffect, useState} from "react"; 7 | import {StorageManagerContext} from "../../managers/storage/storage.tsx"; 8 | import exportGoogleAuthenticator from "../../migration/export.ts"; 9 | import useTelegramMainButton from "../../hooks/telegram/useTelegramMainButton.ts"; 10 | import {useNavigate} from "react-router-dom"; 11 | import {FlatButton} from "../../components/FlatButton.tsx"; 12 | import {useL10n} from "../../hooks/useL10n.ts"; 13 | 14 | 15 | export default function LinkExport() { 16 | const [linkData, setLinkData] = useState(null); 17 | const storageManager = useContext(StorageManagerContext); 18 | useEffect(() => { 19 | if (!storageManager?.accounts || !storageManager.ready) return; 20 | 21 | const data = exportGoogleAuthenticator(storageManager.accounts); 22 | setLinkData(data.replaceAll("+", "-") 23 | .replaceAll("/", "_") 24 | .replaceAll("=", "")) 25 | }, [storageManager?.accounts, storageManager?.ready]); 26 | 27 | const l10n = useL10n(); 28 | const navigate = useNavigate(); 29 | useTelegramMainButton(() => { 30 | navigate(-1); 31 | return true; 32 | }, l10n("GoBackAction")); 33 | 34 | return 35 | 36 | 37 | {l10n("LinkExportTitle")} 38 | 39 | 40 | 41 | {l10n("LinkExportDescription")} 42 | 43 | 44 | { 45 | copyTextToClipboard(`https://t.me/${import.meta.env.VITE_BOT_USERNAME}/${import.meta.env.VITE_APP_NAME}?startapp=${linkData}`); 46 | }}/> 47 | 48 | {l10n("LinkExportSecretWarning")} 49 | 50 | ; 51 | } 52 | -------------------------------------------------------------------------------- /src/pages/export/QrExport.tsx: -------------------------------------------------------------------------------- 1 | import {CircularProgress, Stack, Typography} from "@mui/material"; 2 | import {StorageManagerContext} from "../../managers/storage/storage.tsx"; 3 | import {useContext, useEffect, useState} from "react"; 4 | import exportGoogleAuthenticator from "../../migration/export.ts"; 5 | import {useTheme} from "@mui/material/styles"; 6 | import {QRCode} from "react-qrcode-logo"; 7 | import {useNavigate} from "react-router-dom"; 8 | import useTelegramMainButton from "../../hooks/telegram/useTelegramMainButton.ts"; 9 | import {useL10n} from "../../hooks/useL10n.ts"; 10 | 11 | export default function QrExport() { 12 | const [qrContent, setQrContent] = useState(null); 13 | 14 | const storageManager = useContext(StorageManagerContext); 15 | const theme = useTheme(); 16 | useEffect(() => { 17 | if (!storageManager?.accounts || !storageManager.ready) return; 18 | 19 | const data = exportGoogleAuthenticator(storageManager.accounts); 20 | setQrContent("otpauth-migration://offline?data=" + encodeURIComponent(data)); 21 | }, [storageManager?.accounts, storageManager?.ready]); 22 | 23 | const l10n = useL10n(); 24 | const navigate = useNavigate(); 25 | useTelegramMainButton(() => { 26 | navigate(-1); 27 | return true; 28 | }, l10n("GoBackAction")); 29 | 30 | return 31 | 32 | {l10n("ExportAccountsTitle")} 33 | 34 | 36 | {qrContent === null ? : 37 | } 41 | 42 | 43 | {l10n("QRExportDescription")} 44 | 45 | ; 46 | } 47 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | declare const APP_VERSION: string; 3 | declare const APP_HOMEPAGE: string; 4 | 5 | interface ImportMetaEnv { 6 | readonly VITE_BOT_USERNAME: string; 7 | readonly VITE_APP_NAME: string; 8 | readonly VITE_CHANNEL_LINK: string; 9 | readonly VITE_TRANSLATE_LINK: string; 10 | readonly VITE_PLAUSIBLE_API_HOST: string; 11 | readonly VITE_PLAUSIBLE_DOMAIN: string; 12 | readonly BASE_URL: string; 13 | } 14 | 15 | interface ImportMeta { 16 | readonly env: ImportMetaEnv 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2021", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "useUnknownInCatchVariables": false 23 | }, 24 | "include": ["src"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts", "package.json"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { defineConfig } from "vite"; 4 | import react from "@vitejs/plugin-react"; 5 | import packageJson from "./package.json"; 6 | import svgr from "vite-plugin-svgr"; 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | plugins: [react(), svgr()], 11 | define: { 12 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access 13 | APP_VERSION: JSON.stringify(packageJson.version), 14 | APP_HOMEPAGE: JSON.stringify(packageJson.homepage), 15 | }, 16 | test: { 17 | globals: true, 18 | environment: "jsdom", 19 | }, 20 | }); 21 | --------------------------------------------------------------------------------