mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(security): 新增桌面端应用锁
新增 4 位 PIN、手动锁定、启动锁定和系统闲置锁定。 新增主进程与 preload 能力、桌面端设置面板、全局锁屏覆盖层及四语言界面。 Fixes #190 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
32bd268af4
commit
84b371e73d
17 files changed
+1115
-6
No files matched your search
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
Vendored
+3
-1
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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が正しくありません"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "密码错误"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "密碼錯誤"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user