feat: enhance visitor mode

This commit is contained in:
Peifan Li
2026-01-03 22:07:04 -05:00
parent 76d4269164
commit 13de853a54
20 changed files with 1828 additions and 355 deletions

236
REFACTOR_PLAN.md Normal file
View File

@@ -0,0 +1,236 @@
# VisitorMode 重构计划
## 概述
`visitorMode` 设置项已被标记为 deprecated。权限控制应完全基于 `userRole`'admin' | 'visitor'),而不是依赖 settings 中的 `visitorMode` 标志。
## 当前架构分析
### 当前逻辑
1. **Settings 层面**
- `visitorMode: boolean` - 控制是否启用访客模式
- `visitorPassword: string` - 访客账户密码
2. **权限控制逻辑**
- 如果 `visitorMode === true``userRole !== 'admin'` → 限制为只读
- 两个 middleware 都检查 `visitorMode` 设置
3. **登录逻辑**
- 如果 `visitorMode === true` 且密码匹配 `visitorPassword` → 分配 `visitor` role
### 问题
- 权限控制混合了 settings 配置和 user role
- `visitorMode` 设置项是多余的,权限应该完全基于 `userRole`
- 代码复杂度增加,需要同时检查两个条件
## 重构目标
### 新架构
1. **权限控制**:完全基于 `userRole`
- `userRole === 'visitor'` → 只读权限
- `userRole === 'admin'` → 完全权限
- `userRole === null` → 未登录(根据 loginEnabled 决定是否允许访问)
2. **登录逻辑**
- 密码匹配 `password``admin` role
- 密码匹配 `visitorPassword``visitor` role不再检查 `visitorMode`
- Passkey → `admin` role保持不变
3. **Settings**
- 移除 `visitorMode` 设置项deprecated但保留字段以兼容旧数据
- 保留 `visitorPassword`(用于登录获取 visitor role
## 重构步骤
### Phase 1: Backend 重构
#### 1.1 创建基于 Role 的权限中间件
**文件**: `backend/src/middleware/roleBasedAuthMiddleware.ts` (新建)
- 替换 `visitorModeMiddleware.ts`
- 逻辑:如果 `userRole === 'visitor'`,限制为只读
- 允许 admin 和未登录用户(根据 loginEnabled的 GET 请求
- 阻止 visitor 的写操作POST, PUT, DELETE, PATCH
#### 1.2 更新 Settings 中间件
**文件**: `backend/src/middleware/visitorModeSettingsMiddleware.ts` → 重命名为 `roleBasedSettingsMiddleware.ts`
- 移除对 `visitorMode` 设置的检查
- 基于 `userRole === 'visitor'` 进行权限控制
- 允许 visitor 更新 CloudFlare 设置(如果需要)
#### 1.3 更新密码验证逻辑
**文件**: `backend/src/services/passwordService.ts`
- 移除 `visitorMode` 检查第111行
- 如果密码匹配 `visitorPassword`,直接分配 `visitor` role
- 逻辑:`if (mergedSettings.visitorPassword && isVisitorMatch) → visitor role`
#### 1.4 更新 Settings 验证服务
**文件**: `backend/src/services/settingsValidationService.ts`
- 移除 `checkVisitorModeRestrictions` 函数(或重构为基于 role 的检查)
- 移除 `visitorMode` 相关的验证逻辑
#### 1.5 更新 Settings Controller
**文件**: `backend/src/controllers/settingsController.ts`
- 移除 `visitorMode` 相关的检查逻辑第132-160行
- 基于 `userRole === 'visitor'` 进行权限控制
#### 1.6 更新 Settings 类型
**文件**: `backend/src/types/settings.ts`
- 标记 `visitorMode` 为 deprecated使用 JSDoc 注释)
- 保留字段以兼容旧数据,但不使用
#### 1.7 更新 Server 配置
**文件**: `backend/src/server.ts`
- 替换 `visitorModeMiddleware``roleBasedAuthMiddleware`
- 替换 `visitorModeSettingsMiddleware``roleBasedSettingsMiddleware`
### Phase 2: Frontend 重构
#### 2.1 重构 VisitorModeContext
**文件**: `frontend/src/contexts/VisitorModeContext.tsx`
- 重命名为 `RoleBasedAuthContext.tsx` 或直接移除
- 逻辑改为:`visitorMode = userRole === 'visitor'`
- 不再依赖 settings 中的 `visitorMode`
#### 2.2 更新所有使用 VisitorMode 的组件
需要更新的文件:
- `frontend/src/components/Settings/SecuritySettings.tsx`
- 移除 `visitorMode` switch
- 保留 `visitorPassword` 输入(用于设置访客密码)
- 更新说明文字:访客密码用于登录获取 visitor role
- `frontend/src/components/Header/*.tsx`
- 使用 `userRole === 'visitor'` 替代 `visitorMode`
- `frontend/src/components/ManagePage/*.tsx`
- 使用 `userRole === 'visitor'` 替代 `visitorMode`
- `frontend/src/components/VideoPlayer/*.tsx`
- 使用 `userRole === 'visitor'` 替代 `visitorMode`
- `frontend/src/pages/*.tsx`
- 使用 `userRole === 'visitor'` 替代 `visitorMode`
#### 2.3 更新 LoginPage
**文件**: `frontend/src/pages/LoginPage.tsx`
- 移除对 `visitorMode` 的检查
- Visitor tab 的显示逻辑改为:如果 `visitorPassword` 已设置,显示 Visitor tab
- 或者:始终显示 Visitor tab如果设置了 visitorPassword
#### 2.4 更新类型定义
**文件**: `frontend/src/types.ts`
- 移除 `visitorMode` 字段(或标记为 deprecated
- 保留 `visitorPassword` 相关字段
### Phase 3: 测试更新
#### 3.1 Backend 测试
- `backend/src/__tests__/middleware/visitorModeMiddleware.test.ts`
- 重命名为 `roleBasedAuthMiddleware.test.ts`
- 更新测试用例:基于 `userRole` 而非 `visitorMode` 设置
- `backend/src/__tests__/middleware/visitorModeSettingsMiddleware.test.ts`
- 重命名为 `roleBasedSettingsMiddleware.test.ts`
- 更新测试用例
- `backend/src/__tests__/services/passwordService.test.ts`
- 更新测试:移除 `visitorMode` 检查
#### 3.2 Frontend 测试
- 更新所有 mock `useVisitorMode` 的测试
- 改为 mock `useAuth` 并返回 `userRole: 'visitor'`
### Phase 4: 文档更新
#### 4.1 API 文档
- `documents/en/api-endpoints.md`
- `documents/zh/api-endpoints.md`
- 移除 `visitorMode` 相关说明
#### 4.2 代码注释
- 更新所有相关代码注释
- 添加 migration notes
## 迁移策略
### 数据迁移
1. **向后兼容**
- 保留 `visitorMode` 字段在 Settings 类型中(标记为 deprecated
- 不强制删除现有数据中的 `visitorMode` 字段
2. **自动迁移**(可选):
- 在启动时检查:如果 `visitorMode === true``visitorPassword` 已设置,记录警告
- 提示用户:`visitorMode` 已废弃,权限现在基于 user role
### 渐进式迁移
1. **Phase 1**: 先完成 Backend 重构,确保 API 兼容
2. **Phase 2**: 再完成 Frontend 重构
3. **Phase 3**: 更新测试
4. **Phase 4**: 更新文档
## 风险评估
### 高风险
- **登录逻辑变更**:需要确保 visitor 登录仍然工作
- **权限控制变更**:需要确保 visitor 权限正确限制
### 中风险
- **前端组件更新**:大量组件需要更新
- **测试覆盖**:需要更新大量测试用例
### 低风险
- **文档更新**:纯文档工作
## 回滚计划
如果出现问题:
1. 保留旧代码在 git 分支中
2. 可以临时恢复 `visitorMode` 检查逻辑
3. 添加 feature flag 控制新旧逻辑切换(如果需要)
## 验收标准
1. ✅ Visitor 用户只能进行读操作GET
2. ✅ Visitor 用户不能进行写操作POST, PUT, DELETE, PATCH
3. ✅ Admin 用户拥有完全权限
4. ✅ 使用 `visitorPassword` 登录后获得 `visitor` role
5. ✅ 使用 `password` 登录后获得 `admin` role
6. ✅ 所有测试通过
7. ✅ 前端 UI 正确显示权限限制
8. ✅ 不再依赖 `visitorMode` 设置项
## 时间估算
- Phase 1 (Backend): 4-6 小时
- Phase 2 (Frontend): 6-8 小时
- Phase 3 (Tests): 3-4 小时
- Phase 4 (Docs): 1-2 小时
**总计**: 14-20 小时
## 文件清单
### Backend 需要修改的文件
1. `backend/src/middleware/visitorModeMiddleware.ts` → 重构/删除
2. `backend/src/middleware/visitorModeSettingsMiddleware.ts` → 重构/重命名
3. `backend/src/services/passwordService.ts` → 修改
4. `backend/src/services/settingsValidationService.ts` → 修改
5. `backend/src/controllers/settingsController.ts` → 修改
6. `backend/src/types/settings.ts` → 标记 deprecated
7. `backend/src/server.ts` → 更新中间件引用
8. `backend/src/__tests__/middleware/visitorModeMiddleware.test.ts` → 重构
9. `backend/src/__tests__/middleware/visitorModeSettingsMiddleware.test.ts` → 重构
### Frontend 需要修改的文件
1. `frontend/src/contexts/VisitorModeContext.tsx` → 重构/删除
2. `frontend/src/components/Settings/SecuritySettings.tsx` → 修改
3. `frontend/src/components/Header/*.tsx` (多个文件) → 修改
4. `frontend/src/components/ManagePage/*.tsx` (多个文件) → 修改
5. `frontend/src/components/VideoPlayer/*.tsx` (多个文件) → 修改
6. `frontend/src/pages/*.tsx` (多个文件) → 修改
7. `frontend/src/types.ts` → 修改
8. 所有相关测试文件 → 更新
### 文档需要更新的文件
1. `documents/en/api-endpoints.md`
2. `documents/zh/api-endpoints.md`
3. `documents/en/directory-structure.md`
4. `documents/zh/directory-structure.md`

View File

@@ -0,0 +1,11 @@
CREATE TABLE `passkeys` (
`id` text PRIMARY KEY NOT NULL,
`credential_id` text NOT NULL,
`credential_public_key` text NOT NULL,
`counter` integer DEFAULT 0 NOT NULL,
`transports` text,
`name` text,
`created_at` text NOT NULL,
`rp_id` text,
`origin` text
);

View File

@@ -0,0 +1,907 @@
{
"version": "6",
"dialect": "sqlite",
"id": "5627912c-5cc6-4da0-8d67-e5f73a7b4736",
"prevId": "e727cb82-6923-4f2f-a2dd-459a8a052879",
"tables": {
"collection_videos": {
"name": "collection_videos",
"columns": {
"collection_id": {
"name": "collection_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"video_id": {
"name": "video_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"order": {
"name": "order",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"collection_videos_collection_id_collections_id_fk": {
"name": "collection_videos_collection_id_collections_id_fk",
"tableFrom": "collection_videos",
"tableTo": "collections",
"columnsFrom": [
"collection_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"collection_videos_video_id_videos_id_fk": {
"name": "collection_videos_video_id_videos_id_fk",
"tableFrom": "collection_videos",
"tableTo": "videos",
"columnsFrom": [
"video_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"collection_videos_collection_id_video_id_pk": {
"columns": [
"collection_id",
"video_id"
],
"name": "collection_videos_collection_id_video_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"collections": {
"name": "collections",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"continuous_download_tasks": {
"name": "continuous_download_tasks",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"collection_id": {
"name": "collection_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_url": {
"name": "author_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"total_videos": {
"name": "total_videos",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"downloaded_count": {
"name": "downloaded_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"skipped_count": {
"name": "skipped_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"failed_count": {
"name": "failed_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"current_video_index": {
"name": "current_video_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"download_history": {
"name": "download_history",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"finished_at": {
"name": "finished_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_path": {
"name": "video_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_size": {
"name": "total_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_id": {
"name": "video_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"downloaded_at": {
"name": "downloaded_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"downloads": {
"name": "downloads",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_size": {
"name": "total_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"downloaded_size": {
"name": "downloaded_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"speed": {
"name": "speed",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'active'"
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"passkeys": {
"name": "passkeys",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"credential_id": {
"name": "credential_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credential_public_key": {
"name": "credential_public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rp_id": {
"name": "rp_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"subscriptions": {
"name": "subscriptions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author_url": {
"name": "author_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"interval": {
"name": "interval",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_video_link": {
"name": "last_video_link",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_check": {
"name": "last_check",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"download_count": {
"name": "download_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'YouTube'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"video_downloads": {
"name": "video_downloads",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"source_video_id": {
"name": "source_video_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"video_id": {
"name": "video_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'exists'"
},
"downloaded_at": {
"name": "downloaded_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"deleted_at": {
"name": "deleted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"videos": {
"name": "videos",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_filename": {
"name": "video_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_filename": {
"name": "thumbnail_filename",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"video_path": {
"name": "video_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"added_at": {
"name": "added_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"part_number": {
"name": "part_number",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_parts": {
"name": "total_parts",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"series_title": {
"name": "series_title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"rating": {
"name": "rating",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"view_count": {
"name": "view_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration": {
"name": "duration",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"tags": {
"name": "tags",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"progress": {
"name": "progress",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_size": {
"name": "file_size",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_played_at": {
"name": "last_played_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subtitles": {
"name": "subtitles",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"channel_url": {
"name": "channel_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"visibility": {
"name": "visibility",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -64,6 +64,13 @@
"when": 1766776202201,
"tag": "0008_useful_sharon_carter",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1767494996743,
"tag": "0009_brief_stingray",
"breakpoints": true
}
]
}

View File

@@ -16,6 +16,7 @@ export const getPasswordEnabled = async (
/**
* Verify password for authentication
* @deprecated Use verifyAdminPassword or verifyVisitorPassword instead for better security
* Errors are automatically handled by asyncHandler middleware
*/
export const verifyPassword = async (
@@ -47,6 +48,68 @@ export const verifyPassword = async (
}
};
/**
* Verify admin password for authentication
* Only checks admin password, not visitor password
* Errors are automatically handled by asyncHandler middleware
*/
export const verifyAdminPassword = async (
req: Request,
res: Response
): Promise<void> => {
const { password } = req.body;
const result = await passwordService.verifyAdminPassword(password);
if (result.success) {
res.json({
success: true,
role: result.role,
token: result.token
});
} else {
const statusCode = result.waitTime ? 429 : 401;
res.json({
success: false,
waitTime: result.waitTime,
failedAttempts: result.failedAttempts,
message: result.message,
statusCode
});
}
};
/**
* Verify visitor password for authentication
* Only checks visitor password, not admin password
* Errors are automatically handled by asyncHandler middleware
*/
export const verifyVisitorPassword = async (
req: Request,
res: Response
): Promise<void> => {
const { password } = req.body;
const result = await passwordService.verifyVisitorPassword(password);
if (result.success) {
res.json({
success: true,
role: result.role,
token: result.token
});
} else {
const statusCode = result.waitTime ? 429 : 401;
res.json({
success: false,
waitTime: result.waitTime,
failedAttempts: result.failedAttempts,
message: result.message,
statusCode
});
}
};
/**
* Get the remaining cooldown time for password reset
* Errors are automatically handled by asyncHandler middleware

View File

@@ -124,41 +124,8 @@ export const updateSettings = async (
{}
);
// Check visitor mode restrictions (if not admin)
// If user is admin (jwt authenticated), they bypass visitor mode restrictions
const isAdmin = req.user?.role === "admin";
if (!isAdmin) {
const visitorModeCheck =
settingsValidationService.checkVisitorModeRestrictions(
mergedSettings,
newSettings
);
if (!visitorModeCheck.allowed) {
res.status(403).json({
success: false,
error: visitorModeCheck.error,
});
return;
}
// Handle special case: visitorMode being set to true (already enabled)
// Only applies if NOT admin (admins can update settings while in visitor mode)
if (mergedSettings.visitorMode === true && newSettings.visitorMode === true) {
// Only update visitorMode, ignore other changes
const allowedSettings: Settings = {
...mergedSettings,
visitorMode: true,
};
storageService.saveSettings(allowedSettings);
res.json({
success: true,
settings: { ...allowedSettings, password: undefined, visitorPassword: undefined },
});
return;
}
}
// Permission control is now handled by roleBasedSettingsMiddleware
// No need to check visitorMode here - middleware enforces role-based access control

View File

@@ -1,61 +1,71 @@
import { relations } from 'drizzle-orm';
import { foreignKey, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { relations } from "drizzle-orm";
import {
foreignKey,
integer,
primaryKey,
sqliteTable,
text,
} from "drizzle-orm/sqlite-core";
export const videos = sqliteTable('videos', {
id: text('id').primaryKey(),
title: text('title').notNull(),
author: text('author'),
date: text('date'),
source: text('source'),
sourceUrl: text('source_url'),
videoFilename: text('video_filename'),
thumbnailFilename: text('thumbnail_filename'),
videoPath: text('video_path'),
thumbnailPath: text('thumbnail_path'),
thumbnailUrl: text('thumbnail_url'),
addedAt: text('added_at'),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at'),
partNumber: integer('part_number'),
totalParts: integer('total_parts'),
seriesTitle: text('series_title'),
rating: integer('rating'),
export const videos = sqliteTable("videos", {
id: text("id").primaryKey(),
title: text("title").notNull(),
author: text("author"),
date: text("date"),
source: text("source"),
sourceUrl: text("source_url"),
videoFilename: text("video_filename"),
thumbnailFilename: text("thumbnail_filename"),
videoPath: text("video_path"),
thumbnailPath: text("thumbnail_path"),
thumbnailUrl: text("thumbnail_url"),
addedAt: text("added_at"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at"),
partNumber: integer("part_number"),
totalParts: integer("total_parts"),
seriesTitle: text("series_title"),
rating: integer("rating"),
// Additional fields that might be present
description: text('description'),
viewCount: integer('view_count'),
duration: text('duration'),
tags: text('tags'), // JSON stringified array of strings
progress: integer('progress'), // Playback progress in seconds
fileSize: text('file_size'),
lastPlayedAt: integer('last_played_at'), // Timestamp when video was last played
subtitles: text('subtitles'), // JSON stringified array of subtitle objects
channelUrl: text('channel_url'), // Author channel URL for subscriptions
visibility: integer('visibility').default(1), // 1 = visible, 0 = hidden
description: text("description"),
viewCount: integer("view_count"),
duration: text("duration"),
tags: text("tags"), // JSON stringified array of strings
progress: integer("progress"), // Playback progress in seconds
fileSize: text("file_size"),
lastPlayedAt: integer("last_played_at"), // Timestamp when video was last played
subtitles: text("subtitles"), // JSON stringified array of subtitle objects
channelUrl: text("channel_url"), // Author channel URL for subscriptions
visibility: integer("visibility").default(1), // 1 = visible, 0 = hidden
});
export const collections = sqliteTable('collections', {
id: text('id').primaryKey(),
name: text('name').notNull(),
title: text('title'), // Keeping for backward compatibility/alias
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at'),
export const collections = sqliteTable("collections", {
id: text("id").primaryKey(),
name: text("name").notNull(),
title: text("title"), // Keeping for backward compatibility/alias
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at"),
});
export const collectionVideos = sqliteTable('collection_videos', {
collectionId: text('collection_id').notNull(),
videoId: text('video_id').notNull(),
order: integer('order'), // To maintain order if needed
}, (t) => ({
pk: primaryKey({ columns: [t.collectionId, t.videoId] }),
collectionFk: foreignKey({
columns: [t.collectionId],
foreignColumns: [collections.id],
}).onDelete('cascade'),
videoFk: foreignKey({
columns: [t.videoId],
foreignColumns: [videos.id],
}).onDelete('cascade'),
}));
export const collectionVideos = sqliteTable(
"collection_videos",
{
collectionId: text("collection_id").notNull(),
videoId: text("video_id").notNull(),
order: integer("order"), // To maintain order if needed
},
(t) => ({
pk: primaryKey({ columns: [t.collectionId, t.videoId] }),
collectionFk: foreignKey({
columns: [t.collectionId],
foreignColumns: [collections.id],
}).onDelete("cascade"),
videoFk: foreignKey({
columns: [t.videoId],
foreignColumns: [videos.id],
}).onDelete("cascade"),
})
);
// Relations
export const videosRelations = relations(videos, ({ many }) => ({
@@ -66,94 +76,100 @@ export const collectionsRelations = relations(collections, ({ many }) => ({
videos: many(collectionVideos),
}));
export const collectionVideosRelations = relations(collectionVideos, ({ one }) => ({
collection: one(collections, {
fields: [collectionVideos.collectionId],
references: [collections.id],
}),
video: one(videos, {
fields: [collectionVideos.videoId],
references: [videos.id],
}),
}));
export const collectionVideosRelations = relations(
collectionVideos,
({ one }) => ({
collection: one(collections, {
fields: [collectionVideos.collectionId],
references: [collections.id],
}),
video: one(videos, {
fields: [collectionVideos.videoId],
references: [videos.id],
}),
})
);
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(), // JSON stringified value
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(), // JSON stringified value
});
export const downloads = sqliteTable('downloads', {
id: text('id').primaryKey(),
title: text('title').notNull(),
timestamp: integer('timestamp'),
filename: text('filename'),
totalSize: text('total_size'),
downloadedSize: text('downloaded_size'),
progress: integer('progress'), // Using integer for percentage (0-100) or similar
speed: text('speed'),
status: text('status').notNull().default('active'), // 'active' or 'queued'
sourceUrl: text('source_url'),
type: text('type'),
export const downloads = sqliteTable("downloads", {
id: text("id").primaryKey(),
title: text("title").notNull(),
timestamp: integer("timestamp"),
filename: text("filename"),
totalSize: text("total_size"),
downloadedSize: text("downloaded_size"),
progress: integer("progress"), // Using integer for percentage (0-100) or similar
speed: text("speed"),
status: text("status").notNull().default("active"), // 'active' or 'queued'
sourceUrl: text("source_url"),
type: text("type"),
});
export const downloadHistory = sqliteTable('download_history', {
id: text('id').primaryKey(),
title: text('title').notNull(),
author: text('author'),
sourceUrl: text('source_url'),
finishedAt: integer('finished_at').notNull(), // Timestamp
status: text('status').notNull(), // 'success', 'failed', 'skipped', or 'deleted'
error: text('error'), // Error message if failed
videoPath: text('video_path'), // Path to video file if successful
thumbnailPath: text('thumbnail_path'), // Path to thumbnail if successful
totalSize: text('total_size'),
videoId: text('video_id'), // Reference to video for skipped items
downloadedAt: integer('downloaded_at'), // Original download timestamp for deleted items
deletedAt: integer('deleted_at'), // Deletion timestamp for deleted items
export const downloadHistory = sqliteTable("download_history", {
id: text("id").primaryKey(),
title: text("title").notNull(),
author: text("author"),
sourceUrl: text("source_url"),
finishedAt: integer("finished_at").notNull(), // Timestamp
status: text("status").notNull(), // 'success', 'failed', 'skipped', or 'deleted'
error: text("error"), // Error message if failed
videoPath: text("video_path"), // Path to video file if successful
thumbnailPath: text("thumbnail_path"), // Path to thumbnail if successful
totalSize: text("total_size"),
videoId: text("video_id"), // Reference to video for skipped items
downloadedAt: integer("downloaded_at"), // Original download timestamp for deleted items
deletedAt: integer("deleted_at"), // Deletion timestamp for deleted items
});
export const subscriptions = sqliteTable('subscriptions', {
id: text('id').primaryKey(),
author: text('author').notNull(),
authorUrl: text('author_url').notNull(),
interval: integer('interval').notNull(), // Check interval in minutes
lastVideoLink: text('last_video_link'),
lastCheck: integer('last_check'), // Timestamp
downloadCount: integer('download_count').default(0),
createdAt: integer('created_at').notNull(),
platform: text('platform').default('YouTube'),
export const subscriptions = sqliteTable("subscriptions", {
id: text("id").primaryKey(),
author: text("author").notNull(),
authorUrl: text("author_url").notNull(),
interval: integer("interval").notNull(), // Check interval in minutes
lastVideoLink: text("last_video_link"),
lastCheck: integer("last_check"), // Timestamp
downloadCount: integer("download_count").default(0),
createdAt: integer("created_at").notNull(),
platform: text("platform").default("YouTube"),
});
// Track downloaded video IDs to prevent re-downloading
export const videoDownloads = sqliteTable('video_downloads', {
id: text('id').primaryKey(), // Unique identifier
sourceVideoId: text('source_video_id').notNull(), // Video ID from source (YouTube ID, Bilibili BV ID, etc.)
sourceUrl: text('source_url').notNull(), // Original source URL
platform: text('platform').notNull(), // YouTube, Bilibili, MissAV, etc.
videoId: text('video_id'), // Reference to local video ID (null if deleted)
title: text('title'), // Video title for display
author: text('author'), // Video author
status: text('status').notNull().default('exists'), // 'exists' or 'deleted'
downloadedAt: integer('downloaded_at').notNull(), // Timestamp of first download
deletedAt: integer('deleted_at'), // Timestamp when video was deleted (nullable)
export const videoDownloads = sqliteTable("video_downloads", {
id: text("id").primaryKey(), // Unique identifier
sourceVideoId: text("source_video_id").notNull(), // Video ID from source (YouTube ID, Bilibili BV ID, etc.)
sourceUrl: text("source_url").notNull(), // Original source URL
platform: text("platform").notNull(), // YouTube, Bilibili, MissAV, etc.
videoId: text("video_id"), // Reference to local video ID (null if deleted)
title: text("title"), // Video title for display
author: text("author"), // Video author
status: text("status").notNull().default("exists"), // 'exists' or 'deleted'
downloadedAt: integer("downloaded_at").notNull(), // Timestamp of first download
deletedAt: integer("deleted_at"), // Timestamp when video was deleted (nullable)
});
// Track continuous download tasks for downloading all previous videos from an author
export const continuousDownloadTasks = sqliteTable('continuous_download_tasks', {
id: text('id').primaryKey(),
subscriptionId: text('subscription_id'), // Reference to subscription (nullable if subscription deleted)
collectionId: text('collection_id'), // Reference to collection (nullable, for playlist tasks)
authorUrl: text('author_url').notNull(),
author: text('author').notNull(),
platform: text('platform').notNull(), // YouTube, Bilibili, etc.
status: text('status').notNull().default('active'), // 'active', 'paused', 'completed', 'cancelled'
totalVideos: integer('total_videos').default(0), // Total videos found
downloadedCount: integer('downloaded_count').default(0), // Number of videos downloaded
skippedCount: integer('skipped_count').default(0), // Number of videos skipped (already downloaded)
failedCount: integer('failed_count').default(0), // Number of videos that failed
currentVideoIndex: integer('current_video_index').default(0), // Current video being processed
createdAt: integer('created_at').notNull(), // Timestamp when task was created
updatedAt: integer('updated_at'), // Timestamp of last update
completedAt: integer('completed_at'), // Timestamp when task completed
error: text('error'), // Error message if task failed
});
export const continuousDownloadTasks = sqliteTable(
"continuous_download_tasks",
{
id: text("id").primaryKey(),
subscriptionId: text("subscription_id"), // Reference to subscription (nullable if subscription deleted)
collectionId: text("collection_id"), // Reference to collection (nullable, for playlist tasks)
authorUrl: text("author_url").notNull(),
author: text("author").notNull(),
platform: text("platform").notNull(), // YouTube, Bilibili, etc.
status: text("status").notNull().default("active"), // 'active', 'paused', 'completed', 'cancelled'
totalVideos: integer("total_videos").default(0), // Total videos found
downloadedCount: integer("downloaded_count").default(0), // Number of videos downloaded
skippedCount: integer("skipped_count").default(0), // Number of videos skipped (already downloaded)
failedCount: integer("failed_count").default(0), // Number of videos that failed
currentVideoIndex: integer("current_video_index").default(0), // Current video being processed
createdAt: integer("created_at").notNull(), // Timestamp when task was created
updatedAt: integer("updated_at"), // Timestamp of last update
completedAt: integer("completed_at"), // Timestamp when task completed
error: text("error"), // Error message if task failed
}
);

View File

@@ -0,0 +1,60 @@
import { NextFunction, Request, Response } from "express";
/**
* Middleware to enforce role-based access control
* Visitors (userRole === 'visitor') are restricted to read-only operations
* Admins (userRole === 'admin') have full access
* Unauthenticated users are handled by loginEnabled setting
*/
export const roleBasedAuthMiddleware = (
req: Request,
res: Response,
next: NextFunction
): void => {
// If user is Admin, allow all requests
if (req.user?.role === "admin") {
next();
return;
}
// If user is Visitor, restrict to read-only
if (req.user?.role === "visitor") {
// Allow GET requests (read-only)
if (req.method === "GET") {
next();
return;
}
// Allow authentication-related POST requests
if (req.method === "POST") {
// Allow verify-password requests
if (
req.path.includes("/verify-password") ||
req.url.includes("/verify-password")
) {
next();
return;
}
// Allow passkey authentication
if (
req.path.includes("/settings/passkeys/authenticate") ||
req.url.includes("/settings/passkeys/authenticate")
) {
next();
return;
}
}
// Block all other write operations (POST, PUT, DELETE, PATCH)
res.status(403).json({
success: false,
error: "Visitor role: Write operations are not allowed. Read-only access only.",
});
return;
}
// For unauthenticated users, allow the request to proceed
// (loginEnabled check and other auth logic will handle it)
next();
};

View File

@@ -0,0 +1,85 @@
import { NextFunction, Request, Response } from "express";
/**
* Middleware specifically for settings routes with role-based access control
* Visitors can only read settings and update CloudFlare tunnel settings
* Admins have full access to all settings
*/
export const roleBasedSettingsMiddleware = (
req: Request,
res: Response,
next: NextFunction
): void => {
// If user is Admin, allow all requests
if (req.user?.role === "admin") {
next();
return;
}
// If user is Visitor, restrict to read-only and CloudFlare updates
if (req.user?.role === "visitor") {
// Allow GET requests (read-only)
if (req.method === "GET") {
next();
return;
}
// For POST requests, check if it's authentication or CloudFlare settings
if (req.method === "POST") {
// Allow verify-password requests
if (
req.path.includes("/verify-password") ||
req.url.includes("/verify-password")
) {
next();
return;
}
// Allow passkey authentication
if (
req.path.includes("/passkeys/authenticate") ||
req.url.includes("/passkeys/authenticate")
) {
next();
return;
}
const body = req.body || {};
// Allow CloudFlare tunnel settings updates (read-only access mechanism)
const isOnlyCloudflareUpdate =
(body.cloudflaredTunnelEnabled !== undefined ||
body.cloudflaredToken !== undefined) &&
Object.keys(body).every(
(key) =>
key === "cloudflaredTunnelEnabled" ||
key === "cloudflaredToken"
);
if (isOnlyCloudflareUpdate) {
// Allow CloudFlare settings updates
next();
return;
}
// Block all other settings updates
res.status(403).json({
success: false,
error:
"Visitor role: Only reading settings and updating CloudFlare settings is allowed.",
});
return;
}
// Block all other write operations (PUT, DELETE, PATCH)
res.status(403).json({
success: false,
error: "Visitor role: Write operations are not allowed.",
});
return;
}
// For unauthenticated users, allow the request to proceed
// (loginEnabled check and other auth logic will handle it)
next();
};

View File

@@ -23,6 +23,8 @@ import {
getResetPasswordCooldown,
resetPassword,
verifyPassword,
verifyAdminPassword,
verifyVisitorPassword,
} from "../controllers/passwordController";
import {
checkPasskeysExist,
@@ -56,7 +58,9 @@ router.get("/cloudflared/status", asyncHandler(getCloudflaredStatus));
// Password routes
router.get("/password-enabled", asyncHandler(getPasswordEnabled));
router.get("/reset-password-cooldown", asyncHandler(getResetPasswordCooldown));
router.post("/verify-password", asyncHandler(verifyPassword));
router.post("/verify-password", asyncHandler(verifyPassword)); // Deprecated, use verify-admin-password or verify-visitor-password
router.post("/verify-admin-password", asyncHandler(verifyAdminPassword));
router.post("/verify-visitor-password", asyncHandler(verifyVisitorPassword));
router.post("/reset-password", asyncHandler(resetPassword));
// Passkey routes

View File

@@ -13,8 +13,8 @@ import {
} from "./config/paths";
import { runMigrations } from "./db/migrate";
import { authMiddleware } from "./middleware/authMiddleware";
import { visitorModeMiddleware } from "./middleware/visitorModeMiddleware";
import { visitorModeSettingsMiddleware } from "./middleware/visitorModeSettingsMiddleware";
import { roleBasedAuthMiddleware } from "./middleware/roleBasedAuthMiddleware";
import { roleBasedSettingsMiddleware } from "./middleware/roleBasedSettingsMiddleware";
import apiRoutes from "./routes/api";
import settingsRoutes from "./routes/settingsRoutes";
import { cloudflaredService } from "./services/cloudflaredService";
@@ -246,10 +246,10 @@ const startServer = async () => {
// API Routes
// Apply auth middleware to all API routes
app.use("/api", authMiddleware);
// Apply visitor mode middleware to all API routes
app.use("/api", visitorModeMiddleware, apiRoutes);
// Use separate middleware for settings that allows disabling visitor mode
app.use("/api/settings", visitorModeSettingsMiddleware, settingsRoutes);
// Apply role-based access control middleware to all API routes
app.use("/api", roleBasedAuthMiddleware, apiRoutes);
// Use separate middleware for settings with role-based access control
app.use("/api/settings", roleBasedSettingsMiddleware, settingsRoutes);
// SPA Fallback for Frontend
app.get("*", (req, res) => {

View File

@@ -1,14 +1,14 @@
import type {
GenerateAuthenticationOptionsOpts,
GenerateRegistrationOptionsOpts,
VerifyAuthenticationResponseOpts,
VerifyRegistrationResponseOpts,
GenerateAuthenticationOptionsOpts,
GenerateRegistrationOptionsOpts,
VerifyAuthenticationResponseOpts,
VerifyRegistrationResponseOpts,
} from "@simplewebauthn/server";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { logger } from "../utils/logger";
import { generateToken } from "./authService";
@@ -309,10 +309,10 @@ export async function verifyPasskeyAuthentication(
savePasskeys(updatedPasskeys);
logger.info("Passkey authentication successful");
// Generate admin token (Passkeys are currently only for admins)
const token = generateToken({ role: "admin" });
return { verified: true, token, role: "admin" };
}

View File

@@ -43,9 +43,7 @@ export function isPasswordEnabled(): {
/**
* Verify password for authentication
*/
/**
* Verify password for authentication
* @deprecated Use verifyAdminPassword or verifyVisitorPassword instead for better security
*/
import { generateToken } from "./authService";
@@ -105,14 +103,10 @@ export async function verifyPassword(
}
}
// 2. Check Visitor Password (only if visitorMode is enabled AND visitorPassword is set)
// Actually, user said: "When visitor user enable... login from this password input user role is Visitor"
// So we check if visitorMode is enabled in settings.
if (mergedSettings.visitorMode && mergedSettings.visitorPassword) {
// NOTE: visitorPassword might not be hashed yet if we just added it?
// Step 6 in Plan said "Update Settings type", but we didn't discuss hashing.
// We should probably hash it when saving too.
// For now, assuming it WILL be hashed. I'll need to update `settingsValidationService` to hash it.
// 2. Check Visitor Password (if visitorPassword is set)
// Permission control is now based on user role, not visitorMode setting
// If password matches visitorPassword, assign visitor role
if (mergedSettings.visitorPassword) {
const isVisitorMatch = await bcrypt.compare(password, mergedSettings.visitorPassword);
if (isVisitorMatch) {
loginAttemptService.resetFailedAttempts();
@@ -133,6 +127,137 @@ export async function verifyPassword(
};
}
/**
* Verify admin password for authentication
* Only checks admin password, not visitor password
*/
export async function verifyAdminPassword(
password: string
): Promise<{
success: boolean;
role?: "admin";
token?: string;
waitTime?: number;
failedAttempts?: number;
message?: string;
}> {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
if (!passwordLoginAllowed) {
return {
success: false,
message: "Password login is not allowed. Please use passkey authentication.",
};
}
// Check if user can attempt login (wait time check)
const remainingWaitTime = loginAttemptService.canAttemptLogin();
if (remainingWaitTime > 0) {
return {
success: false,
waitTime: remainingWaitTime,
message: "Too many failed attempts. Please wait before trying again.",
};
}
// Check Admin Password only
if (mergedSettings.password) {
const isAdminMatch = await bcrypt.compare(password, mergedSettings.password);
if (isAdminMatch) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "admin" });
return { success: true, role: "admin", token };
}
} else {
// If no admin password set, and login enabled, allow as admin
if (mergedSettings.loginEnabled) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "admin" });
return { success: true, role: "admin", token };
}
}
// No match - record failed attempt
const waitTime = loginAttemptService.recordFailedAttempt();
const failedAttempts = loginAttemptService.getFailedAttempts();
return {
success: false,
waitTime,
failedAttempts,
message: "Incorrect admin password",
};
}
/**
* Verify visitor password for authentication
* Only checks visitor password, not admin password
*/
export async function verifyVisitorPassword(
password: string
): Promise<{
success: boolean;
role?: "visitor";
token?: string;
waitTime?: number;
failedAttempts?: number;
message?: string;
}> {
const settings = storageService.getSettings();
const mergedSettings = { ...defaultSettings, ...settings };
// Check if password login is allowed (defaults to true for backward compatibility)
const passwordLoginAllowed = mergedSettings.passwordLoginAllowed !== false;
if (!passwordLoginAllowed) {
return {
success: false,
message: "Password login is not allowed. Please use passkey authentication.",
};
}
// Check if user can attempt login (wait time check)
const remainingWaitTime = loginAttemptService.canAttemptLogin();
if (remainingWaitTime > 0) {
return {
success: false,
waitTime: remainingWaitTime,
message: "Too many failed attempts. Please wait before trying again.",
};
}
// Check Visitor Password only
if (mergedSettings.visitorPassword) {
const isVisitorMatch = await bcrypt.compare(password, mergedSettings.visitorPassword);
if (isVisitorMatch) {
loginAttemptService.resetFailedAttempts();
const token = generateToken({ role: "visitor" });
return { success: true, role: "visitor", token };
}
} else {
// No visitor password set
return {
success: false,
message: "Visitor password is not configured.",
};
}
// No match - record failed attempt
const waitTime = loginAttemptService.recordFailedAttempt();
const failedAttempts = loginAttemptService.getFailedAttempts();
return {
success: false,
waitTime,
failedAttempts,
message: "Incorrect visitor password",
};
}
/**
* Hash a password
*/

View File

@@ -26,7 +26,9 @@ export function validateSettings(newSettings: Partial<Settings>): void {
}
/**
* Check if visitor mode restrictions should apply
* @deprecated This function is deprecated. Permission control is now handled by role-based middleware.
* This function is kept for backward compatibility but should not be used.
* Use roleBasedSettingsMiddleware instead.
*/
export function checkVisitorModeRestrictions(
existingSettings: Settings,
@@ -35,42 +37,9 @@ export function checkVisitorModeRestrictions(
allowed: boolean;
error?: string;
} {
// If visitor mode is not enabled, no restrictions
if (existingSettings.visitorMode !== true) {
return { allowed: true };
}
// If visitorMode is being explicitly set to false, allow the update
if (newSettings.visitorMode === false) {
return { allowed: true };
}
// If visitorMode is explicitly set to true (already enabled), allow but only update visitorMode
if (newSettings.visitorMode === true) {
return { allowed: true };
}
// Allow CloudFlare tunnel settings updates (read-only access mechanism, doesn't violate visitor mode)
const isOnlyCloudflareUpdate =
(newSettings.cloudflaredTunnelEnabled !== undefined ||
newSettings.cloudflaredToken !== undefined) &&
Object.keys(newSettings).every(
(key) =>
key === "cloudflaredTunnelEnabled" ||
key === "cloudflaredToken" ||
key === "visitorMode"
);
if (isOnlyCloudflareUpdate) {
return { allowed: true };
}
// Block all other changes
return {
allowed: false,
error:
"Visitor mode is enabled. Only disabling visitor mode or updating CloudFlare settings is allowed.",
};
// Always allow - permission control is now handled by role-based middleware
// This function is kept for backward compatibility
return { allowed: true };
}
/**

View File

@@ -10,29 +10,29 @@ export { initializeStorage } from "./initialization";
// Download Status
export {
addActiveDownload,
updateActiveDownload,
getDownloadStatus,
removeActiveDownload,
setQueuedDownloads,
getDownloadStatus,
updateActiveDownload,
} from "./downloadStatus";
// Download History
export {
addDownloadHistoryItem,
clearDownloadHistory,
getDownloadHistory,
removeDownloadHistoryItem,
clearDownloadHistory,
} from "./downloadHistory";
// Video Download Tracking
export {
checkVideoDownloadBySourceId,
checkVideoDownloadByUrl,
recordVideoDownload,
handleVideoDownloadCheck,
markVideoDownloadDeleted,
recordVideoDownload,
updateVideoDownloadRecord,
verifyVideoExists,
handleVideoDownloadCheck,
} from "./videoDownloadTracking";
// Settings
@@ -40,31 +40,30 @@ export { getSettings, saveSettings } from "./settings";
// Videos
export {
getVideos,
getVideoBySourceUrl,
getVideoById,
deleteVideo,
formatLegacyFilenames,
getVideoById,
getVideoBySourceUrl,
getVideos,
saveVideo,
updateVideo,
deleteVideo,
} from "./videos";
// Collections
export {
getCollections,
getCollectionById,
getCollectionByVideoId,
getCollectionByName,
generateUniqueCollectionName,
saveCollection,
addVideoToCollection,
atomicUpdateCollection,
deleteCollection,
addVideoToCollection,
removeVideoFromCollection,
deleteCollectionWithFiles,
deleteCollectionAndVideos,
deleteCollectionWithFiles,
generateUniqueCollectionName,
getCollectionById,
getCollectionByName,
getCollectionByVideoId,
getCollections,
removeVideoFromCollection,
saveCollection,
} from "./collections";
// File Helpers
export { findVideoFile, findImageFile, moveFile } from "./fileHelpers";
export { findImageFile, findVideoFile, moveFile } from "./fileHelpers";

View File

@@ -8,9 +8,9 @@ import {
UPLOADS_DIR,
VIDEOS_DIR,
} from "../../config/paths";
import { MigrationError } from "../../errors/DownloadErrors";
import { db, sqlite } from "../../db";
import { downloads, videos } from "../../db/schema";
import { MigrationError } from "../../errors/DownloadErrors";
import { logger } from "../../utils/logger";
import { findVideoFile } from "./fileHelpers";
@@ -36,7 +36,10 @@ export function initializeStorage(): void {
fs.writeFileSync(STATUS_DATA_PATH, JSON.stringify(status, null, 2));
logger.info("Cleared active downloads on startup");
} catch (error) {
logger.error("Error resetting active downloads", error instanceof Error ? error : new Error(String(error)));
logger.error(
"Error resetting active downloads",
error instanceof Error ? error : new Error(String(error))
);
fs.writeFileSync(
STATUS_DATA_PATH,
JSON.stringify({ activeDownloads: [], queuedDownloads: [] }, null, 2)
@@ -49,7 +52,10 @@ export function initializeStorage(): void {
db.delete(downloads).where(eq(downloads.status, "active")).run();
logger.info("Cleared active downloads from database on startup");
} catch (error) {
logger.error("Error clearing active downloads from database", error instanceof Error ? error : new Error(String(error)));
logger.error(
"Error clearing active downloads from database",
error instanceof Error ? error : new Error(String(error))
);
}
// Check and migrate tags column if needed
@@ -65,7 +71,10 @@ export function initializeStorage(): void {
logger.info("Migration successful.");
}
} catch (error) {
logger.error("Error checking/migrating tags column", error instanceof Error ? error : new Error(String(error)));
logger.error(
"Error checking/migrating tags column",
error instanceof Error ? error : new Error(String(error))
);
throw new MigrationError(
"Failed to migrate tags column",
"tags_column",
@@ -198,7 +207,10 @@ export function initializeStorage(): void {
.run();
} catch (indexError) {
// Indexes might already exist, ignore error
logger.debug("Index creation skipped (may already exist)", indexError instanceof Error ? indexError : new Error(String(indexError)));
logger.debug(
"Index creation skipped (may already exist)",
indexError instanceof Error ? indexError : new Error(String(indexError))
);
}
// Check download_history table for video_id, downloaded_at, deleted_at columns
@@ -282,7 +294,10 @@ export function initializeStorage(): void {
);
}
} catch (error) {
logger.error("Error backfilling video_id in download history", error instanceof Error ? error : new Error(String(error)));
logger.error(
"Error backfilling video_id in download history",
error instanceof Error ? error : new Error(String(error))
);
}
} catch (error) {
logger.error(

View File

@@ -23,6 +23,11 @@ export interface Settings {
proxyOnlyYoutube?: boolean;
moveSubtitlesToVideoFolder?: boolean;
moveThumbnailsToVideoFolder?: boolean;
/**
* @deprecated Visitor mode is deprecated. Permission control is now based on user role.
* This field is kept for backward compatibility but is no longer used.
* Use userRole ('admin' | 'visitor') from JWT token instead.
*/
visitorMode?: boolean;
visitorPassword?: string;
infiniteScroll?: boolean;
@@ -55,5 +60,3 @@ export const defaultSettings: Settings = {
videoColumns: 4,
pauseOnFocusLoss: false,
};

View File

@@ -148,16 +148,15 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
return (
<Box>
<FormControlLabel
control={
<Switch
checked={settings.loginEnabled}
onChange={(e) => onChange('loginEnabled', e.target.checked)}
disabled={settings.visitorMode} // Locked enabled if visitor mode is on
<FormControlLabel
control={
<Switch
checked={settings.loginEnabled}
onChange={(e) => onChange('loginEnabled', e.target.checked)}
/>
}
label={t('enableLogin')}
/>
}
label={t('enableLogin')}
/>
{settings.loginEnabled && (
<Box sx={{ mt: 2, maxWidth: 400 }}>
@@ -194,53 +193,27 @@ const SecuritySettings: React.FC<SecuritySettingsProps> = ({ settings, onChange
</Typography>
</Box>
<FormControlLabel
control={
<Switch
checked={settings.visitorMode === true}
onChange={(e) => {
const enabled = e.target.checked;
onChange('visitorMode', enabled);
// Lock loginEnabled to true if visitor mode is enabled
if (enabled) {
if (!settings.loginEnabled) {
onChange('loginEnabled', true);
}
if (!settings.visitorPassword) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
const array = new Uint32Array(12);
window.crypto.getRandomValues(array);
const newPassword = Array.from(array, x => chars[x % chars.length]).join('');
onChange('visitorPassword', newPassword);
}
}
}}
disabled={!settings.loginEnabled && settings.visitorMode} // Unlock only if login is enabled? Actually user said "loginEnabled should be locked enabled". So if visitor mode is ON, loginEnabled switch (above) should be disabled or force checked.
/>
}
label={t('visitorUser') || 'Visitor User'}
/>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
{t('visitorUser') || 'Visitor User'}
</Typography>
<Box sx={{ mt: 1, mb: 2 }}>
<Typography variant="body2" color="text.secondary">
{t('visitorUserHelper') || 'Enable a restricted Visitor User role. Visitors have read-only access and cannot change settings.'}
{t('visitorUserHelper') || 'Set a password for the Visitor User role. Users logging in with this password will have read-only access and cannot change settings.'}
</Typography>
</Box>
{settings.visitorMode && (
<TextField
fullWidth
sx={{ mb: 2 }}
label={t('visitorPassword') || 'Visitor Password'}
type="text" // User said "It should be visible" - wait, "show a input ... It should be visible". Does it mean the input is visible, or the password text is visible? "let admin setup visior password. It should be visible." Usually setup inputs are passwords but maybe they want it visible to see what it is? let's stick to type="text" or "password" with show toggle. "It should be visible" likely means the input field itself appears. I will use standard password field for security but maybe default show? Or just text if implied. "It should be visible" logically refers to the input field appearing. Safe bet is standard password field behavior.
value={settings.visitorPassword || ''}
onChange={(e) => onChange('visitorPassword', e.target.value)}
helperText={
settings.isVisitorPasswordSet
? (t('visitorPasswordSetHelper') || 'Password is set. Leave empty to keep it.')
: (t('visitorPasswordHelper') || 'Password for the Visitor User to log in.')
}
/>
)}
<TextField
fullWidth
sx={{ mb: 2 }}
label={t('visitorPassword') || 'Visitor Password'}
type="text"
value={settings.visitorPassword || ''}
onChange={(e) => onChange('visitorPassword', e.target.value)}
helperText={
settings.isVisitorPasswordSet
? (t('visitorPasswordSetHelper') || 'Password is set. Leave empty to keep it.')
: (t('visitorPasswordHelper') || 'Password for the Visitor User to log in.')
}
/>
<FormControlLabel
control={

View File

@@ -1,12 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import React, { createContext, ReactNode, useContext } from 'react';
import { useAuth } from './AuthContext';
const API_URL = import.meta.env.VITE_API_URL;
/**
* @deprecated This context is kept for backward compatibility.
* Permission control is now based on userRole from AuthContext.
* Use `useAuth().userRole === 'visitor'` instead of `useVisitorMode().visitorMode`.
*/
interface VisitorModeContextType {
visitorMode: boolean;
isLoading: boolean;
@@ -14,25 +13,19 @@ interface VisitorModeContextType {
const VisitorModeContext = createContext<VisitorModeContextType>({
visitorMode: false,
isLoading: true,
isLoading: false,
});
/**
* @deprecated Use useAuth().userRole === 'visitor' instead
*/
export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { userRole } = useAuth();
const { data: settingsData, isLoading } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings`);
return response.data;
},
refetchInterval: 30000, // Refetch every 30 seconds (reduced frequency)
staleTime: 10000, // Consider data fresh for 10 seconds
gcTime: 10 * 60 * 1000, // Garbage collect after 10 minutes
});
const { userRole, checkingAuth } = useAuth();
// Visitor mode is active if enabled in settings AND user is not an admin
const visitorMode = settingsData?.visitorMode === true && userRole !== 'admin';
// Visitor mode is now based solely on userRole
// No longer depends on settings.visitorMode
const visitorMode = userRole === 'visitor';
const isLoading = checkingAuth;
return (
<VisitorModeContext.Provider value={{ visitorMode, isLoading }}>
@@ -41,6 +34,9 @@ export const VisitorModeProvider: React.FC<{ children: ReactNode }> = ({ childre
);
};
/**
* @deprecated Use useAuth().userRole === 'visitor' instead
*/
export const useVisitorMode = () => {
const context = useContext(VisitorModeContext);
if (!context) {

View File

@@ -26,7 +26,6 @@ import AlertModal from '../components/AlertModal';
import ConfirmationModal from '../components/ConfirmationModal';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useVisitorMode } from '../contexts/VisitorModeContext';
import getTheme from '../theme';
import { getWebAuthnErrorTranslationKey } from '../utils/translations';
@@ -48,7 +47,6 @@ const LoginPage: React.FC = () => {
const [resetPasswordCooldown, setResetPasswordCooldown] = useState(0); // in milliseconds
const { t } = useLanguage();
const { login } = useAuth();
const { visitorMode } = useVisitorMode();
const queryClient = useQueryClient();
// Fetch website name and settings from settings
@@ -68,6 +66,8 @@ const LoginPage: React.FC = () => {
const passwordLoginAllowed = settingsData?.passwordLoginAllowed !== false;
const allowResetPassword = settingsData?.allowResetPassword !== false;
// Show visitor tab if visitorPassword is set (no longer depends on visitorMode setting)
const showVisitorTab = !!settingsData?.isVisitorPasswordSet;
// Update website name when settings are loaded
useEffect(() => {
@@ -194,9 +194,9 @@ const LoginPage: React.FC = () => {
setAlertOpen(true);
};
const loginMutation = useMutation({
const adminLoginMutation = useMutation({
mutationFn: async (passwordToVerify: string) => {
const response = await axios.post(`${API_URL}/settings/verify-password`, { password: passwordToVerify });
const response = await axios.post(`${API_URL}/settings/verify-admin-password`, { password: passwordToVerify });
return response.data;
},
onSuccess: (data) => {
@@ -241,13 +241,55 @@ const LoginPage: React.FC = () => {
const visitorLoginMutation = useMutation({
mutationFn: async (passwordToVerify: string) => {
const response = await axios.post(`${API_URL}/settings/verify-visitor-password`, { password: passwordToVerify });
return response.data;
},
onSuccess: (data) => {
if (data.success) {
setWaitTime(0); // Reset wait time on success
login(data.token, data.role);
} else {
// Handle failures (incorrect password or too many attempts)
const statusCode = data.statusCode || 401;
const responseData = data;
if (statusCode === 429) {
// Too many attempts - wait time required
const waitTimeMs = responseData.waitTime || 0;
setWaitTime(waitTimeMs);
const formattedTime = formatWaitTime(waitTimeMs);
showAlert(t('error'), `${t('tooManyAttempts')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`);
} else if (statusCode === 401) {
// Incorrect password - check if wait time is returned
const waitTimeMs = responseData.waitTime || 0;
if (waitTimeMs > 0) {
setWaitTime(waitTimeMs);
const formattedTime = formatWaitTime(waitTimeMs);
showAlert(t('error'), `${t('incorrectPassword')} ${t('waitTimeMessage').replace('{time}', formattedTime)}`);
} else {
showAlert(t('error'), t('incorrectPassword'));
}
} else {
showAlert(t('error'), t('loginFailed'));
}
}
},
onError: (err: any) => {
console.error('Login error:', err);
// Handle actual network errors or unexpected 500s
showAlert(t('error'), t('loginFailed'));
}
});
const handleVisitorSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (waitTime > 0) {
return;
}
setError('');
loginMutation.mutate(visitorPassword);
visitorLoginMutation.mutate(visitorPassword);
}
const resetPasswordMutation = useMutation({
@@ -341,7 +383,7 @@ const LoginPage: React.FC = () => {
return; // Don't allow submission if wait time is active
}
setError('');
loginMutation.mutate(password);
adminLoginMutation.mutate(password);
};
const handleResetPassword = () => {
@@ -423,12 +465,7 @@ const LoginPage: React.FC = () => {
<Typography variant="h4" sx={{ fontWeight: 'bold', lineHeight: 1 }}>
{websiteName}
</Typography>
{visitorMode && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.75rem', lineHeight: 1.2, mt: 0.5 }}>
{t('visitorMode')}
</Typography>
)}
{websiteName !== 'MyTube' && !visitorMode && (
{websiteName !== 'MyTube' && (
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem', lineHeight: 1.2, mt: 0.25 }}>
Powered by MyTube
</Typography>
@@ -442,7 +479,7 @@ const LoginPage: React.FC = () => {
</Typography>
</Box>
<Box sx={{ mt: 1, width: '100%' }}>
{visitorMode && (
{showVisitorTab && (
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
<Tabs value={activeTab} onChange={(_: React.SyntheticEvent, newValue: number) => setActiveTab(newValue)} aria-label="login tabs" variant="fullWidth">
<Tab label={t('admin') || 'Admin'} id="login-tab-0" aria-controls="login-tabpanel-0" />
@@ -451,14 +488,14 @@ const LoginPage: React.FC = () => {
</Box>
)}
{/* Admin Tab Panel (and default view when visitor mode is off) */}
{/* Admin Tab Panel (and default view when visitor tab is not shown) */}
<div
role="tabpanel"
hidden={visitorMode && activeTab !== 0}
hidden={showVisitorTab && activeTab !== 0}
id="login-tabpanel-0"
aria-labelledby="login-tab-0"
>
{(visitorMode ? activeTab === 0 : true) && (
{(showVisitorTab ? activeTab === 0 : true) && (
<>
{passwordLoginAllowed && (
<Box component="form" onSubmit={handleSubmit} noValidate>
@@ -473,8 +510,8 @@ const LoginPage: React.FC = () => {
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus={!visitorMode || activeTab === 0}
disabled={waitTime > 0 || loginMutation.isPending}
autoFocus={!showVisitorTab || activeTab === 0}
disabled={waitTime > 0 || adminLoginMutation.isPending}
helperText={t('defaultPasswordHint') || "Default password: 123"}
slotProps={{
input: {
@@ -497,9 +534,9 @@ const LoginPage: React.FC = () => {
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loginMutation.isPending || waitTime > 0}
disabled={adminLoginMutation.isPending || waitTime > 0}
>
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('signIn') || 'Admin Sign In')}
{adminLoginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('signIn') || 'Admin Sign In')}
</Button>
</Box>
)}
@@ -577,7 +614,7 @@ const LoginPage: React.FC = () => {
</div>
{/* Visitor Tab Panel */}
{visitorMode && (
{showVisitorTab && (
<div
role="tabpanel"
hidden={activeTab !== 1}
@@ -597,7 +634,7 @@ const LoginPage: React.FC = () => {
value={visitorPassword}
onChange={(e) => setVisitorPassword(e.target.value)}
autoFocus={activeTab === 1}
disabled={waitTime > 0 || loginMutation.isPending}
disabled={waitTime > 0 || visitorLoginMutation.isPending}
slotProps={{
input: {
endAdornment: (
@@ -619,9 +656,9 @@ const LoginPage: React.FC = () => {
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loginMutation.isPending || waitTime > 0}
disabled={visitorLoginMutation.isPending || waitTime > 0}
>
{loginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('visitorSignIn') || 'Visitor Sign In')}
{visitorLoginMutation.isPending ? (t('verifying') || 'Verifying...') : (t('visitorSignIn') || 'Visitor Sign In')}
</Button>
</Box>
)}