feat(security): 新增桌面端应用锁

新增 4 位 PIN、手动锁定、启动锁定和系统闲置锁定。

新增主进程与 preload 能力、桌面端设置面板、全局锁屏覆盖层及四语言界面。

Fixes #190

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
xyz-225648andClaude authored and digua committed 2026-07-18 11:49:09 +08:00
1 parent 32bd268af4
commit 84b371e73d
17 files changed
+1115 -6

No files matched your search

+7
View File
@@ -10,6 +10,7 @@ import { initAnalytics } from './analytics'
import { logger } from './logger'
import { prepareDesktopRuntime } from './app/bootstrap'
import { createMainWindow, markAppQuitting } from './window/main-window'
import { initLockManager, cleanupLockManager } from './security/lock-manager'
class MainProcess {
mainWindow: BrowserWindow | null
@@ -104,6 +105,11 @@ class MainProcess {
console.log('[Main] Registering IPC handlers...')
mainIpcMain(this.mainWindow)
console.log('[Main] IPC handlers registered')
// 初始化应用锁(在 IPC 注册之后)
console.log('[Main] Initializing app lock...')
initLockManager(this.mainWindow)
console.log('[Main] App lock initialized')
}
app.on('browser-window-created', (_, window) => {
@@ -144,6 +150,7 @@ class MainProcess {
app.on('will-quit', () => {
stopInternalServer().catch(() => {})
cleanup()
cleanupLockManager()
})
})
}
+2
View File
@@ -15,6 +15,7 @@ import { registerNetworkHandlers } from './network'
import { registerAnalyticsHandlers } from '../analytics'
import { registerApiHandlers, initApiServer, cleanupApiServer } from './api'
import { registerDemoHandlers } from './demo'
import { registerSecurityHandlers } from './security'
import { cleanupArchiveImportSources } from '../import/archive-source-runtime'
// 导入 Worker 模块(用于异步分析查询和流式导入)
import * as worker from '../worker/workerManager'
@@ -46,6 +47,7 @@ const mainIpcMain = (win: BrowserWindow) => {
registerAnalyticsHandlers()
registerApiHandlers(context)
registerDemoHandlers(context)
registerSecurityHandlers(context)
// 启动 ChatLab API 服务(异步,不阻塞 IPC 注册)
initApiServer(context).catch((err) => {
+25
View File
@@ -0,0 +1,25 @@
import { ipcMain } from 'electron'
import type { IpcContext } from './types'
import {
changePassword,
getLockConfig,
getLockState,
lockApp,
resetAppLockPassword,
setPassword,
unlockApp,
updateLockConfig,
} from '../security/lock-manager'
export function registerSecurityHandlers(_ctx: IpcContext): void {
ipcMain.handle('app-lock:getConfig', getLockConfig)
ipcMain.handle('app-lock:getState', getLockState)
ipcMain.handle('app-lock:lock', lockApp)
ipcMain.handle('app-lock:unlock', (_event, password: unknown) => unlockApp(password))
ipcMain.handle('app-lock:setPassword', (_event, password: unknown) => setPassword(password))
ipcMain.handle('app-lock:changePassword', (_event, oldPassword: unknown, newPassword: unknown) =>
changePassword(oldPassword, newPassword)
)
ipcMain.handle('app-lock:resetPassword', resetAppLockPassword)
ipcMain.handle('app-lock:updateConfig', (_event, updates: unknown) => updateLockConfig(updates))
}
@@ -0,0 +1,52 @@
import * as crypto from 'node:crypto'
const PBKDF2_ITERATIONS = 600000
const SALT_LENGTH = 32
const HASH_LENGTH = 64
const HEX_PATTERN = /^[0-9a-f]+$/i
export interface PasswordHash {
hash: string
salt: string
version: 1
}
export function isPasswordHash(value: unknown): value is PasswordHash {
if (!value || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return (
record.version === 1 &&
typeof record.hash === 'string' &&
record.hash.length === HASH_LENGTH * 2 &&
HEX_PATTERN.test(record.hash) &&
typeof record.salt === 'string' &&
record.salt.length === SALT_LENGTH * 2 &&
HEX_PATTERN.test(record.salt)
)
}
function derivePassword(password: string, salt: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
crypto.pbkdf2(password, salt, PBKDF2_ITERATIONS, HASH_LENGTH, 'sha512', (error, hash) => {
if (error) reject(error)
else resolve(hash)
})
})
}
export async function hashPassword(password: string): Promise<PasswordHash> {
const salt = crypto.randomBytes(SALT_LENGTH)
const hash = await derivePassword(password, salt)
return {
hash: hash.toString('hex'),
salt: salt.toString('hex'),
version: 1,
}
}
export async function verifyPasswordHash(password: string, stored: PasswordHash): Promise<boolean> {
if (!isPasswordHash(stored)) return false
const expected = Buffer.from(stored.hash, 'hex')
const actual = await derivePassword(password, Buffer.from(stored.salt, 'hex'))
return crypto.timingSafeEqual(actual, expected)
}
+336
View File
@@ -0,0 +1,336 @@
import { powerMonitor, type BrowserWindow } from 'electron'
import * as fs from 'node:fs'
import { join } from 'node:path'
import type {
AppLockConfig,
AppLockConfigUpdate,
AppLockResult,
AppLockState,
AppLockUnlockResult,
} from '../../shared/types'
import { logger } from '../logger'
import { getPathProvider } from '../paths/provider'
import { hashPassword, isPasswordHash, verifyPasswordHash, type PasswordHash } from './crypto-utils'
interface LockSettings {
idleTimeoutMinutes: number
lockOnStartup: boolean
}
const LOCK_CONFIG_FILE = 'app-lock.json'
const LOCK_FLAG_FILE = '.app-lock-flag'
const PIN_PATTERN = /^\d{4}$/
const IDLE_CHECK_INTERVAL_MS = 10000
const IDLE_TIMEOUT_OPTIONS = new Set([0, 1, 3, 5, 10, 15, 30, 60])
const DEFAULT_LOCK_SETTINGS: LockSettings = {
idleTimeoutMinutes: 0,
lockOnStartup: false,
}
let lockState: AppLockState = 'unlocked'
let currentSettings: LockSettings = { ...DEFAULT_LOCK_SETTINGS }
let storedPasswordHash: PasswordHash | null = null
let idleTimer: ReturnType<typeof setInterval> | null = null
let isTransitioning = false
let mainWindowRef: BrowserWindow | null = null
function getSettingsDir(): string {
return getPathProvider().getSettingsDir()
}
function getLockConfigPath(): string {
return join(getSettingsDir(), LOCK_CONFIG_FILE)
}
function getLockFlagPath(): string {
return join(getSettingsDir(), LOCK_FLAG_FILE)
}
function isValidIdleTimeout(value: unknown): value is number {
return typeof value === 'number' && IDLE_TIMEOUT_OPTIONS.has(value)
}
function loadConfig(): boolean {
currentSettings = { ...DEFAULT_LOCK_SETTINGS }
storedPasswordHash = null
try {
const configPath = getLockConfigPath()
if (!fs.existsSync(configPath)) return false
const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as unknown
if (!data || typeof data !== 'object' || Array.isArray(data)) {
logger.warn('App lock config ignored because it is not an object')
return false
}
const record = data as Record<string, unknown>
currentSettings = {
idleTimeoutMinutes: isValidIdleTimeout(record.idleTimeoutMinutes)
? record.idleTimeoutMinutes
: DEFAULT_LOCK_SETTINGS.idleTimeoutMinutes,
lockOnStartup:
typeof record.lockOnStartup === 'boolean' ? record.lockOnStartup : DEFAULT_LOCK_SETTINGS.lockOnStartup,
}
storedPasswordHash = isPasswordHash(record.passwordHash) ? record.passwordHash : null
return true
} catch (error) {
logger.error(`App lock config ignored because it is invalid: ${error instanceof Error ? error.message : error}`)
return false
}
}
function saveConfig(settings: LockSettings, passwordHash: PasswordHash | null): boolean {
const configPath = getLockConfigPath()
try {
fs.mkdirSync(getSettingsDir(), { recursive: true })
const record: Record<string, unknown> = { ...settings }
if (passwordHash) record.passwordHash = passwordHash
fs.writeFileSync(configPath, JSON.stringify(record, null, 2), 'utf-8')
return true
} catch (error) {
logger.error(`Failed to save app lock config: ${error instanceof Error ? error.message : error}`)
return false
}
}
function setLockFlag(): boolean {
try {
fs.writeFileSync(getLockFlagPath(), Date.now().toString(), 'utf-8')
return true
} catch (error) {
logger.error(`Failed to write app lock flag: ${error instanceof Error ? error.message : error}`)
return false
}
}
function clearLockFlag(): boolean {
try {
const flagPath = getLockFlagPath()
if (fs.existsSync(flagPath)) fs.unlinkSync(flagPath)
return true
} catch (error) {
logger.error(`Failed to clear app lock flag: ${error instanceof Error ? error.message : error}`)
return false
}
}
function hasLockFlag(): boolean {
try {
return fs.existsSync(getLockFlagPath())
} catch {
return false
}
}
function isEnabled(): boolean {
return storedPasswordHash !== null
}
function notifyLockState(): void {
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
mainWindowRef.webContents.send('app-lock-state-changed', lockState === 'locked')
}
}
export function initLockManager(mainWindow: BrowserWindow): void {
mainWindowRef = mainWindow
lockState = 'unlocked'
isTransitioning = false
const loaded = loadConfig()
if (!loaded || !isEnabled()) clearLockFlag()
const wasLocked = isEnabled() && hasLockFlag()
logger.info(`App lock initialized: enabled=${isEnabled()}, wasLocked=${wasLocked}`)
if (isEnabled() && (currentSettings.lockOnStartup || wasLocked)) {
lockApp()
} else if (isEnabled() && currentSettings.idleTimeoutMinutes > 0) {
startIdleTimer()
}
}
export function lockApp(): AppLockResult {
if (lockState === 'locked') return { success: true }
if (isTransitioning) return { success: false, error: 'busy' }
if (!isEnabled()) return { success: false, error: 'disabled' }
isTransitioning = true
try {
if (!setLockFlag()) return { success: false, error: 'save-failed' }
lockState = 'locked'
stopIdleTimer()
logger.info('App locked')
notifyLockState()
return { success: true }
} finally {
isTransitioning = false
}
}
export async function unlockApp(password: unknown): Promise<AppLockUnlockResult> {
if (lockState === 'unlocked') return { success: true }
if (isTransitioning) return { success: false, error: 'busy' }
if (!storedPasswordHash) return { success: false, error: 'password-not-set' }
if (typeof password !== 'string' || !PIN_PATTERN.test(password)) {
return { success: false, error: 'wrong-password', wrongPassword: true }
}
isTransitioning = true
try {
if (!(await verifyPasswordHash(password, storedPasswordHash))) {
return { success: false, error: 'wrong-password', wrongPassword: true }
}
clearLockFlag()
lockState = 'unlocked'
logger.info('App unlocked')
notifyLockState()
if (currentSettings.idleTimeoutMinutes > 0) startIdleTimer()
return { success: true }
} finally {
isTransitioning = false
}
}
export async function setPassword(newPassword: unknown): Promise<AppLockResult> {
if (lockState === 'locked') return { success: false, error: 'locked' }
if (isTransitioning) return { success: false, error: 'busy' }
if (typeof newPassword !== 'string' || !PIN_PATTERN.test(newPassword)) {
return { success: false, error: 'invalid-pin' }
}
if (storedPasswordHash) return { success: false, error: 'password-already-set' }
isTransitioning = true
try {
const passwordHash = await hashPassword(newPassword)
if (!saveConfig(currentSettings, passwordHash)) return { success: false, error: 'save-failed' }
storedPasswordHash = passwordHash
if (currentSettings.idleTimeoutMinutes > 0) startIdleTimer()
logger.info('App lock password initialized')
return { success: true }
} catch (error) {
logger.error(`Failed to set app lock password: ${error instanceof Error ? error.message : error}`)
return { success: false, error: 'internal-error' }
} finally {
isTransitioning = false
}
}
export async function changePassword(oldPassword: unknown, newPassword: unknown): Promise<AppLockResult> {
if (lockState === 'locked') return { success: false, error: 'locked' }
if (isTransitioning) return { success: false, error: 'busy' }
if (!storedPasswordHash) return { success: false, error: 'password-not-set' }
if (typeof oldPassword !== 'string' || typeof newPassword !== 'string' || !PIN_PATTERN.test(newPassword)) {
return { success: false, error: 'invalid-pin' }
}
if (oldPassword === newPassword) return { success: false, error: 'same-password' }
isTransitioning = true
try {
if (!(await verifyPasswordHash(oldPassword, storedPasswordHash))) {
return { success: false, error: 'wrong-password' }
}
const passwordHash = await hashPassword(newPassword)
if (!saveConfig(currentSettings, passwordHash)) return { success: false, error: 'save-failed' }
storedPasswordHash = passwordHash
logger.info('App lock password changed')
return { success: true }
} catch (error) {
logger.error(`Failed to change app lock password: ${error instanceof Error ? error.message : error}`)
return { success: false, error: 'internal-error' }
} finally {
isTransitioning = false
}
}
export function resetAppLockPassword(): AppLockResult {
if (lockState === 'locked') return { success: false, error: 'locked' }
if (isTransitioning) return { success: false, error: 'busy' }
if (!saveConfig(currentSettings, null)) return { success: false, error: 'save-failed' }
storedPasswordHash = null
stopIdleTimer()
clearLockFlag()
logger.info('App lock disabled and password cleared')
return { success: true }
}
export function getLockConfig(): AppLockConfig {
return {
enabled: isEnabled(),
...currentSettings,
}
}
function parseConfigUpdate(value: unknown): AppLockConfigUpdate | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const record = value as Record<string, unknown>
if (Object.keys(record).some((key) => key !== 'idleTimeoutMinutes' && key !== 'lockOnStartup')) return null
const updates: AppLockConfigUpdate = {}
if ('idleTimeoutMinutes' in record) {
if (!isValidIdleTimeout(record.idleTimeoutMinutes)) return null
updates.idleTimeoutMinutes = record.idleTimeoutMinutes
}
if ('lockOnStartup' in record) {
if (typeof record.lockOnStartup !== 'boolean') return null
updates.lockOnStartup = record.lockOnStartup
}
return updates
}
export function updateLockConfig(value: unknown): AppLockResult & { config?: AppLockConfig } {
if (isTransitioning) return { success: false, error: 'busy' }
const updates = parseConfigUpdate(value)
if (!updates) return { success: false, error: 'invalid-config' }
const nextSettings = { ...currentSettings, ...updates }
if (!saveConfig(nextSettings, storedPasswordHash)) return { success: false, error: 'save-failed' }
currentSettings = nextSettings
if (isEnabled() && currentSettings.idleTimeoutMinutes > 0) startIdleTimer()
else stopIdleTimer()
logger.info(`App lock config updated: ${JSON.stringify(updates)}`)
return { success: true, config: getLockConfig() }
}
export function getLockState(): AppLockState {
return lockState
}
function checkSystemIdle(): void {
if (!isEnabled() || lockState === 'locked' || currentSettings.idleTimeoutMinutes <= 0) return
try {
if (powerMonitor.getSystemIdleTime() >= currentSettings.idleTimeoutMinutes * 60) {
logger.info(`App idle timeout reached (${currentSettings.idleTimeoutMinutes} min)`)
lockApp()
}
} catch (error) {
logger.error(`Failed to read system idle time: ${error instanceof Error ? error.message : error}`)
stopIdleTimer()
}
}
function startIdleTimer(): void {
stopIdleTimer()
idleTimer = setInterval(checkSystemIdle, IDLE_CHECK_INTERVAL_MS)
checkSystemIdle()
}
function stopIdleTimer(): void {
if (!idleTimer) return
clearInterval(idleTimer)
idleTimer = null
}
export function cleanupLockManager(): void {
stopIdleTimer()
mainWindowRef = null
logger.info('App lock manager cleaned up')
}
+18
View File
@@ -0,0 +1,18 @@
import { ipcRenderer } from 'electron'
import type { SecurityApi } from '../../shared/types'
export const securityApi: SecurityApi = {
getConfig: () => ipcRenderer.invoke('app-lock:getConfig'),
getState: () => ipcRenderer.invoke('app-lock:getState'),
lock: () => ipcRenderer.invoke('app-lock:lock'),
unlock: (password) => ipcRenderer.invoke('app-lock:unlock', password),
setPassword: (password) => ipcRenderer.invoke('app-lock:setPassword', password),
changePassword: (oldPassword, newPassword) => ipcRenderer.invoke('app-lock:changePassword', oldPassword, newPassword),
resetPassword: () => ipcRenderer.invoke('app-lock:resetPassword'),
updateConfig: (updates) => ipcRenderer.invoke('app-lock:updateConfig', updates),
onLockStateChanged: (callback) => {
const handler = (_event: Electron.IpcRendererEvent, locked: boolean) => callback(locked)
ipcRenderer.on('app-lock-state-changed', handler)
return () => ipcRenderer.removeListener('app-lock-state-changed', handler)
},
}
+3 -1
View File
@@ -1,6 +1,6 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import type { ImportProgress, ExportProgress } from '../../../src/types/base'
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../shared/types'
import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo, SecurityApi } from '../shared/types'
import type { TimeFilter } from '@openchatlab/shared-types'
// 迁移相关类型
@@ -299,6 +299,7 @@ declare global {
networkApi: NetworkApi
apiServerApi: ApiServerApi
internalApi: InternalApi
securityApi: SecurityApi
}
}
@@ -334,4 +335,5 @@ export {
ApiServerStatus,
InternalApi,
InternalEndpoint,
SecurityApi,
}
+4
View File
@@ -12,6 +12,7 @@ import { aiApi } from './apis/ai'
import { networkApi, cacheApi } from './apis/utils'
import { apiServerApi } from './apis/api-server'
import { internalApi } from './apis/internal-api'
import { securityApi } from './apis/security'
// 为渲染进程提供统一的类型入口,避免 type-only import 指向无导出的运行时代码。
export type { PreprocessConfig } from './apis/ai'
@@ -41,6 +42,7 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('networkApi', networkApi)
contextBridge.exposeInMainWorld('apiServerApi', apiServerApi)
contextBridge.exposeInMainWorld('internalApi', internalApi)
contextBridge.exposeInMainWorld('securityApi', securityApi)
} catch (error) {
console.error(error)
}
@@ -61,4 +63,6 @@ if (process.contextIsolated) {
window.apiServerApi = apiServerApi
// @ts-ignore (define in dts)
window.internalApi = internalApi
// @ts-ignore (define in dts)
window.securityApi = securityApi
}
+47
View File
@@ -41,3 +41,50 @@ export interface AgentRuntimeStatus {
totalUsage: TokenUsage
updatedAt: number
}
export type AppLockState = 'unlocked' | 'locked'
export type AppLockErrorCode =
| 'busy'
| 'locked'
| 'disabled'
| 'invalid-pin'
| 'password-already-set'
| 'password-not-set'
| 'same-password'
| 'wrong-password'
| 'save-failed'
| 'invalid-config'
| 'internal-error'
export interface AppLockConfig {
enabled: boolean
idleTimeoutMinutes: number
lockOnStartup: boolean
}
export interface AppLockConfigUpdate {
idleTimeoutMinutes?: number
lockOnStartup?: boolean
}
export interface AppLockResult {
success: boolean
error?: AppLockErrorCode
}
export interface AppLockUnlockResult extends AppLockResult {
wrongPassword?: boolean
}
export interface SecurityApi {
getConfig: () => Promise<AppLockConfig>
getState: () => Promise<AppLockState>
lock: () => Promise<AppLockResult>
unlock: (password: string) => Promise<AppLockUnlockResult>
setPassword: (newPassword: string) => Promise<AppLockResult>
changePassword: (oldPassword: string, newPassword: string) => Promise<AppLockResult>
resetPassword: () => Promise<AppLockResult>
updateConfig: (updates: AppLockConfigUpdate) => Promise<AppLockResult & { config?: AppLockConfig }>
onLockStateChanged: (callback: (locked: boolean) => void) => () => void
}
+7 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue'
import { useColorMode } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
@@ -25,6 +25,8 @@ import { IS_ELECTRON } from '@/utils/platform'
import { usePlatformService } from '@/services'
import { resolvePageTransitionKey } from '@/routes/page-transition-key'
const LockScreen = IS_ELECTRON ? defineAsyncComponent(() => import('@/components/lock-screen/LockScreen.vue')) : null
const { t } = useI18n()
const sessionStore = useSessionStore()
@@ -82,12 +84,14 @@ async function initializeApp() {
function handleGlobalKeydown(e: KeyboardEvent) {
const isMeta = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey
// Ctrl+, → 打开设置
if (isMeta && e.key === ',') {
e.preventDefault()
e.stopPropagation()
if (!layoutStore.showSettings) {
layoutStore.openSettings()
}
return
}
}
@@ -208,6 +212,8 @@ onUnmounted(() => {
<ChatRecordDrawer />
<!-- 全局 AI 后台任务条:允许用户离开当前页面后仍然快速返回进行中的对话。 -->
<GlobalTaskBar />
<!-- 应用锁覆盖层:最高 z-index,锁定后拦截全部底层操作 -->
<LockScreen v-if="IS_ELECTRON" />
</UApp>
</template>
@@ -0,0 +1,323 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
type SecurityApi = Window['securityApi']
type LockConfig = Awaited<ReturnType<SecurityApi['getConfig']>>
type LockConfigUpdate = Parameters<SecurityApi['updateConfig']>[0]
const { t, locale } = useI18n()
const PIN_PATTERN = /^\d{4}$/
const DEFAULT_IDLE_TIMEOUT_MINUTES = 5
const IDLE_TIMEOUT_OPTIONS = [1, 3, 5, 10, 15, 30, 60]
const config = ref<LockConfig | null>(null)
const isLoading = ref(true)
const isSaving = ref(false)
const operationError = ref('')
const showPasswordModal = ref(false)
const oldPassword = ref('')
const newPassword = ref('')
const newPasswordConfirm = ref('')
const passwordError = ref('')
const isSubmitting = ref(false)
const isEnabled = computed(() => config.value?.enabled ?? false)
const canSubmitPassword = computed(() => {
if (!PIN_PATTERN.test(newPassword.value) || newPassword.value !== newPasswordConfirm.value) return false
return !isEnabled.value || PIN_PATTERN.test(oldPassword.value)
})
const timeoutOptions = computed(() => {
const formatter = new Intl.NumberFormat(locale.value, {
style: 'unit',
unit: 'minute',
unitDisplay: 'short',
})
return IDLE_TIMEOUT_OPTIONS.map((value) => ({ value, label: formatter.format(value) }))
})
onMounted(async () => {
await loadConfig()
isLoading.value = false
})
async function loadConfig(): Promise<void> {
try {
config.value = await window.securityApi.getConfig()
} catch {
operationError.value = t('settings.security.password.errorFailed')
}
}
async function applyConfig(updates: LockConfigUpdate): Promise<void> {
isSaving.value = true
operationError.value = ''
try {
const result = await window.securityApi.updateConfig(updates)
if (result.success && result.config) config.value = result.config
else operationError.value = t('settings.security.password.errorFailed')
} catch {
operationError.value = t('settings.security.password.errorFailed')
} finally {
isSaving.value = false
}
}
function resetPasswordForm(): void {
oldPassword.value = ''
newPassword.value = ''
newPasswordConfirm.value = ''
passwordError.value = ''
}
function openPasswordModal(): void {
resetPasswordForm()
showPasswordModal.value = true
}
function closePasswordModal(): void {
showPasswordModal.value = false
resetPasswordForm()
}
function handlePasswordModalOpenChange(open: boolean): void {
if (!open) closePasswordModal()
}
function normalizePinInput(value: unknown): string {
return String(value ?? '')
.replace(/\D/g, '')
.slice(0, 4)
}
async function handleSetOrChangePassword(): Promise<void> {
passwordError.value = ''
if (!PIN_PATTERN.test(newPassword.value)) {
passwordError.value = t('settings.security.password.errorInvalidPin')
return
}
if (newPassword.value !== newPasswordConfirm.value) {
passwordError.value = t('settings.security.password.errorMismatch')
return
}
if (isEnabled.value && newPassword.value === oldPassword.value) {
passwordError.value = t('settings.security.password.errorSameAsOld')
return
}
isSubmitting.value = true
try {
const result = isEnabled.value
? await window.securityApi.changePassword(oldPassword.value, newPassword.value)
: await window.securityApi.setPassword(newPassword.value)
if (!result.success) {
passwordError.value = t('settings.security.password.errorFailed')
return
}
closePasswordModal()
await loadConfig()
} catch {
passwordError.value = t('settings.security.password.errorFailed')
} finally {
isSubmitting.value = false
}
}
async function handleDisableLock(): Promise<void> {
if (!confirm(t('settings.security.lock.disableConfirm'))) return
operationError.value = ''
try {
const result = await window.securityApi.resetPassword()
if (result.success) await loadConfig()
else operationError.value = t('settings.security.password.errorFailed')
} catch {
operationError.value = t('settings.security.password.errorFailed')
}
}
async function handleManualLock(): Promise<void> {
operationError.value = ''
try {
const result = await window.securityApi.lock()
if (!result.success) operationError.value = t('settings.security.password.errorFailed')
} catch {
operationError.value = t('settings.security.password.errorFailed')
}
}
</script>
<template>
<div class="space-y-6 pb-6">
<div v-if="isLoading" class="flex items-center justify-center gap-2 py-10 text-xs text-gray-500 dark:text-gray-400">
<UIcon name="i-heroicons-arrow-path" class="h-4 w-4 animate-spin text-primary-500" />
{{ t('common.loading') }}
</div>
<template v-else-if="config">
<section>
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-shield-check" class="h-4 w-4 text-primary-500" />
{{ t('settings.security.lock.title') }}
</h3>
<div
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50"
>
<div class="min-w-0 flex-1 pr-4">
<p class="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
<span
class="h-2 w-2 shrink-0 rounded-full"
:class="isEnabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'"
/>
{{ isEnabled ? t('settings.security.lock.statusOn') : t('settings.security.lock.statusOff') }}
</p>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('settings.security.lock.desc') }}
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<template v-if="isEnabled">
<UButton size="sm" variant="soft" icon="i-heroicons-key" @click="openPasswordModal">
{{ t('settings.security.password.titleChange') }}
</UButton>
<UButton
color="neutral"
variant="soft"
size="sm"
icon="i-heroicons-lock-closed"
@click="handleManualLock"
>
{{ t('settings.security.lock.manualLock') }}
</UButton>
<UButton color="error" variant="soft" size="sm" @click="handleDisableLock">
{{ t('settings.security.lock.disableBtn') }}
</UButton>
</template>
<UButton v-else size="sm" icon="i-heroicons-lock-open" @click="openPasswordModal">
{{ t('settings.security.password.btnEnable') }}
</UButton>
</div>
</div>
</section>
<section v-if="isEnabled">
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-white">
<UIcon name="i-heroicons-clock" class="h-4 w-4 text-primary-500" />
{{ t('settings.security.autoLock.title') }}
</h3>
<div class="rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50">
<div class="flex items-center justify-between gap-4 p-4">
<p class="min-w-0 flex-1 pr-4 text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.security.autoLock.idleLabel') }}
</p>
<div class="flex shrink-0 items-center gap-2">
<USelect
v-if="config.idleTimeoutMinutes > 0"
:model-value="config.idleTimeoutMinutes"
:items="timeoutOptions"
:ui="{ content: 'z-[200]' }"
size="sm"
class="w-28 shrink-0"
:aria-label="t('settings.security.autoLock.idleLabel')"
:disabled="isSaving"
@update:model-value="applyConfig({ idleTimeoutMinutes: Number($event) })"
/>
<USwitch
:model-value="config.idleTimeoutMinutes > 0"
:loading="isSaving"
@update:model-value="applyConfig({ idleTimeoutMinutes: $event ? DEFAULT_IDLE_TIMEOUT_MINUTES : 0 })"
/>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700" />
<div class="flex items-center justify-between gap-4 p-4">
<p class="min-w-0 flex-1 pr-4 text-sm font-medium text-gray-900 dark:text-white">
{{ t('settings.security.autoLock.startupLabel') }}
</p>
<USwitch
:model-value="config.lockOnStartup"
:loading="isSaving"
@update:model-value="applyConfig({ lockOnStartup: $event })"
/>
</div>
</div>
</section>
<UModal
:open="showPasswordModal"
:title="isEnabled ? t('settings.security.password.titleChange') : t('settings.security.password.titleSetup')"
:dismissible="!isSubmitting"
:close="{ disabled: isSubmitting }"
:ui="{ content: 'z-[102] sm:max-w-md', overlay: 'z-[101]' }"
@update:open="handlePasswordModalOpenChange"
>
<template #body>
<div class="space-y-4">
<div v-if="isEnabled">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('settings.security.password.labelOld') }}
</label>
<UInput
:model-value="oldPassword"
type="password"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="current-password"
class="w-full"
maxlength="4"
@update:model-value="oldPassword = normalizePinInput($event)"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('settings.security.password.labelNew') }}
</label>
<UInput
:model-value="newPassword"
type="password"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="new-password"
class="w-full"
maxlength="4"
@update:model-value="newPassword = normalizePinInput($event)"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('settings.security.password.labelConfirm') }}
</label>
<UInput
:model-value="newPasswordConfirm"
type="password"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="new-password"
class="w-full"
maxlength="4"
@update:model-value="newPasswordConfirm = normalizePinInput($event)"
/>
</div>
<p v-if="passwordError" class="text-xs text-red-500">{{ passwordError }}</p>
</div>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton color="neutral" variant="soft" size="sm" :disabled="isSubmitting" @click="closePasswordModal">
{{ t('common.cancel') }}
</UButton>
<UButton
size="sm"
:loading="isSubmitting"
:disabled="!canSubmitPassword"
@click="handleSetOrChangePassword"
>
{{ isEnabled ? t('common.save') : t('settings.security.password.btnEnable') }}
</UButton>
</div>
</template>
</UModal>
<p v-if="operationError" class="text-xs text-red-500">{{ operationError }}</p>
</template>
</div>
</template>
+12 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, computed, nextTick } from 'vue'
import { ref, watch, computed, defineAsyncComponent, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import AISettingsTab from './Settings/AISettingsTab.vue'
@@ -11,6 +11,11 @@ import ApiSettingsTab from './Settings/ApiSettingsTab.vue'
import { PageTabs } from '@/components/navigation'
import { usePromptStore } from '@/stores/prompt'
import { useLayoutStore } from '@/stores/layout'
import { IS_ELECTRON } from '@/utils/platform'
const SecuritySettingsTab = IS_ELECTRON
? defineAsyncComponent(() => import('./Settings/SecuritySettingsTab.vue'))
: null
const { t } = useI18n()
const promptStore = usePromptStore()
@@ -28,6 +33,7 @@ const tabs = computed(() => [
{ id: 'api', label: t('settings.tabs.api'), icon: 'i-heroicons-server-stack' },
{ id: 'data', label: t('settings.tabs.dataManage'), icon: 'i-heroicons-rectangle-stack' },
{ id: 'storage', label: t('settings.tabs.storage'), icon: 'i-heroicons-folder-open' },
...(IS_ELECTRON ? [{ id: 'security', label: t('settings.tabs.security'), icon: 'i-heroicons-shield-check' }] : []),
{ id: 'about', label: t('settings.tabs.about'), icon: 'i-heroicons-information-circle' },
])
@@ -59,7 +65,8 @@ function scrollToSubTab(subTab: string) {
watch(showSettings, async (visible) => {
if (visible) {
activeTab.value = settingsTab.value || 'settings'
const requestedTab = settingsTab.value || 'settings'
activeTab.value = tabs.value.some((tab) => tab.id === requestedTab) ? requestedTab : 'settings'
if (settingsSubTab.value) {
await nextTick()
setTimeout(() => scrollToSubTab(settingsSubTab.value!), 100)
@@ -105,9 +112,9 @@ watch(showSettings, async (visible) => {
</div>
<div class="relative flex-1">
<div class="absolute inset-0 p-6">
<div class="absolute inset-0 overflow-y-auto p-6">
<Transition name="tab-slide" mode="out-in">
<div v-if="activeTab === 'settings'" key="settings" class="h-full overflow-y-auto">
<div v-if="activeTab === 'settings'" key="settings" class="h-full">
<BasicSettingsTab />
</div>
<AISettingsTab
@@ -127,6 +134,7 @@ watch(showSettings, async (visible) => {
key="storage"
:ref="(el: unknown) => setTabRef('storage', el)"
/>
<SecuritySettingsTab v-else-if="IS_ELECTRON && activeTab === 'security'" key="security" />
<div v-else-if="activeTab === 'about'" key="about" class="h-full overflow-y-auto">
<AboutTab />
</div>
+131
View File
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const visible = ref(false)
const isUnlocking = ref(false)
const password = ref('')
const showPassword = ref(false)
const errorMessage = ref('')
const passwordInputRef = ref<HTMLInputElement | null>(null)
const canUnlock = computed(() => /^\d{4}$/.test(password.value))
let removeLockListener: (() => void) | null = null
onMounted(async () => {
removeLockListener = window.securityApi.onLockStateChanged((locked) => {
if (locked) showOverlay()
else hideOverlay()
})
try {
if ((await window.securityApi.getState()) === 'locked') showOverlay()
} catch {
// 主进程会在下一次状态变化时重新通知锁屏。
}
})
onUnmounted(() => removeLockListener?.())
async function showOverlay(): Promise<void> {
visible.value = true
resetForm()
await nextTick()
passwordInputRef.value?.focus()
}
function hideOverlay(): void {
visible.value = false
resetForm()
}
function resetForm(): void {
password.value = ''
showPassword.value = false
errorMessage.value = ''
}
async function handleUnlock(): Promise<void> {
if (!canUnlock.value || isUnlocking.value) return
isUnlocking.value = true
errorMessage.value = ''
try {
const result = await window.securityApi.unlock(password.value)
if (result.success) return
errorMessage.value = result.wrongPassword
? t('settings.security.lockScreen.errorWrongPassword')
: t('settings.security.lockScreen.errorService')
password.value = ''
} catch {
errorMessage.value = t('settings.security.lockScreen.errorService')
} finally {
isUnlocking.value = false
}
}
function handlePasswordInput(event: Event): void {
password.value = (event.target as HTMLInputElement).value.replace(/\D/g, '').slice(0, 4)
}
</script>
<template>
<div
v-if="visible"
class="fixed inset-0 z-[99999] flex items-center justify-center bg-white dark:bg-page-dark"
@keydown.enter="handleUnlock"
>
<div class="w-full max-w-sm px-8">
<div class="mb-8 text-center">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-primary-600 text-white shadow-sm dark:bg-primary-500"
>
<UIcon name="i-heroicons-lock-closed" class="h-6 w-6" />
</div>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">
{{ t('settings.security.lockScreen.titleLocked') }}
</h1>
</div>
<div class="space-y-3">
<div class="relative">
<input
ref="passwordInputRef"
:value="password"
:type="showPassword ? 'text' : 'password'"
inputmode="numeric"
pattern="[0-9]*"
autocomplete="current-password"
:placeholder="t('settings.security.lockScreen.placeholderPassword')"
class="w-full rounded-xl border border-gray-200 bg-gray-100 py-3 pl-4 pr-12 text-sm text-gray-900 placeholder-gray-400 outline-none transition focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:opacity-40 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:placeholder-gray-500"
:disabled="isUnlocking"
maxlength="4"
@input="handlePasswordInput"
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
:aria-label="t('settings.security.lockScreen.placeholderPassword')"
:disabled="isUnlocking"
@click="showPassword = !showPassword"
>
<UIcon :name="showPassword ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'" class="h-5 w-5" />
</button>
</div>
<p v-if="errorMessage" class="text-center text-xs text-red-500">
{{ errorMessage }}
</p>
<UButton
block
size="lg"
icon="i-heroicons-lock-open"
:loading="isUnlocking"
:disabled="!canUnlock"
class="justify-center rounded-xl"
@click="handleUnlock"
>
{{ isUnlocking ? t('settings.security.lockScreen.verifying') : t('settings.security.lockScreen.btnUnlock') }}
</UButton>
</div>
</div>
</div>
</template>
+37
View File
@@ -11,6 +11,7 @@
"aiPreprocess": "Preprocess",
"dataManage": "Data Management",
"storage": "Storage",
"security": "Security",
"sessionManage": "Session Index",
"about": "About",
"api": "Automation",
@@ -609,5 +610,41 @@
"nodeRequired": "npx and CLI modes require Node.js 20 or higher.",
"docs": "For detailed documentation, visit docs.chatlab.fun."
}
},
"security": {
"lock": {
"title": "App Lock",
"desc": "Protect ChatLab with a 4-digit PIN",
"statusOn": "Enabled",
"statusOff": "Disabled",
"disableBtn": "Disable App Lock",
"disableConfirm": "Disable app lock? Your current PIN will be deleted.",
"manualLock": "Lock Now"
},
"password": {
"titleSetup": "Set PIN & Enable App Lock",
"titleChange": "Change PIN",
"labelOld": "Current PIN",
"labelNew": "New PIN",
"labelConfirm": "Confirm New PIN",
"btnEnable": "Enable",
"errorInvalidPin": "PIN must be exactly 4 digits",
"errorMismatch": "PINs do not match",
"errorSameAsOld": "New PIN must differ from current PIN",
"errorFailed": "Operation failed, please try again"
},
"autoLock": {
"title": "Auto Lock",
"idleLabel": "Auto lock after idle timeout",
"startupLabel": "Require unlock on startup"
},
"lockScreen": {
"titleLocked": "ChatLab is Locked",
"placeholderPassword": "Enter 4-digit PIN",
"btnUnlock": "Unlock",
"verifying": "Verifying...",
"errorService": "Unlock failed, please try again",
"errorWrongPassword": "Incorrect PIN"
}
}
}
+37
View File
@@ -11,6 +11,7 @@
"aiPreprocess": "前処理",
"dataManage": "データ管理",
"storage": "ストレージ管理",
"security": "セキュリティ",
"sessionManage": "セッションインデックス",
"about": "ChatLabについて",
"api": "オートメーション",
@@ -609,5 +610,41 @@
"nodeRequired": "npx および CLI モードには Node.js 20 以上が必要です。",
"docs": "詳細なドキュメントは docs.chatlab.fun をご覧ください。"
}
},
"security": {
"lock": {
"title": "アプリロック",
"desc": "4桁のPINでChatLabを保護します",
"statusOn": "有効",
"statusOff": "無効",
"disableBtn": "アプリロックを無効にする",
"disableConfirm": "アプリロックを無効にしますか?現在のPINは削除されます。",
"manualLock": "今すぐロック"
},
"password": {
"titleSetup": "PIN設定とアプリロック有効化",
"titleChange": "PIN変更",
"labelOld": "現在のPIN",
"labelNew": "新しいPIN",
"labelConfirm": "新しいPIN(確認)",
"btnEnable": "有効にする",
"errorInvalidPin": "PINは4桁の数字で入力してください",
"errorMismatch": "PINが一致しません",
"errorSameAsOld": "新しいPINを現在のものと同じにしないでください",
"errorFailed": "操作に失敗しました。再試行してください"
},
"autoLock": {
"title": "自動ロック",
"idleLabel": "アイドル時に自動ロック",
"startupLabel": "起動時にロック解除を要求"
},
"lockScreen": {
"titleLocked": "ChatLab はロックされています",
"placeholderPassword": "4桁のPINを入力",
"btnUnlock": "ロック解除",
"verifying": "検証中...",
"errorService": "ロック解除に失敗しました。再試行してください",
"errorWrongPassword": "PINが正しくありません"
}
}
}
+37
View File
@@ -11,6 +11,7 @@
"aiPreprocess": "预处理",
"dataManage": "数据管理",
"storage": "存储管理",
"security": "安全",
"sessionManage": "会话索引",
"about": "关于",
"api": "自动化",
@@ -609,5 +610,41 @@
"nodeRequired": "npx 和 CLI 方式需要 Node.js 20 或更高版本。",
"docs": "详细文档请访问 docs.chatlab.fun。"
}
},
"security": {
"lock": {
"title": "应用锁",
"desc": "使用 4 位数字密码保护 ChatLab",
"statusOn": "已开启",
"statusOff": "未开启",
"disableBtn": "关闭应用锁",
"disableConfirm": "确定关闭应用锁?关闭后将删除当前密码。",
"manualLock": "立即锁定"
},
"password": {
"titleSetup": "设置 4 位密码并开启应用锁",
"titleChange": "修改密码",
"labelOld": "旧密码",
"labelNew": "输入新密码",
"labelConfirm": "确认新密码",
"btnEnable": "开启",
"errorInvalidPin": "密码必须是 4 位数字",
"errorMismatch": "两次输入的密码不一致",
"errorSameAsOld": "新密码不能与旧密码相同",
"errorFailed": "操作失败,请重试"
},
"autoLock": {
"title": "自动锁定",
"idleLabel": "闲置超时自动锁定",
"startupLabel": "启动时要求解锁"
},
"lockScreen": {
"titleLocked": "ChatLab 已锁定",
"placeholderPassword": "输入 4 位数字密码",
"btnUnlock": "解锁",
"verifying": "验证中...",
"errorService": "解锁失败,请重试",
"errorWrongPassword": "密码错误"
}
}
}
+37
View File
@@ -11,6 +11,7 @@
"aiPreprocess": "前處理",
"dataManage": "資料管理",
"storage": "儲存管理",
"security": "安全",
"sessionManage": "會話索引",
"about": "關於 ChatLab",
"api": "自動化",
@@ -609,5 +610,41 @@
"nodeRequired": "npx 和 CLI 方式需要 Node.js 20 或更高版本。",
"docs": "詳細文件請造訪 docs.chatlab.fun。"
}
},
"security": {
"lock": {
"title": "應用鎖",
"desc": "使用 4 位數字密碼保護 ChatLab",
"statusOn": "已開啟",
"statusOff": "未開啟",
"disableBtn": "關閉應用鎖",
"disableConfirm": "確定關閉應用鎖?關閉後將刪除目前的密碼。",
"manualLock": "立即鎖定"
},
"password": {
"titleSetup": "設定 4 位密碼並開啟應用鎖",
"titleChange": "修改密碼",
"labelOld": "舊密碼",
"labelNew": "輸入新密碼",
"labelConfirm": "確認新密碼",
"btnEnable": "開啟",
"errorInvalidPin": "密碼必須是 4 位數字",
"errorMismatch": "兩次輸入的密碼不一致",
"errorSameAsOld": "新密碼不能與舊密碼相同",
"errorFailed": "操作失敗,請重試"
},
"autoLock": {
"title": "自動鎖定",
"idleLabel": "閒置逾時自動鎖定",
"startupLabel": "啟動時要求解鎖"
},
"lockScreen": {
"titleLocked": "ChatLab 已鎖定",
"placeholderPassword": "輸入 4 位數字密碼",
"btnUnlock": "解鎖",
"verifying": "驗證中...",
"errorService": "解鎖失敗,請重試",
"errorWrongPassword": "密碼錯誤"
}
}
}