refactor: remove ApiKeyGuard and update AuthGuard logic

- Deleted the ApiKeyGuard as it was no longer needed.
- Updated AuthGuard to handle session validation more efficiently by checking for a session before assigning user data.
- Enhanced role verification logic to ensure proper authorization checks.
- Introduced Onboarding module with controllers and services for managing onboarding processes.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-10-23 21:03:57 +08:00
parent 70dea4e911
commit 46d71a2947
11 changed files with 164 additions and 477 deletions

View File

@@ -1,22 +0,0 @@
import { env } from '@afilmory/env'
import type { CanActivate, ExecutionContext } from '@afilmory/framework'
import { UnauthorizedException } from '@afilmory/framework'
import { injectable } from 'tsyringe'
@injectable()
export class ApiKeyGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const { hono } = context.getContext()
const apiKey = hono.req.header('x-api-key')
const expected = env.API_KEY ?? 'secret-key'
if (apiKey !== expected) {
throw new UnauthorizedException({
statusCode: 401,
message: 'Invalid API key',
})
}
return true
}
}

View File

@@ -4,7 +4,8 @@ import type { Session } from 'better-auth'
import { BizException, ErrorCode } from 'core/errors'
import { injectable } from 'tsyringe'
import { AuthProvider, AuthSession } from '../modules/auth/auth.provider'
import type { AuthSession } from '../modules/auth/auth.provider'
import { AuthProvider } from '../modules/auth/auth.provider'
import { getAllowedRoleMask, roleNameToBit } from './roles.decorator'
declare module '@afilmory/framework' {
@@ -24,24 +25,26 @@ export class AuthGuard implements CanActivate {
const store = context.getContext()
const { hono } = store
const auth = this.authProvider.getAuth()
const auth = await this.authProvider.getAuth()
const session = await auth.api.getSession({ headers: hono.req.raw.headers })
if (!session) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
if (session) {
HttpContext.assign({
auth: {
user: session.user,
session: session.session,
},
})
}
HttpContext.assign({
auth: {
user: session.user,
session: session.session,
},
})
// Role verification if decorator is present
const handler = context.getHandler()
const requiredMask = getAllowedRoleMask(handler)
if (requiredMask > 0) {
if (!session) {
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
}
const userRoleName = session.user.role as 'user' | 'admin' | undefined
const userMask = userRoleName ? roleNameToBit(userRoleName) : 0
const hasRole = (requiredMask & userMask) !== 0

View File

@@ -1,22 +1,27 @@
import 'reflect-metadata'
import { applyDecorators } from '@afilmory/framework'
export const ROLES_METADATA = Symbol.for('core.auth.allowed_roles')
export enum RoleBit {
GUEST = 0,
USER = 1 << 0,
ADMIN = 1 << 1,
}
export type RoleName = 'user' | 'admin'
export type RoleName = 'user' | 'admin' | (string & {})
export function roleNameToBit(name: RoleName): RoleBit {
export function roleNameToBit(name?: RoleName): RoleBit {
switch (name) {
case 'admin': {
return RoleBit.ADMIN
return RoleBit.ADMIN | RoleBit.USER | RoleBit.GUEST
}
case 'user': {
return RoleBit.USER | RoleBit.GUEST
}
default: {
return RoleBit.USER
return RoleBit.GUEST
}
}
}
@@ -24,10 +29,10 @@ export function roleNameToBit(name: RoleName): RoleBit {
export function Roles(...roles: Array<RoleBit | RoleName>): MethodDecorator & ClassDecorator {
const mask = roles.map((r) => (typeof r === 'string' ? roleNameToBit(r) : r)).reduce((m, r) => m | r, 0)
return (target: object, propertyKey?: string | symbol, descriptor?: PropertyDescriptor) => {
return applyDecorators((target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const targetForMetadata = descriptor?.value && typeof descriptor.value === 'function' ? descriptor.value : target
Reflect.defineMetadata(ROLES_METADATA, mask, targetForMetadata)
}
})
}
export function getAllowedRoleMask(target: object): number {

View File

@@ -1,7 +1,6 @@
import { Body, ContextParam, Controller, Get, Post, UnauthorizedException, UseGuards } from '@afilmory/framework'
import { Body, ContextParam, Controller, Get, Post, UnauthorizedException } from '@afilmory/framework'
import type { Context } from 'hono'
import { AuthGuard } from '../../guards/auth.guard'
import { RoleBit, Roles } from '../../guards/roles.decorator'
import { AuthProvider } from './auth.provider'
@@ -19,24 +18,6 @@ export class AuthController {
return { user: session.user, session: session.session }
}
@Post('/sign-up/email')
async signUpEmail(
@ContextParam() _context: Context,
@Body() body: { name: string; email: string; password: string },
) {
const auth = this.auth.getAuth()
const res = await auth.api.signUpEmail({
body: {
name: body.name,
email: body.email,
password: body.password,
},
})
return res
}
@Post('/sign-in/email')
async signInEmail(@ContextParam() _context: Context, @Body() body: { email: string; password: string }) {
const auth = this.auth.getAuth()
@@ -51,7 +32,6 @@ export class AuthController {
}
@Get('/admin-only')
@UseGuards(AuthGuard)
@Roles(RoleBit.ADMIN)
async adminOnly(@ContextParam() _context: Context) {
return { ok: true }

View File

@@ -7,6 +7,7 @@ import { RedisAccessor } from 'core/redis/redis.provider'
import { DatabaseModule } from '../database/database.module'
import { RedisModule } from '../redis/redis.module'
import { AuthModule } from './auth/auth.module'
import { OnboardingModule } from './onboarding/onboarding.module'
import { PhotoModule } from './photo/photo.module'
import { SettingModule } from './setting/setting.module'
@@ -16,6 +17,7 @@ import { SettingModule } from './setting/setting.module'
RedisModule,
AuthModule,
SettingModule,
OnboardingModule,
PhotoModule,
EventModule.forRootAsync({
useFactory: async (redis: RedisAccessor) => {

View File

@@ -0,0 +1,26 @@
import { Body, Controller, Get, Post } from '@afilmory/framework'
import { BizException, ErrorCode } from 'core/errors'
import { OnboardingInitDto } from './onboarding.dto'
import { OnboardingService } from './onboarding.service'
@Controller('onboarding')
export class OnboardingController {
constructor(private readonly service: OnboardingService) {}
@Get('/status')
async getStatus() {
const initialized = await this.service.isInitialized()
return { initialized }
}
@Post('/init')
async initialize(@Body() dto: OnboardingInitDto) {
const initialized = await this.service.isInitialized()
if (initialized) {
throw new BizException(ErrorCode.COMMON_CONFLICT, { message: 'Already initialized' })
}
const result = await this.service.initialize(dto)
return { ok: true, adminUserId: result.adminUserId }
}
}

View File

@@ -0,0 +1,39 @@
import { createZodDto } from '@afilmory/framework'
import { z } from 'zod'
import { SETTING_SCHEMAS, SettingKeys } from '../setting/setting.constant'
const adminSchema = z.object({
email: z.email(),
password: z.string().min(8),
name: z.string().min(1),
})
const keySchema = z.enum(SettingKeys)
const settingEntrySchema = z.object({
key: keySchema,
value: z.unknown(),
})
const normalizeEntries = z
.union([settingEntrySchema, z.object({ entries: z.array(settingEntrySchema).min(1) })])
.transform((payload) => {
const entries = 'entries' in payload ? payload.entries : [payload]
return entries.map((entry) => ({
key: entry.key,
value: SETTING_SCHEMAS[entry.key].parse(entry.value),
}))
})
export class OnboardingInitDto extends createZodDto(
z.object({
admin: adminSchema,
settings: normalizeEntries.optional().transform((entries) => entries ?? []),
}),
) {}
export type NormalizedSettingEntry = {
key: z.infer<typeof keySchema>
value: unknown
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@afilmory/framework'
import { DatabaseModule } from '../../database/database.module'
import { AuthModule } from '../auth/auth.module'
import { SettingModule } from '../setting/setting.module'
import { OnboardingController } from './onboarding.controller'
import { OnboardingService } from './onboarding.service'
@Module({
imports: [DatabaseModule, AuthModule, SettingModule],
providers: [OnboardingService],
controllers: [OnboardingController],
})
export class OnboardingModule {}

View File

@@ -0,0 +1,55 @@
import { authUsers } from '@afilmory/db'
import { eq } from 'drizzle-orm'
import { injectable } from 'tsyringe'
import { DbAccessor } from '../../database/database.provider'
import { AuthProvider } from '../auth/auth.provider'
import { SettingService } from '../setting/setting.service'
import type { NormalizedSettingEntry, OnboardingInitDto } from './onboarding.dto'
@injectable()
export class OnboardingService {
constructor(
private readonly db: DbAccessor,
private readonly auth: AuthProvider,
private readonly settings: SettingService,
) {}
async isInitialized(): Promise<boolean> {
const db = this.db.get()
const [user] = await db.select().from(authUsers).limit(1)
return Boolean(user)
}
async initialize(payload: OnboardingInitDto): Promise<{ adminUserId: string }> {
const already = await this.isInitialized()
if (already) {
return { adminUserId: 'already-initialized' }
}
const auth = this.auth.getAuth()
// Create admin via better-auth email/password
const result = await auth.api.signUpEmail({
body: {
email: payload.admin.email,
password: payload.admin.password,
name: payload.admin.name,
},
})
const userId = result.user.id
// Promote to admin if not already
const db = this.db.get()
await db.update(authUsers).set({ role: 'admin' }).where(eq(authUsers.id, userId))
// Apply initial settings
const entries = (payload.settings as unknown as NormalizedSettingEntry[]) ?? []
if (entries.length > 0) {
await this.settings.setMany(entries as any)
}
return { adminUserId: userId }
}
}

View File

@@ -1,168 +0,0 @@
# ActionGroup 优化方案
## 当前问题分析
### 现状
- **5 个操作按钮**水平排列:搜索、地图、过滤器、列数、排序
- **圆形按钮设计**占据较大空间(每个 40px + 12px gap
- **扩展性受限**:无法继续添加新功能按钮
- **桌面端空间浪费**:按钮过于分散
---
## 方案 1分组下拉设计 ⭐️ 推荐
### 设计思路
将功能按使用场景分组,减少顶层按钮数量:
**分组逻辑:**
1. **搜索**Search- 独立按钮
2. **探索**Explore- 独立按钮(地图)
3. **筛选**Filter- 独立按钮(标签、相机、镜头、评分)
4. **视图**View- **合并按钮**(排序 + 列数 + 布局模式)
### 优点
- ✅ 按钮数量从 5 个减少到 **4 个**
- ✅ 功能分组符合用户心智模型
- ✅ 为未来功能预留扩展空间
- ✅ 视图设置集中管理,逻辑清晰
### 实现细节
```typescript
// View 按钮下拉内容
<ViewPanel>
<Section title="排序">
<SortOptions /> // 最新优先 / 最早优先
</Section>
<Divider />
<Section title="列数">
<ColumnsSlider /> // 2-8 列滑块
</Section>
<Divider />
<Section title="布局">
<LayoutOptions /> // Masonry / Grid (未来扩展)
</Section>
</ViewPanel>
```
---
## 方案 2紧凑工具栏模式
### 设计思路
使用更紧凑的按钮样式,添加视觉分隔符:
```
[搜索] [地图] | [过滤器] [排序] [列数] | [•••更多]
```
### 优点
- ✅ 所有功能一目了然
- ✅ 分组视觉清晰(分隔符)
- ✅ 更多菜单提供扩展性
### 缺点
- ❌ 按钮仍然较多,空间利用不够高效
- ❌ 移动端适配困难
---
## 方案 3双层菜单设计
### 设计思路
主要按钮 + 次要功能折叠:
**第一层(常驻):**
- 搜索、过滤器、更多菜单
**第二层(更多菜单):**
- 地图探索
- 视图设置(排序、列数)
- 其他设置
### 优点
- ✅ 顶层按钮最少3 个)
- ✅ 扩展性最强
### 缺点
- ❌ 次要功能可发现性降低
- ❌ 需要额外点击层级
---
## 方案 4智能响应式设计
### 设计思路
根据屏幕宽度动态调整按钮显示:
**宽屏(>1280px**
显示全部 5 个按钮
**中等屏幕768-1280px**
显示 3 个常用按钮 + 更多菜单
**窄屏(<768px**
移动端抽屉模式(当前实现)
### 优点
- ✅ 充分利用屏幕空间
- ✅ 不同设备最佳体验
### 缺点
- ❌ 实现复杂度高
- ❌ 用户体验不一致
---
## 推荐方案对比
| 方案 | 按钮数量 | 扩展性 | 用户体验 | 实现难度 |
|------|---------|--------|----------|----------|
| **方案 1 分组下拉** | ⭐️⭐️⭐️⭐️ 4个 | ⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️ 中等 |
| 方案 2 紧凑工具栏 | ⭐️⭐️ 6+个 | ⭐️⭐️⭐️ | ⭐️⭐️⭐️⭐️ | ⭐️⭐️ 简单 |
| 方案 3 双层菜单 | ⭐️⭐️⭐️⭐️⭐️ 3个 | ⭐️⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️ | ⭐️⭐️⭐️ 中等 |
| 方案 4 智能响应式 | ⭐️⭐️⭐️ 动态 | ⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️⭐️ 复杂 |
---
## 最终建议
**推荐实现方案 1 + 方案 3 的混合版本:**
### 桌面端Desktop
```
[搜索] [探索] [筛选 (badge)] [视图] [•••]
```
- **搜索**:全局照片搜索
- **探索**:地图视图(导航到 /explory
- **筛选**:标签、相机、镜头、评分过滤(显示激活数量 badge
- **视图**:排序 + 列数 + 布局设置
- **更多**:未来功能(导出、设置等)
### 移动端Mobile
保持当前抽屉设计,但可以将"视图"合并为统一入口
---
## 实现优先级
1. **Phase 1当前 PR**:实现方案 1 - 合并排序和列数为"视图"按钮
2. **Phase 2未来**:添加"更多"菜单,为新功能预留空间
3. **Phase 3优化**:根据用户反馈调整分组逻辑
---
## 国际化文案建议
```json
// locales/app/en.json
{
"action.view.title": "View Settings",
"action.view.layout": "Layout",
"action.view.layout.masonry": "Masonry",
"action.view.layout.grid": "Grid",
"action.more.title": "More Options",
"action.explore.title": "Explore Map"
}
```

View File

@@ -1,247 +0,0 @@
# ActionGroup 优化实现总结
## 📋 实现的改动
### 1. **合并搜索和过滤功能**
#### 之前的设计问题:
- ❌ 搜索和过滤是两个独立按钮
- ❌ Filter 中使用 Tab 切换(标签、相机、镜头、评分)
- ❌ 功能分散,操作层级过深
- ❌ 按钮数量过多5个桌面端空间不足
#### 优化后的设计:
-**统一搜索面板** (`UnifiedSearchPanel`)
- ✅ 使用**预设按钮**替代 Tab 界面
- ✅ 顶部快速切换:全部、标签、相机、镜头、评分
- ✅ 减少顶层按钮至 **3 个**:搜索/过滤、地图、视图
---
## 🎨 新的 UI 结构
### 按钮布局(从左到右)
```
[🔍 搜索/过滤] [🗺️ 地图探索] [⚙️ 视图设置]
```
### 统一搜索面板布局
```
┌─────────────────────────────────────┐
│ 搜索和筛选 [清除] │
├─────────────────────────────────────┤
│ [搜索输入框] │
├─────────────────────────────────────┤
│ [全部] [标签²] [相机¹] [镜头] [评分] │ ← 预设按钮
├─────────────────────────────────────┤
│ │
│ 【根据预设显示不同内容】 │
│ │
│ - 全部: 照片搜索结果 │
│ - 标签: 标签列表 + 匹配模式 │
│ - 相机: 相机列表 │
│ - 镜头: 镜头列表 │
│ - 评分: 星级评分选择器 │
│ │
└─────────────────────────────────────┘
```
---
## 📁 新增文件
### `apps/web/src/components/gallery/UnifiedSearchPanel.tsx`
**核心组件:**
- `UnifiedSearchPanel` - 主面板组件
- `FilterList` - 通用过滤列表组件
- `StarRating` - 星级评分组件
**关键特性:**
1. **预设系统**5 种预设(全部、标签、相机、镜头、评分)
2. **统一搜索**全局照片搜索标题、描述、标签、EXIF
3. **快速过滤**:每个类别独立的项目搜索
4. **智能 Badge**:显示激活的过滤器数量
5. **一键清除**:重置所有搜索和过滤条件
---
## 🔄 修改的文件
### 1. `apps/web/src/modules/gallery/ActionGroup.tsx`
**主要改动:**
- ✅ 导入 `UnifiedSearchPanel` 替代 `FilterPanel`
- ✅ 移除独立的"过滤"按钮
- ✅ 移除旧的 `SearchPanel` 组件
- ✅ 合并搜索和过滤逻辑
- ✅ 更新 badge 计算逻辑
**Button 减少:**
```diff
- [搜索] [地图] [过滤] [列数] [排序] // 5个按钮
+ [搜索/过滤] [地图] [视图] // 3个按钮
```
### 2. 国际化文件i18n
**新增翻译键:**
```json
{
"action.search.unified.title": "Search & Filter",
"action.search.preset.all": "All",
"action.search.filter.placeholder": "Filter items...",
"action.search.filter.no-results": "No matches found"
}
```
**支持的语言:**
- ✅ English (`en.json`)
- ✅ 简体中文 (`zh-CN.json`)
- ✅ 繁体中文-台湾 (`zh-TW.json`)
- ✅ 繁体中文-香港 (`zh-HK.json`)
- ✅ 日本語 (`jp.json`)
- ✅ 한국어 (`ko.json`)
---
## 🎯 用户体验提升
### 操作流程简化
**之前:**
1. 点击"过滤"按钮
2. 点击 Tab 切换类别(标签/相机/镜头/评分)
3. 搜索或选择项目
4. 返回,点击"搜索"按钮
5. 输入关键词
**现在:**
1. 点击"搜索/过滤"按钮
2. 输入关键词(全局搜索)或点击预设
3. 立即查看结果
### 交互优化
1. **预设按钮**
- 一键切换不同过滤类型
- Badge 显示激活数量
- 激活状态有明显视觉反馈
2. **搜索体验**
- 全局搜索匹配标题、描述、标签、EXIF
- 类别搜索:在列表中快速筛选
- 实时结果展示
3. **Badge 系统**
- 搜索激活:显示 `●`
- 过滤激活:显示数字(如 `3`
- 一目了然的状态提示
---
## 💡 设计理念
### 信息架构优化
**旧架构(平铺):**
```
搜索 | 地图 | 过滤(Tab:标签/相机/镜头/评分) | 列数 | 排序
```
**新架构(分组):**
```
搜索/过滤(预设:全部/标签/相机/镜头/评分) | 地图 | 视图(排序+列数)
```
### 原则:
1. **功能聚合**:相关功能合并到同一入口
2. **预设替代 Tab**:减少交互层级
3. **扁平化**:主要操作一步到位
4. **扩展性**:预设系统易于添加新类型
---
## 🔮 未来扩展方向
### 可能的新预设
- 📅 **日期范围**:按拍摄日期筛选
- 📍 **地理位置**:按拍摄地点筛选
- 🎨 **颜色**:按主色调筛选
- 📸 **参数**按光圈、快门、ISO 筛选
### 可能的功能增强
- 💾 **保存预设**:自定义常用过滤组合
- 🔄 **搜索历史**:快速访问之前的搜索
- 🏷️ **智能建议**:根据输入提示相关标签
---
## 📊 技术实现亮点
### 1. 组件复用
```typescript
<FilterList
items={allTags}
selectedItems={selectedTags}
onToggle={toggleTag}
showMatchMode // 仅标签支持
/>
```
### 2. 状态管理
- 使用 Jotai 全局状态
- 预设切换本地状态
- 过滤器持久化
### 3. 性能优化
- `useMemo` 缓存搜索结果
- `useCallback` 防止重渲染
- 虚拟滚动(如需要可添加)
### 4. 响应式设计
- 桌面端:下拉菜单
- 移动端:底部抽屉
- 自适应宽度和高度
---
## ✅ 实现检查清单
- [x] 创建 `UnifiedSearchPanel` 组件
- [x] 更新 `ActionGroup` 使用新面板
- [x] 移除旧的 Tab 界面
- [x] 添加预设按钮系统
- [x] 实现过滤列表组件
- [x] 添加国际化支持6种语言
- [x] 更新 badge 逻辑
- [x] 保持响应式设计
- [x] 创建设计文档
---
## 📝 使用指南
### 对于用户
1. 点击搜索图标打开面板
2. 输入关键词或点击预设按钮
3. 查看结果或选择过滤项
4. Badge 显示激活的过滤器数量
### 对于开发者
1. 新预设添加位置:`UnifiedSearchPanel.tsx``presets` 数组
2. 修改过滤逻辑:更新 `FilterList` 组件
3. 添加新的过滤类型:扩展 `SearchPreset` 类型
---
## 🎉 总结
这次优化成功地:
-**简化了 UI**:从 5 个按钮减少到 3 个
-**提升了效率**:减少点击层级
-**增强了扩展性**:预设系统易于添加新功能
-**改善了体验**:搜索和过滤统一入口
-**保持了一致性**:与现有设计风格匹配
预设按钮系统比 Tab 界面更加直观和高效!🚀