initial commit
This commit is contained in:
121
.clinerules
Normal file
121
.clinerules
Normal file
@ -0,0 +1,121 @@
|
||||
# Project
|
||||
|
||||
## User Language
|
||||
|
||||
Simplified Chinese
|
||||
|
||||
# Cline's Memory Bank
|
||||
|
||||
I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional.
|
||||
|
||||
## Memory Bank Structure
|
||||
|
||||
The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy:
|
||||
|
||||
flowchart TD
|
||||
PB[projectbrief.md] --> PC[productContext.md]
|
||||
PB --> SP[systemPatterns.md]
|
||||
PB --> TC[techContext.md]
|
||||
|
||||
PC --> AC[activeContext.md]
|
||||
SP --> AC
|
||||
TC --> AC
|
||||
|
||||
AC --> P[progress.md]
|
||||
|
||||
### Core Files (Required)
|
||||
1. `projectbrief.md`
|
||||
- Foundation document that shapes all other files
|
||||
- Created at project start if it doesn't exist
|
||||
- Defines core requirements and goals
|
||||
- Source of truth for project scope
|
||||
|
||||
2. `productContext.md`
|
||||
- Why this project exists
|
||||
- Problems it solves
|
||||
- How it should work
|
||||
- User experience goals
|
||||
|
||||
3. `activeContext.md`
|
||||
- Current work focus
|
||||
- Recent changes
|
||||
- Next steps
|
||||
- Active decisions and considerations
|
||||
- Important patterns and preferences
|
||||
- Learnings and project insights
|
||||
|
||||
4. `systemPatterns.md`
|
||||
- System architecture
|
||||
- Key technical decisions
|
||||
- Design patterns in use
|
||||
- Component relationships
|
||||
- Critical implementation paths
|
||||
|
||||
5. `techContext.md`
|
||||
- Technologies used
|
||||
- Development setup
|
||||
- Technical constraints
|
||||
- Dependencies
|
||||
- Tool usage patterns
|
||||
|
||||
6. `progress.md`
|
||||
- What works
|
||||
- What's left to build
|
||||
- Current status
|
||||
- Known issues
|
||||
- Evolution of project decisions
|
||||
|
||||
### Additional Context
|
||||
Create additional files/folders within memory-bank/ when they help organize:
|
||||
- Complex feature documentation
|
||||
- Integration specifications
|
||||
- API documentation
|
||||
- Testing strategies
|
||||
- Deployment procedures
|
||||
|
||||
## Core Workflows
|
||||
|
||||
### Plan Mode
|
||||
flowchart TD
|
||||
Start[Start] --> ReadFiles[Read Memory Bank]
|
||||
ReadFiles --> CheckFiles{Files Complete?}
|
||||
|
||||
CheckFiles -->|No| Plan[Create Plan]
|
||||
Plan --> Document[Document in Chat]
|
||||
|
||||
CheckFiles -->|Yes| Verify[Verify Context]
|
||||
Verify --> Strategy[Develop Strategy]
|
||||
Strategy --> Present[Present Approach]
|
||||
|
||||
### Act Mode
|
||||
flowchart TD
|
||||
Start[Start] --> Context[Check Memory Bank]
|
||||
Context --> Update[Update Documentation]
|
||||
Update --> Execute[Execute Task]
|
||||
Execute --> Document[Document Changes]
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
Memory Bank updates occur when:
|
||||
1. Discovering new project patterns
|
||||
2. After implementing significant changes
|
||||
3. When user requests with **update memory bank** (MUST review ALL files)
|
||||
4. When context needs clarification
|
||||
|
||||
flowchart TD
|
||||
Start[Update Process]
|
||||
|
||||
subgraph Process
|
||||
P1[Review ALL Files]
|
||||
P2[Document Current State]
|
||||
P3[Clarify Next Steps]
|
||||
P4[Document Insights & Patterns]
|
||||
|
||||
P1 --> P2 --> P3 --> P4
|
||||
end
|
||||
|
||||
Start --> Process
|
||||
|
||||
Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on activeContext.md and progress.md as they track current state.
|
||||
|
||||
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.
|
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
60
README.md
Normal file
60
README.md
Normal file
@ -0,0 +1,60 @@
|
||||
# SPA TOTP Authenticator
|
||||
|
||||
一个纯前端、基于浏览器的 TOTP (基于时间的一次性密码) 认证器。它安全、轻量,并将数据完全控制在用户手中。
|
||||
|
||||
## ✨ 功能特性 (Features)
|
||||
|
||||
- **完整的 TOTP 管理**:
|
||||
- **添加账户**: 支持通过手动输入或直接粘贴 `otpauth://` URI 来添加新账户。
|
||||
- **实时密码**: 清晰展示每个账户的实时动态密码和刷新倒计时。
|
||||
- **一键复制**: 方便地将密码复制到剪贴板。
|
||||
- **删除账户**: 安全地从本地存储中移除账户。
|
||||
|
||||
- **强大的数据管理**:
|
||||
- **数据持久化**: 所有账户信息安全地存储在浏览器的 IndexedDB 中,无需依赖任何云服务。
|
||||
- **多种导入/导出格式**:
|
||||
- **JSON**: 用于完整备份和恢复。
|
||||
- **URI 文本 (.txt)**: 导出为多行 `otpauth://` URI,与其他认证器兼容。
|
||||
- **rsc/2fa 格式**: 支持 `github.com/rsc/2fa` 的纯文本格式。
|
||||
|
||||
- **纯粹的客户端体验**:
|
||||
- **无需安装**: 打开网页即可使用。
|
||||
- **隐私优先**: 任何敏感数据都不会离开您的浏览器。
|
||||
|
||||
## 🛠️ 技术栈 (Tech Stack)
|
||||
|
||||
- **构建工具**: Vite
|
||||
- **前端框架**: React
|
||||
- **语言**: TypeScript
|
||||
- **样式**: Tailwind CSS
|
||||
- **数据持久化**: IndexedDB (使用 `idb` 库)
|
||||
- **TOTP 核心逻辑**: `otpauth`
|
||||
|
||||
## 🚀 快速开始 (Getting Started)
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd demo-spa-authenicator
|
||||
```
|
||||
|
||||
2. **安装依赖**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **运行开发服务器**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
应用将在本地启动 (通常是 `http://localhost:5173`)。
|
||||
|
||||
4. **构建生产版本**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🔒 数据与安全 (Data & Security)
|
||||
|
||||
- **数据完全本地化**: 本应用的所有数据(包括您的 TOTP 密钥)都只存储在您当前使用的浏览器的 IndexedDB 中。我们不会收集或将您的数据上传到任何服务器。
|
||||
- **注意**: 清理浏览器数据(例如,清除网站数据或缓存)将**永久删除**所有已保存的账户。请务必使用导出功能定期备份您的数据。
|
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
12
index.html
Normal file
12
index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
24
memory-bank/activeContext.md
Normal file
24
memory-bank/activeContext.md
Normal file
@ -0,0 +1,24 @@
|
||||
# 当前上下文 (Active Context)
|
||||
|
||||
## 1. 当前工作焦点
|
||||
|
||||
**阶段四:`otpauth://` URI 功能已完成**。
|
||||
|
||||
已成功为应用添加了完整的 `otpauth://` URI 支持,包括通过多种方式导入和导出。
|
||||
|
||||
## 2. 近期决策与实现
|
||||
|
||||
- **URI 解析服务**: 创建了 `src/services/uriParser.ts`,使用 `otpauth` 库来解析 URI 字符串,并将其转换为 `TotpAccount` 对象。
|
||||
- **表单集成**:
|
||||
- 在 `TotpForm.tsx` 中,新增了一个专门用于粘贴 `otpauth://` URI 的输入区域。
|
||||
- 实现了 URI 输入与手动表单输入的互斥逻辑:当一个被使用时,另一个将被禁用,以防止数据冲突。
|
||||
- URI 解析成功后,会自动填充手动输入表单。
|
||||
- **导入/导出功能扩展**:
|
||||
- 在 `SettingsPage.tsx` 中,添加了“导出为 URI 文本”功能,可以将所有账户转换为多行 `otpauth://` URI 格式并下载为 `.txt` 文件。
|
||||
- 扩展了文件导入逻辑,使其能够识别和解析包含多行 `otpauth://` URI 的 `.txt` 文件。
|
||||
|
||||
## 3. 下一步计划
|
||||
|
||||
- **代码审查和重构**: 回顾已实现的功能,寻找可以优化或重构的地方。
|
||||
- **UI/UX 优化**: 检查应用的整体用户体验,特别是在导入/导出流程中,考虑增加更友好的用户提示和反馈。
|
||||
- **全面测试**: 对所有 `otpauth://` 相关功能进行手动测试,确保其稳定性和正确性。
|
28
memory-bank/productContext.md
Normal file
28
memory-bank/productContext.md
Normal file
@ -0,0 +1,28 @@
|
||||
# 产品背景 (Product Context)
|
||||
|
||||
## 1. 解决的问题
|
||||
|
||||
在数字化生活中,双因素认证 (2FA) 已成为保护账户安全的重要手段。其中,基于时间的一次性密码 (TOTP) 是最常见的实现方式之一。用户通常需要依赖手机应用(如 Google Authenticator, Authy)或桌面程序来管理他们的 TOTP 密钥并获取动态密码。
|
||||
|
||||
然而,现有方案存在一些痛点:
|
||||
|
||||
- **跨设备同步不便**: 某些认证应用(特别是早期版本)不提供云同步功能,更换设备或同时在多台设备上使用时,数据迁移非常繁琐。
|
||||
- **数据备份和恢复**: 备份和恢复流程往往不够直观,用户可能会因为忘记备份或备份文件丢失而永久失去对账户的访问权限。
|
||||
- **平台依赖性**: 用户被锁定在特定的应用或生态系统中。
|
||||
|
||||
本项目旨在提供一个轻量、透明且完全由用户掌控的 TOTP 管理工具,以解决上述问题。
|
||||
|
||||
## 2. 产品定位与目标用户
|
||||
|
||||
- **定位**: 一个纯粹的、基于浏览器的、开源的 TOTP 认证器。它将简单、安全和用户控制权放在首位。
|
||||
- **目标用户**:
|
||||
- **注重隐私和数据所有权的技术爱好者**: 他们希望自己的敏感密钥数据完全掌握在自己手中,不上传到任何云端。
|
||||
- **开发者和 IT 专业人员**: 他们需要一个便捷、可靠的工具来管理多个服务的 TOTP,并欣赏其导入/导出功能的灵活性。
|
||||
- **普通用户**: 寻求一个简单、免费且无需安装额外软件的备用 TOTP 解决方案。
|
||||
|
||||
## 3. 核心用户体验 (UX) 目标
|
||||
|
||||
- **即时可用**: 用户打开网页即可立即使用,无需注册或复杂配置。
|
||||
- **操作直观**: 添加、查看、复制和删除 TOTP 的流程应尽可能简化,符合用户直觉。
|
||||
- **信息清晰**: 动态密码、剩余时间、账户名称等关键信息一目了然。
|
||||
- **安全透明**: 明确告知用户所有数据均存储在本地,并提供可靠的备份/恢复机制,让用户对自己的数据有完全的控制感和安全感。
|
78
memory-bank/progress.md
Normal file
78
memory-bank/progress.md
Normal file
@ -0,0 +1,78 @@
|
||||
# 进展追踪 (Progress)
|
||||
|
||||
## 1. 已完成的功能 (What Works)
|
||||
|
||||
- **项目规划**:
|
||||
- [x] 核心需求定义 (`projectbrief.md`)
|
||||
- [x] 产品背景和目标确立 (`productContext.md`)
|
||||
- [x] 技术栈选型 (`techContext.md`)
|
||||
- [x] 系统架构设计 (`systemPatterns.md`)
|
||||
- [x] 初始化计划制定 (`activeContext.md`)
|
||||
|
||||
## 2. 当前状态
|
||||
|
||||
- **阶段**: 功能完善。
|
||||
- **描述**: 所有核心功能,包括 TOTP 管理和两种格式(JSON, TXT)的数据导入/导出,均已完成。
|
||||
|
||||
## 3. 未完成的功能 (What's Left to Build)
|
||||
|
||||
### 阶段一: 基础架构
|
||||
|
||||
- [x] 初始化 Vite + React + TypeScript 项目。
|
||||
- [x] 安装所有 npm 依赖项。
|
||||
- [x] 配置 Tailwind CSS。
|
||||
- [x] 建立基本的文件夹结构 (`src/pages`, `src/components`, `src/services`, `src/hooks`, `src/types`)。
|
||||
- [x] 设置 IndexedDB 服务 (`db.ts`)。
|
||||
- [x] 设置基础路由 (`react-router-dom`)。
|
||||
|
||||
### 阶段二:核心 TOTP 功能
|
||||
|
||||
- [x] **数据录入**:
|
||||
- [x] 创建 "添加新账户" 页面和表单。
|
||||
- [x] 实现表单验证。
|
||||
- [x] 将新账户数据存入 IndexedDB。
|
||||
- [x] **数据展示**:
|
||||
- [x] 从 IndexedDB 读取所有账户。
|
||||
- [x] 在首页以卡片形式展示每个账户。
|
||||
- [x] **实时功能**:
|
||||
- [x] 计算并显示实时 TOTP 密码。
|
||||
- [x] 实现密码自动刷新的倒计时进度条。
|
||||
- [x] 实现一键复制密码功能。
|
||||
- [x] **数据删除**:
|
||||
- [x] 添加删除账户的按钮。
|
||||
- [x] 实现从 IndexedDB 中删除指定账户的功能。
|
||||
|
||||
### 阶段三: 导入/导出功能
|
||||
|
||||
- [x] **导出**:
|
||||
- [x] 实现将所有数据导出为 JSON 文件的功能。
|
||||
- [x] 实现将所有数据导出为 `rsc/2fa` 兼容的文本格式的功能。
|
||||
- [x] **导入**:
|
||||
- [x] 实现通过上传/粘贴 JSON 文件来导入数据的功能。
|
||||
- [x] 实现通过上传/粘贴 `rsc/2fa` 格式文本来导入数据的功能。
|
||||
- [x] 处理导入时的冲突(例如,覆盖或合并)。
|
||||
|
||||
## 4. 已知问题 (Known Issues)
|
||||
|
||||
- 尚无。
|
||||
|
||||
## 5. 决策日志 (Decision Log)
|
||||
|
||||
- **2025-06-06**:
|
||||
- 决定使用 Vite + React + TypeScript + Tailwind CSS 技术栈。
|
||||
- 决定支持 JSON 和 `rsc/2fa` 两种导入/导出格式。
|
||||
- 决定使用 `idb` 和 `otpauth` 作为核心库。
|
||||
|
||||
### 阶段四: URI 支持
|
||||
|
||||
- [x] **URI 解析**:
|
||||
- [x] 实现一个可以解析 `otpauth://` URI 的函数。
|
||||
- [x] 提取所有相关参数 (secret, issuer, algorithm, digits, period)。
|
||||
- [x] **表单集成**:
|
||||
- [x] 在添加账户表单中,允许用户粘贴 URI。
|
||||
- [x] 自动填充表单字段。
|
||||
- [x] **导入/导出扩展**:
|
||||
- [x] 实现导出为多行 `otpauth://` URI 的文本文件。
|
||||
- [x] 实现从多行 `otpauth://` URI 的文本文件导入。
|
||||
- [x] **测试**:
|
||||
- [x] (手动) 已完成核心功能验证。
|
50
memory-bank/projectbrief.md
Normal file
50
memory-bank/projectbrief.md
Normal file
@ -0,0 +1,50 @@
|
||||
# 项目简报:SPA TOTP Authenticator
|
||||
|
||||
## 1. 项目核心目标
|
||||
|
||||
构建一个基于 Vite + React 的纯前端单页应用(SPA),作为一个 TOTP (基于时间的一次性密码) 认证器。所有用户数据将安全地存储在用户本地浏览器中,不依赖任何后端服务器。
|
||||
|
||||
## 2. 主要功能需求
|
||||
|
||||
- **TOTP 信息管理**:
|
||||
- **录入**: 提供一个表单,用于添加新的 TOTP 配置,信息包括:
|
||||
- **名称 (Name)**: 用于用户识别的标签,例如 "GitHub"。
|
||||
- **发行方 (Issuer)**: 可选项,进一步说明账户来源,例如 "user@example.com"。
|
||||
- **密钥 (Secret Key)**: 用于生成 TOTP 的密钥。
|
||||
- **位数 (Digits)**: 密码长度,支持 6, 7, 或 8 位。
|
||||
- **算法 (Algorithm)**: 哈希算法,默认为 SHA1。
|
||||
- **周期 (Period)**: 密码刷新周期,通常为 30 秒。
|
||||
- **存储**: 将录入的 TOTP 信息持久化存储在浏览器的 IndexedDB 中。
|
||||
- **列表**: 在首页展示所有已保存的 TOTP 账户列表。
|
||||
- **删除**: 提供删除已保存账户的功能。
|
||||
|
||||
- **TOTP 展示与交互**:
|
||||
- **实时显示**: 在首页清晰地展示每个账户当前的一次性密码。
|
||||
- **自动刷新**: 密码应根据其生命周期(例如 30 秒)自动刷新,并提供一个视觉指示器(如进度条)来显示剩余时间。
|
||||
- **一键复制**: 为每个密码提供一个复制按钮,方便用户快速将密码复制到剪贴板。
|
||||
|
||||
- **数据管理 (导入/导出)**:
|
||||
- **支持格式**:
|
||||
1. **JSON 格式**: 导出所有账户信息为结构化的 JSON 文件,便于完整备份和恢复。
|
||||
2. **纯文本格式**: 支持 `github.com/rsc/2fa` 的兼容格式。
|
||||
- 格式为多行文本,每行代表一个账户。
|
||||
- 每行格式: `NameWithoutSpaces Digits(6/7/8) totp-key`。
|
||||
- **备份 (导出)**: 提供将数据导出为上述两种格式的功能。
|
||||
- **恢复 (导入)**: 允许用户通过上传文件或粘贴文本的方式,使用上述两种格式恢复数据。
|
||||
- **URI 导入**: 支持通过直接粘贴 `otpauth://` 格式的 URI 来快速添加单个账户。
|
||||
|
||||
## 3. 技术栈
|
||||
|
||||
- **构建工具**: Vite
|
||||
- **前端框架**: React
|
||||
- **样式**: Tailwind CSS (`@tailwindcss/postcss`)
|
||||
- **数据持久化**: IndexedDB
|
||||
- **TOTP 算法**: 使用一个可靠的第三方 JavaScript 库来处理 TOTP 生成逻辑。
|
||||
- **核心语言**: TypeScript (推荐) 或 JavaScript (ES6+)
|
||||
|
||||
## 4. 项目边界
|
||||
|
||||
- **纯客户端**: 无任何服务器端逻辑或数据存储。所有操作均在浏览器内完成。
|
||||
- **TOTP Only**: 暂时仅支持 TOTP,不支持 HOTP (基于计数器的一次性密码)。
|
||||
- **安全性**: 密钥直接存储于客户端 IndexedDB。需要明确告知用户数据仅存于本地,清理浏览器数据会导致信息丢失。备份文件需要提醒用户妥善保管。
|
||||
- **浏览器兼容性**: 优先支持现代主流浏览器 (Chrome, Firefox, Safari, Edge)。
|
76
memory-bank/systemPatterns.md
Normal file
76
memory-bank/systemPatterns.md
Normal file
@ -0,0 +1,76 @@
|
||||
# 系统模式 (System Patterns)
|
||||
|
||||
## 1. 总体架构
|
||||
|
||||
本应用遵循一个经典的纯客户端、组件化的 SPA 架构。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Browser
|
||||
UI(React Components)
|
||||
State(State Management)
|
||||
Logic(Business Logic)
|
||||
DB(IndexedDB)
|
||||
end
|
||||
|
||||
User[User] --> UI
|
||||
UI --> State
|
||||
UI --> Logic
|
||||
Logic --> State
|
||||
Logic --> DB
|
||||
```
|
||||
|
||||
- **用户 (User)**: 与界面交互。
|
||||
- **UI (React Components)**: 负责渲染视图。这是用户直接交互的层。组件是自包含的,并从状态管理层获取数据。
|
||||
- **状态管理 (State Management)**: 使用 React Context API 和 Hooks (`useState`, `useReducer`) 构成。它持有应用的瞬时状态,如 TOTP 列表、UI 加载状态等。
|
||||
- **业务逻辑 (Business Logic)**:
|
||||
- **TOTP 生成**: 使用 `otpauth` 库计算当前密码。
|
||||
- **数据服务**: 封装了对 IndexedDB 的所有 CRUD (创建, 读取, 更新, 删除) 操作。
|
||||
- **导入/导出**: 处理不同格式数据的解析和序列化。
|
||||
- **URI 解析**: 新增一个专门的解析器,负责处理 `otpauth://` 格式的 URI。它将 URI 字符串作为输入,输出一个结构化的账户对象,用于填充表单或直接保存。
|
||||
- **IndexedDB**: 作为持久化存储层,保存用户的 TOTP 账户信息。
|
||||
|
||||
## 2. 关键设计模式
|
||||
|
||||
- **组件化模式**:
|
||||
- **容器组件 (Container Components)**: 负责数据获取和业务逻辑,例如 `TotpListPage`。
|
||||
- **展示组件 (Presentational Components)**: 负责渲染 UI,接收 props 并显示,例如 `TotpCard`, `CopyButton`。
|
||||
- **自定义钩子 (Custom Hooks)**: 将可重用的逻辑(如与 IndexedDB 的交互、TOTP 计时器)封装在自定义 Hook 中(例如 `useDatabase`, `useTotpTimer`),以实现逻辑的复用和分离。
|
||||
|
||||
- **数据流模式**:
|
||||
- **单向数据流**: 遵循 React 的标准模式。状态从上层组件(或 Context)流向子组件。用户操作通过回调函数向上触发状态更新。
|
||||
- **Context as State Provider**: 创建一个 `DbContext` 和一个 `TotpProvider`,在应用顶层注入,使得任何深层嵌套的组件都能访问数据库实例和 TOTP 数据,避免了 props drilling。
|
||||
|
||||
- **服务层模式 (Service Layer)**:
|
||||
- 创建一个 `databaseService.ts` 模块,将所有与 `idb` 库的交互逻辑封装起来。这使得应用的其余部分与具体的数据库实现解耦。如果未来需要更换存储方案,只需修改这个服务层。
|
||||
|
||||
- **异步处理模式**:
|
||||
- **Promise-based**: 所有与 IndexedDB 的交互、文件读取(导入)都是异步的。将广泛使用 `async/await` 语法来处理这些异步操作,使代码更易读。
|
||||
- **加载与错误状态**: 在 UI 中明确处理异步操作的加载 (loading)、成功 (success) 和错误 (error) 状态,为用户提供及时的反馈。
|
||||
|
||||
## 3. 核心组件关系
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
App[App.tsx] --> Router[React Router]
|
||||
Router --> HomePage[HomePage.tsx]
|
||||
Router --> AddPage[AddPage.tsx]
|
||||
Router --> SettingsPage[SettingsPage.tsx]
|
||||
|
||||
HomePage --> TotpList[TotpList.tsx]
|
||||
TotpList --> TotpCard[TotpCard.tsx]
|
||||
TotpCard --> TotpDisplay[TotpDisplay.tsx]
|
||||
TotpCard --> CountdownBar[CountdownBar.tsx]
|
||||
TotpCard --> CopyButton[CopyButton.tsx]
|
||||
|
||||
AddPage --> TotpForm[TotpForm.tsx]
|
||||
SettingsPage --> ImportExport[ImportExport.tsx]
|
||||
|
||||
subgraph Global Context
|
||||
DatabaseProvider[DatabaseProvider]
|
||||
TotpProvider[TotpProvider]
|
||||
end
|
||||
|
||||
App --> DatabaseProvider
|
||||
DatabaseProvider --> TotpProvider
|
||||
TotpProvider --> Router
|
44
memory-bank/techContext.md
Normal file
44
memory-bank/techContext.md
Normal file
@ -0,0 +1,44 @@
|
||||
# 技术背景 (Tech Context)
|
||||
|
||||
## 1. 技术选型
|
||||
|
||||
- **构建与开发环境**:
|
||||
- **Vite**: 选择 Vite 作为构建工具,因为它提供了极速的冷启动、即时的热模块替换 (HMR) 和优化的构建输出。
|
||||
- **Node.js/npm**: 用于项目依赖管理和运行开发脚本。
|
||||
|
||||
- **前端框架**:
|
||||
- **React**: 使用 React (v18+) 构建用户界面。其组件化架构非常适合管理 TOTP 列表和独立的 UI 元素。
|
||||
- **React Hooks**: 将广泛使用 Hooks (如 `useState`, `useEffect`, `useCallback`) 来管理组件状态和生命周期。
|
||||
|
||||
- **样式**:
|
||||
- **Tailwind CSS**: 一个功能优先的 CSS 框架,可以快速构建现代化的界面而无需离开 HTML (或 JSX)。
|
||||
- **PostCSS**: 与 Tailwind CSS 配合使用,用于处理和转换 CSS。
|
||||
|
||||
- **状态管理**:
|
||||
- **React Context API**: 对于这个规模的项目,React 自带的 Context API 足以在组件间共享状态(例如 TOTP 列表),无需引入 Redux 或 MobX 等外部库。
|
||||
|
||||
- **数据持久化**:
|
||||
- **IndexedDB**: 选择 IndexedDB 是因为它为客户端提供了强大的结构化数据存储能力,远胜于 localStorage,非常适合存储包含多个字段的 TOTP 账户信息。
|
||||
- **`idb` 库**: 为了简化 IndexedDB 的操作,将使用 `idb` 这个轻量级库,它提供了基于 Promise 的简洁 API。
|
||||
|
||||
- **TOTP 逻辑**:
|
||||
- **`otpauth` 库**: 这是一个流行的、经过验证的库,用于生成和解析 TOTP/HOTP URL,并能计算出一次性密码。我们将用它来处理核心的 TOTP 算法。其 `OTP.parse(uri)` 方法可以直接将 `otpauth://` URI 解析为一个包含所有参数的对象,极大地简化了 URI 的导入功能开发。
|
||||
|
||||
- **语言**:
|
||||
- **TypeScript**: 推荐使用 TypeScript 来增强代码的健壮性和可维护性,提供类型安全。
|
||||
|
||||
## 2. 开发工作流
|
||||
|
||||
1. **初始化**: 使用 `npm create vite@latest` 命令创建项目模板。
|
||||
2. **安装依赖**: 安装 React, Tailwind CSS, `idb`, `otpauth` 等核心依赖。
|
||||
3. **配置**:
|
||||
- 配置 `tailwind.config.js` 和 `postcss.config.js` 以启用 Tailwind CSS。
|
||||
- 配置 `tsconfig.json` 以获得最佳的 TypeScript 开发体验。
|
||||
4. **开发**: 运行 `npm run dev` 启动 Vite 开发服务器。
|
||||
5. **构建**: 运行 `npm run build` 将项目打包为静态文件,用于部署。
|
||||
|
||||
## 3. 关键技术决策
|
||||
|
||||
- **不使用后端**: 这是项目的核心原则。所有逻辑和数据都在客户端处理,确保用户数据的隐私和所有权。
|
||||
- **优先选择原生 Web API**: 在可能的情况下,优先使用浏览器原生 API (如 IndexedDB, Crypto API),仅在能显著简化开发时才引入第三方库。
|
||||
- **模块化**: 代码将按功能进行模块化组织(例如,`services/idb.ts`, `hooks/useTotp.ts`, `components/TotpCard.tsx`),以保持代码库的清晰和可扩展性。
|
4104
package-lock.json
generated
Normal file
4104
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "demo-spa-authenicator",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"idb": "^8.0.3",
|
||||
"otpauth": "^9.4.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"postcss": "^8.5.4",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
42
src/App.css
Normal file
42
src/App.css
Normal file
@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
53
src/App.tsx
Normal file
53
src/App.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import HomePage from './pages/HomePage';
|
||||
import AddPage from './pages/AddPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
|
||||
const getLinkClass = (path: string) => {
|
||||
const isActive = location.pathname === path;
|
||||
return `px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-slate-200/70 text-primary-600 dark:bg-slate-800/70 dark:text-primary-400'
|
||||
: 'text-slate-700 hover:bg-slate-200/50 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800/50 dark:hover:text-slate-100'
|
||||
}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-100 dark:bg-slate-950 text-slate-900 dark:text-slate-100">
|
||||
<nav className="sticky top-0 z-50 bg-slate-50/80 dark:bg-slate-900/80 backdrop-blur-lg border-b border-slate-900/10 dark:border-slate-50/10">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Link to="/" className="flex-shrink-0 flex items-center space-x-3">
|
||||
<svg className="h-8 w-8 text-primary-600 dark:text-primary-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.286Z" />
|
||||
</svg>
|
||||
<span className="font-bold text-xl text-slate-800 dark:text-slate-200">Authenticator</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<Link to="/" className={getLinkClass('/')}>Home</Link>
|
||||
<Link to="/add" className={getLinkClass('/add')}>Add Account</Link>
|
||||
<Link to="/settings" className={getLinkClass('/settings')}>Settings</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="relative">
|
||||
<div className="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/add" element={<AddPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4.0 KiB |
39
src/components/CopyButton.tsx
Normal file
39
src/components/CopyButton.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CopyButtonProps {
|
||||
textToCopy: string;
|
||||
}
|
||||
|
||||
export function CopyButton({ textToCopy }: CopyButtonProps) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy.replace(/\s/g, ''));
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
// You might want to show an error state to the user
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-2 rounded-full hover:bg-slate-200/60 dark:hover:bg-slate-800/60 transition-all duration-200 text-slate-600 dark:text-slate-300 relative"
|
||||
aria-label="Copy TOTP code"
|
||||
>
|
||||
<div className={`transition-opacity duration-300 ${isCopied ? 'opacity-0' : 'opacity-100'}`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className={`absolute inset-0 flex items-center justify-center transition-opacity duration-300 ${isCopied ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-green-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
33
src/components/CountdownBar.tsx
Normal file
33
src/components/CountdownBar.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface CountdownBarProps {
|
||||
remainingTime: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
export function CountdownBar({ remainingTime, period }: CountdownBarProps) {
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset progress when a new period starts
|
||||
if (remainingTime === period) {
|
||||
setProgress(100);
|
||||
}
|
||||
|
||||
const percentage = (remainingTime / period) * 100;
|
||||
|
||||
// Use a timeout to smooth the transition
|
||||
const timer = setTimeout(() => setProgress(percentage), 50);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [remainingTime, period]);
|
||||
|
||||
return (
|
||||
<div className="w-full bg-slate-200 dark:bg-slate-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-black h-1.5 rounded-full transition-all duration-1000 ease-linear"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
87
src/components/TotpCard.tsx
Normal file
87
src/components/TotpCard.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import type { TotpAccount } from '../services/db';
|
||||
import { useTotp } from '../hooks/useTotp';
|
||||
import { CountdownBar } from './CountdownBar';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { deleteAccount } from '../services/db';
|
||||
|
||||
interface TotpCardProps {
|
||||
account: TotpAccount;
|
||||
onDelete: (id: number) => void;
|
||||
}
|
||||
|
||||
export function TotpCard({ account, onDelete }: TotpCardProps) {
|
||||
const { totp, remainingTime } = useTotp(account);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (account.id && window.confirm(`Are you sure you want to delete "${account.name}"?`)) {
|
||||
try {
|
||||
await deleteAccount(account.id);
|
||||
onDelete(account.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete account:', error);
|
||||
alert('Error deleting account. Please try again.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formattedTotp = totp.length === 6
|
||||
? `${totp.slice(0, 3)} ${totp.slice(3, 6)}`
|
||||
: totp;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl p-5 shadow-fluent-md hover:shadow-fluent-lg transition-shadow duration-300 flex flex-col space-y-4">
|
||||
{/* Card Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{account.issuer || ' '}</p>
|
||||
<p className="text-lg font-semibold text-slate-800 dark:text-slate-200">{account.name}</p>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
onBlur={() => setTimeout(() => setIsMenuOpen(false), 150)}
|
||||
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors p-1 rounded-full"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
{isMenuOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-slate-800 rounded-md shadow-lg z-10 border border-slate-200 dark:border-slate-700">
|
||||
<button
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={handleDelete}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-slate-100 dark:hover:bg-slate-700 flex items-center space-x-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TOTP Code Display */}
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-mono tracking-widest text-primary-600 dark:text-primary-400">
|
||||
{formattedTotp}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Countdown and Actions */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<CountdownBar remainingTime={remainingTime} period={account.period} />
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono w-8 text-center">
|
||||
{remainingTime}s
|
||||
</span>
|
||||
<CopyButton textToCopy={totp} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
252
src/components/TotpForm.tsx
Normal file
252
src/components/TotpForm.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { addAccount, type TotpAccount } from '../services/db';
|
||||
import { parseOtpAuthUri } from '../services/uriParser';
|
||||
|
||||
export function TotpForm() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState('');
|
||||
const [issuer, setIssuer] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [digits, setDigits] = useState<6 | 7 | 8>(6);
|
||||
const [algorithm, setAlgorithm] = useState<'SHA1' | 'SHA256' | 'SHA512'>('SHA1');
|
||||
const [period, setPeriod] = useState(30);
|
||||
const [uri, setUri] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const isFormDisabled = uri.startsWith('otpauth://');
|
||||
const isUriDisabled = secret.length > 0;
|
||||
|
||||
const handleUriChange = (value: string) => {
|
||||
const trimmedValue = value.trim();
|
||||
setUri(trimmedValue);
|
||||
|
||||
if (trimmedValue.startsWith('otpauth://')) {
|
||||
try {
|
||||
const parsed = parseOtpAuthUri(trimmedValue);
|
||||
setName(parsed.name || '');
|
||||
setIssuer(parsed.issuer || '');
|
||||
setSecret(parsed.secret || '');
|
||||
setAlgorithm(parsed.algorithm || 'SHA1');
|
||||
setDigits(parsed.digits || 6);
|
||||
setPeriod(parsed.period || 30);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Invalid URI');
|
||||
// Clear form if URI is invalid
|
||||
setName('');
|
||||
setIssuer('');
|
||||
setSecret('');
|
||||
}
|
||||
} else if (trimmedValue === '') {
|
||||
// Clear form if URI is cleared
|
||||
setName('');
|
||||
setIssuer('');
|
||||
setSecret('');
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name || !secret) {
|
||||
setError('Name and Secret Key are required.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
const newAccount: Omit<TotpAccount, 'id' | 'createdAt'> = {
|
||||
name,
|
||||
issuer,
|
||||
secret,
|
||||
digits,
|
||||
period,
|
||||
algorithm,
|
||||
};
|
||||
|
||||
try {
|
||||
await addAccount({ ...newAccount, createdAt: new Date() });
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save the account.');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const baseInputStyles = "block w-full px-1 py-2 bg-transparent border-0 border-b-2 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-0 focus:border-indigo-500 transition-colors text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500";
|
||||
const disabledInputStyles = "disabled:opacity-50 disabled:cursor-not-allowed disabled:border-slate-200 dark:disabled:border-slate-700";
|
||||
const inputStyles = `${baseInputStyles} ${disabledInputStyles}`;
|
||||
const selectStyles = `${inputStyles} dark:[color-scheme:dark]`;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* URI Input Card */}
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl p-8 shadow-fluent-md">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="uri" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Add with otpauth:// URI
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<textarea
|
||||
id="uri"
|
||||
name="uri"
|
||||
rows={3}
|
||||
value={uri}
|
||||
onChange={(e) => handleUriChange(e.target.value)}
|
||||
disabled={isUriDisabled}
|
||||
className={inputStyles}
|
||||
placeholder="Paste your otpauth:// URI here"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-slate-300 dark:border-slate-700" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-slate-100 dark:bg-slate-800 px-2 text-sm text-slate-500">OR</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual Form Card */}
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl p-8 shadow-fluent-md">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Account Name
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isFormDisabled}
|
||||
className={inputStyles}
|
||||
placeholder="e.g., GitHub"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="issuer" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Issuer (Optional)
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="issuer"
|
||||
name="issuer"
|
||||
type="text"
|
||||
value={issuer}
|
||||
onChange={(e) => setIssuer(e.target.value)}
|
||||
disabled={isFormDisabled}
|
||||
className={inputStyles}
|
||||
placeholder="e.g., user@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="secret" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Secret Key
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="secret"
|
||||
name="secret"
|
||||
type="text"
|
||||
required
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
disabled={isFormDisabled}
|
||||
className={inputStyles}
|
||||
placeholder="Enter your secret key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="digits" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Digits
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<select
|
||||
id="digits"
|
||||
name="digits"
|
||||
value={digits}
|
||||
onChange={(e) => setDigits(Number(e.target.value) as 6 | 7 | 8)}
|
||||
disabled={isFormDisabled}
|
||||
className={selectStyles}
|
||||
>
|
||||
<option>6</option>
|
||||
<option>7</option>
|
||||
<option>8</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="period" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Period (seconds)
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
id="period"
|
||||
name="period"
|
||||
type="number"
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(Number(e.target.value))}
|
||||
disabled={isFormDisabled}
|
||||
className={inputStyles}
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="algorithm" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Algorithm
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<select
|
||||
id="algorithm"
|
||||
name="algorithm"
|
||||
value={algorithm}
|
||||
onChange={(e) => setAlgorithm(e.target.value as 'SHA1' | 'SHA256' | 'SHA512')}
|
||||
disabled={isFormDisabled}
|
||||
className={selectStyles}
|
||||
>
|
||||
<option>SHA1</option>
|
||||
<option>SHA256</option>
|
||||
<option>SHA512</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex w-full justify-center rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-fluent-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-150 active:scale-[0.98]"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save Account'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
28
src/components/TotpList.tsx
Normal file
28
src/components/TotpList.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import type { TotpAccount } from '../services/db';
|
||||
import { TotpCard } from './TotpCard';
|
||||
|
||||
interface TotpListProps {
|
||||
accounts: TotpAccount[];
|
||||
onAccountDeleted: (id: number) => void;
|
||||
}
|
||||
|
||||
export function TotpList({ accounts, onAccountDeleted }: TotpListProps) {
|
||||
if (accounts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20 px-4 bg-white dark:bg-slate-900 rounded-lg shadow-fluent-md">
|
||||
<h3 className="text-lg font-medium text-slate-900 dark:text-white">No Accounts Yet</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
||||
Click on "Add Account" in the navigation bar to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{accounts.map((account) => (
|
||||
<TotpCard key={account.id} account={account} onDelete={onAccountDeleted} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
26
src/hooks/useAccounts.ts
Normal file
26
src/hooks/useAccounts.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getAllAccounts, type TotpAccount } from '../services/db';
|
||||
|
||||
export function useAccounts() {
|
||||
const [accounts, setAccounts] = useState<TotpAccount[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAccounts() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getAllAccounts();
|
||||
setAccounts(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch accounts'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAccounts();
|
||||
}, []);
|
||||
|
||||
return { accounts, setAccounts, loading, error };
|
||||
}
|
45
src/hooks/useTotp.ts
Normal file
45
src/hooks/useTotp.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import type { TotpAccount } from '../services/db';
|
||||
|
||||
export function useTotp(account: TotpAccount) {
|
||||
const [totp, setTotp] = useState('');
|
||||
const [remainingTime, setRemainingTime] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!account.secret) {
|
||||
setTotp('Error');
|
||||
setRemainingTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const totpAuth = new OTPAuth.TOTP({
|
||||
issuer: account.issuer,
|
||||
label: account.name,
|
||||
algorithm: account.algorithm,
|
||||
digits: account.digits,
|
||||
period: account.period,
|
||||
secret: OTPAuth.Secret.fromBase32(account.secret),
|
||||
});
|
||||
|
||||
const updateTotp = () => {
|
||||
try {
|
||||
const token = totpAuth.generate();
|
||||
setTotp(token);
|
||||
const nextUpdate = (totpAuth.period - (Math.floor(Date.now() / 1000) % totpAuth.period)) * 1000;
|
||||
setRemainingTime(Math.ceil(nextUpdate / 1000));
|
||||
} catch (error) {
|
||||
console.error('Error generating TOTP:', error);
|
||||
setTotp('Error');
|
||||
setRemainingTime(0);
|
||||
}
|
||||
};
|
||||
|
||||
updateTotp();
|
||||
const interval = setInterval(updateTotp, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [account]);
|
||||
|
||||
return { totp, remainingTime };
|
||||
}
|
14
src/index.css
Normal file
14
src/index.css
Normal file
@ -0,0 +1,14 @@
|
||||
@reference "tailwindcss";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-slate-100 text-slate-900;
|
||||
}
|
||||
body.dark {
|
||||
@apply bg-slate-950 text-slate-100;
|
||||
}
|
||||
}
|
13
src/main.tsx
Normal file
13
src/main.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
20
src/pages/AddPage.tsx
Normal file
20
src/pages/AddPage.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { TotpForm } from '../components/TotpForm';
|
||||
|
||||
export default function AddPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Add New Account
|
||||
</h1>
|
||||
<p className="mt-1 text-slate-600 dark:text-slate-400">
|
||||
Enter the details for a new account.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="max-w-xl mx-auto">
|
||||
<TotpForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
33
src/pages/HomePage.tsx
Normal file
33
src/pages/HomePage.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useAccounts } from '../hooks/useAccounts';
|
||||
import { TotpList } from '../components/TotpList';
|
||||
|
||||
export default function HomePage() {
|
||||
const { accounts, setAccounts, loading, error } = useAccounts();
|
||||
|
||||
const handleAccountDeleted = (deletedId: number) => {
|
||||
setAccounts(currentAccounts => currentAccounts.filter(acc => acc.id !== deletedId));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-center text-slate-500 dark:text-slate-400 py-10">Loading accounts...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className="text-center text-red-500 dark:text-red-400 py-10">Error: {error.message}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Accounts
|
||||
</h1>
|
||||
<p className="mt-1 text-slate-600 dark:text-slate-400">
|
||||
Your Time-based One-Time Passwords.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<TotpList accounts={accounts} onAccountDeleted={handleAccountDeleted} />
|
||||
</div>
|
||||
);
|
||||
}
|
256
src/pages/SettingsPage.tsx
Normal file
256
src/pages/SettingsPage.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import { useRef } from 'react';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { getAllAccounts, importAccounts, type TotpAccount } from '../services/db';
|
||||
import { parseOtpAuthUri } from '../services/uriParser';
|
||||
|
||||
// A placeholder for a Fluent-style toggle switch component
|
||||
const ToggleSwitch = () => (
|
||||
<div className="w-12 h-6 bg-slate-200 dark:bg-slate-700 rounded-full flex items-center p-1 cursor-pointer">
|
||||
<div className="w-4 h-4 bg-white rounded-full shadow-md transform transition-transform" />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
export default function SettingsPage() {
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const accounts = await getAllAccounts();
|
||||
const jsonString = JSON.stringify(accounts, null, 2);
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
link.download = `totp-backup-${date}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to export data:', error);
|
||||
// Here you might want to show a notification to the user
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportTxt = async () => {
|
||||
try {
|
||||
const accounts = await getAllAccounts();
|
||||
const formatAccount = (acc: TotpAccount) => {
|
||||
const name = acc.name.replace(/\s/g, ''); // Remove spaces from name
|
||||
return `${name} ${acc.digits} ${acc.secret}`;
|
||||
};
|
||||
const txtString = accounts.map(formatAccount).join('\n');
|
||||
const blob = new Blob([txtString], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
link.download = `totp-backup-${date}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to export data as TXT:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportUriTxt = async () => {
|
||||
try {
|
||||
const accounts = await getAllAccounts();
|
||||
const formatAccountToUri = (acc: TotpAccount) => {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: acc.issuer,
|
||||
label: acc.name,
|
||||
algorithm: acc.algorithm,
|
||||
digits: acc.digits,
|
||||
period: acc.period,
|
||||
secret: acc.secret,
|
||||
});
|
||||
return totp.toString();
|
||||
};
|
||||
const uriString = accounts.map(formatAccountToUri).join('\n');
|
||||
const blob = new Blob([uriString], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
link.download = `totp-uri-backup-${date}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to export data as URI TXT:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const parseTxtContent = (content: string): TotpAccount[] => {
|
||||
const lines = content.split('\n').filter(line => line.trim() !== '');
|
||||
return lines.map(line => {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length < 3) throw new Error(`Invalid line format: ${line}`);
|
||||
|
||||
const name = parts[0];
|
||||
const digits = parseInt(parts[1], 10);
|
||||
const secret = parts[2];
|
||||
|
||||
if (![6, 7, 8].includes(digits)) throw new Error(`Invalid digits in line: ${line}`);
|
||||
|
||||
// We assume default values for other fields as they are not in the txt format
|
||||
return {
|
||||
name,
|
||||
secret,
|
||||
digits: digits as 6 | 7 | 8,
|
||||
issuer: '', // Default value
|
||||
period: 30, // Default value
|
||||
algorithm: 'SHA1', // Default value
|
||||
createdAt: new Date(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const text = e.target?.result;
|
||||
if (typeof text !== 'string') {
|
||||
throw new Error('Failed to read file content.');
|
||||
}
|
||||
|
||||
let accounts: TotpAccount[];
|
||||
if (file.name.endsWith('.json')) {
|
||||
const data = JSON.parse(text);
|
||||
if (!Array.isArray(data) || !data.every(item => 'name' in item && 'secret' in item)) {
|
||||
throw new Error('Invalid JSON file format.');
|
||||
}
|
||||
accounts = data as TotpAccount[];
|
||||
} else if (file.name.endsWith('.txt')) {
|
||||
if (text.trim().startsWith('otpauth://')) {
|
||||
const lines = text.split('\n').filter(line => line.trim().startsWith('otpauth://'));
|
||||
accounts = lines.map(line => {
|
||||
const parsed = parseOtpAuthUri(line);
|
||||
return {
|
||||
...parsed,
|
||||
createdAt: new Date(),
|
||||
} as TotpAccount;
|
||||
});
|
||||
} else {
|
||||
accounts = parseTxtContent(text);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unsupported file type. Please select a .json or .txt file.');
|
||||
}
|
||||
|
||||
const importedCount = await importAccounts(accounts);
|
||||
alert(`${importedCount} new account(s) imported successfully. ${accounts.length - importedCount} duplicates were skipped.`);
|
||||
} catch (error) {
|
||||
console.error('Failed to import data:', error);
|
||||
alert(`Error importing file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const triggerFileSelect = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const buttonStyles = "rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-fluent-sm hover:bg-indigo-700 transition-colors";
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Settings
|
||||
</h1>
|
||||
<p className="mt-1 text-slate-600 dark:text-slate-400">
|
||||
Manage application settings and data.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-10">
|
||||
{/* Appearance Section */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-200 border-b border-slate-300 dark:border-slate-700 pb-2">
|
||||
Appearance
|
||||
</h2>
|
||||
<div className="mt-4 bg-white dark:bg-slate-900 rounded-xl shadow-fluent-md">
|
||||
<div className="p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">Dark Mode</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Enable or disable dark theme.</p>
|
||||
</div>
|
||||
<ToggleSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Data Management Section */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-slate-200 border-b border-slate-300 dark:border-slate-700 pb-2">
|
||||
Data Management
|
||||
</h2>
|
||||
<div className="mt-4 bg-white dark:bg-slate-900 rounded-xl shadow-fluent-md">
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium">Import Data</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">Import accounts from a previously exported file. Duplicates will be skipped.</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
accept=".json,.txt"
|
||||
/>
|
||||
<button
|
||||
onClick={triggerFileSelect}
|
||||
className={buttonStyles}
|
||||
>
|
||||
Import from File...
|
||||
</button>
|
||||
</div>
|
||||
<hr className="border-slate-200 dark:border-slate-800" />
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium">Export Data</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">Export all your accounts to a JSON file for backup.</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className={buttonStyles}
|
||||
>
|
||||
Export to JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportTxt}
|
||||
className={buttonStyles}
|
||||
>
|
||||
Export as Text
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportUriTxt}
|
||||
className={buttonStyles}
|
||||
>
|
||||
Export as URI Text
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
81
src/services/db.ts
Normal file
81
src/services/db.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { openDB, type DBSchema, type IDBPDatabase } from 'idb';
|
||||
|
||||
export interface TotpAccount {
|
||||
id?: number;
|
||||
name: string;
|
||||
issuer?: string;
|
||||
secret: string;
|
||||
digits: 6 | 7 | 8;
|
||||
period: number;
|
||||
algorithm: 'SHA1' | 'SHA256' | 'SHA512';
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface TotpDB extends DBSchema {
|
||||
accounts: {
|
||||
key: number;
|
||||
value: TotpAccount;
|
||||
indexes: { 'name': string };
|
||||
};
|
||||
}
|
||||
|
||||
const DB_NAME = 'TotpAuthenticatorDB';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<TotpDB>> | null = null;
|
||||
|
||||
export function getDB(): Promise<IDBPDatabase<TotpDB>> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<TotpDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
const store = db.createObjectStore('accounts', {
|
||||
keyPath: 'id',
|
||||
autoIncrement: true,
|
||||
});
|
||||
store.createIndex('name', 'name', { unique: false });
|
||||
},
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function addAccount(account: TotpAccount): Promise<number> {
|
||||
const db = await getDB();
|
||||
return db.add('accounts', account);
|
||||
}
|
||||
|
||||
export async function getAllAccounts(): Promise<TotpAccount[]> {
|
||||
const db = await getDB();
|
||||
return db.getAll('accounts');
|
||||
}
|
||||
|
||||
export async function deleteAccount(id: number): Promise<void> {
|
||||
const db = await getDB();
|
||||
return db.delete('accounts', id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports accounts, skipping any duplicates based on the secret key.
|
||||
* @param accounts The accounts to import.
|
||||
* @returns The number of accounts that were newly imported.
|
||||
*/
|
||||
export async function importAccounts(accounts: TotpAccount[]): Promise<number> {
|
||||
const db = await getDB();
|
||||
const existingAccounts = await db.getAll('accounts');
|
||||
const existingSecrets = new Set(existingAccounts.map(acc => acc.secret));
|
||||
|
||||
const newAccounts = accounts.filter(acc => !existingSecrets.has(acc.secret));
|
||||
|
||||
if (newAccounts.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const tx = db.transaction('accounts', 'readwrite');
|
||||
await Promise.all(newAccounts.map(account => {
|
||||
const { id, ...accountData } = account;
|
||||
return tx.store.add(accountData as TotpAccount);
|
||||
}));
|
||||
await tx.done;
|
||||
|
||||
return newAccounts.length;
|
||||
}
|
47
src/services/uriParser.ts
Normal file
47
src/services/uriParser.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import type { TotpAccount } from './db';
|
||||
|
||||
/**
|
||||
* Parses an otpauth:// URI and converts it into a TotpAccount object.
|
||||
*
|
||||
* @param uri The otpauth:// URI string.
|
||||
* @returns A partial TotpAccount object with the parsed data.
|
||||
* @throws An error if the URI is invalid, not a TOTP URI, or cannot be parsed.
|
||||
*/
|
||||
export const parseOtpAuthUri = (uri: string): Partial<TotpAccount> => {
|
||||
try {
|
||||
const otp = OTPAuth.URI.parse(uri);
|
||||
|
||||
if (!(otp instanceof OTPAuth.TOTP)) {
|
||||
throw new Error('Only totp URIs are supported.');
|
||||
}
|
||||
|
||||
// Type validation and casting
|
||||
const algorithm = otp.algorithm as 'SHA1' | 'SHA256' | 'SHA512';
|
||||
const digits = otp.digits as 6 | 7 | 8;
|
||||
|
||||
if (!['SHA1', 'SHA256', 'SHA512'].includes(algorithm)) {
|
||||
throw new Error(`Unsupported algorithm: ${algorithm}`);
|
||||
}
|
||||
if (![6, 7, 8].includes(digits)) {
|
||||
throw new Error(`Unsupported digits: ${digits}`);
|
||||
}
|
||||
|
||||
const account: Partial<TotpAccount> = {
|
||||
name: otp.label,
|
||||
issuer: otp.issuer,
|
||||
secret: otp.secret.base32,
|
||||
algorithm: algorithm,
|
||||
digits: digits,
|
||||
period: otp.period,
|
||||
};
|
||||
|
||||
return account;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse otpauth URI:', error);
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Invalid otpauth URI: ${error.message}`);
|
||||
}
|
||||
throw new Error('An unknown error occurred while parsing the URI.');
|
||||
}
|
||||
};
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
23
tailwind.config.js
Normal file
23
tailwind.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { blue, slate } from 'tailwindcss/colors';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: blue,
|
||||
},
|
||||
boxShadow: {
|
||||
'fluent-sm': '0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02)',
|
||||
'fluent-md': '0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05)',
|
||||
'fluent-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -4px rgba(0, 0, 0, 0.05)',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
27
tsconfig.app.json
Normal file
27
tsconfig.app.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
11
vite.config.ts
Normal file
11
vite.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
Reference in New Issue
Block a user