Files
clawpanel/src/pages/security.js

296 lines
13 KiB
JavaScript

/**
* 安全设置页面 — 访问密码管理 & 无视风险模式
* 支持 Web 部署模式和 Tauri 桌面端
*/
import { toast } from '../components/toast.js'
import { statusIcon } from '../lib/icons.js'
import { t } from '../lib/i18n.js'
const isTauri = !!window.__TAURI_INTERNALS__
let _tauriApi = null
async function getTauriApi() {
if (!_tauriApi) _tauriApi = (await import('../lib/tauri-api.js')).api
return _tauriApi
}
async function apiCall(cmd, args = {}) {
if (isTauri) {
// 桌面端:通过 Tauri IPC 读写 clawpanel.json
const api = await getTauriApi()
const cfg = await api.readPanelConfig()
if (cmd === 'auth_status') {
const isDefault = cfg.accessPassword === '123456'
const result = { hasPassword: !!cfg.accessPassword, mustChangePassword: isDefault, ignoreRisk: !!cfg.ignoreRisk }
if (isDefault) result.defaultPassword = '123456'
return result
}
if (cmd === 'auth_change_password') {
if (cfg.accessPassword && args.oldPassword !== cfg.accessPassword) throw new Error(t('security.wrongPassword'))
const weakErr = checkPasswordStrengthLocal(args.newPassword)
if (weakErr) throw new Error(weakErr)
if (args.newPassword === cfg.accessPassword) throw new Error(t('security.pwSameAsOld'))
cfg.accessPassword = args.newPassword
delete cfg.mustChangePassword
delete cfg.ignoreRisk
await api.writePanelConfig(cfg)
sessionStorage.setItem('clawpanel_authed', '1')
return { success: true }
}
if (cmd === 'auth_ignore_risk') {
if (args.enable) {
delete cfg.accessPassword
delete cfg.mustChangePassword
cfg.ignoreRisk = true
sessionStorage.removeItem('clawpanel_authed')
} else {
delete cfg.ignoreRisk
}
await api.writePanelConfig(cfg)
return { success: true }
}
throw new Error(`${t('common.unknownCommand')}: ${cmd}`)
}
// Web 模式
const resp = await fetch(`/__api/${cmd}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args),
})
const data = await resp.json()
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`)
return data
}
function checkPasswordStrengthLocal(pw) {
if (!pw || pw.length < 6) return t('security.pwMin6')
if (pw.length > 64) return t('security.pwMax64')
if (/^\d+$/.test(pw)) return t('security.pwNoDigitOnly')
const weak = ['123456', '654321', 'password', 'admin', 'qwerty', 'abc123', '111111', '000000', 'letmein', 'welcome', 'clawpanel', 'openclaw']
if (weak.includes(pw.toLowerCase())) return t('security.pwTooCommon')
return null
}
function strengthLevel(pw) {
if (!pw) return { level: 0, text: '', color: '' }
if (pw.length < 6) return { level: 1, text: t('security.strengthTooShort'), color: 'var(--error)' }
if (/^\d+$/.test(pw)) return { level: 1, text: t('security.strengthDigitOnly'), color: 'var(--error)' }
let score = 0
if (pw.length >= 8) score++
if (pw.length >= 12) score++
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++
if (/\d/.test(pw)) score++
if (/[^a-zA-Z0-9]/.test(pw)) score++
if (score <= 1) return { level: 2, text: t('security.strengthFair'), color: 'var(--warning)' }
if (score <= 3) return { level: 3, text: t('security.strengthGood'), color: 'var(--primary)' }
return { level: 4, text: t('security.strengthStrong'), color: 'var(--success)' }
}
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
<div class="page-header"><h1>${t('security.title')}</h1></div>
<div id="security-content">
<div class="config-section loading-placeholder" style="height:120px"></div>
</div>
`
loadStatus(page)
return page
}
async function loadStatus(page) {
const container = page.querySelector('#security-content')
try {
const status = await apiCall('auth_status')
renderContent(container, status)
} catch (e) {
container.innerHTML = `<div class="config-section"><p style="color:var(--error)">${t('security.loadFailed')}: ${e.message}</p></div>`
}
}
function renderContent(container, status) {
let html = ''
// 当前状态
const stateIcon = status.hasPassword ? statusIcon('ok', 20) : statusIcon('warn', 20)
const stateText = status.hasPassword
? (status.mustChangePassword ? t('security.stateDefault') : t('security.stateCustom'))
: (status.ignoreRisk ? t('security.stateIgnoreRisk') : t('security.stateNone'))
const stateColor = status.hasPassword && !status.mustChangePassword ? 'var(--success)' : 'var(--warning)'
html += `
<div class="config-section">
<div class="config-section-title">${t('security.passwordStatus')}</div>
<div style="display:flex;align-items:center;gap:8px;padding:12px 16px;background:var(--bg-tertiary);border-radius:var(--radius-sm);border-left:3px solid ${stateColor}">
<span style="font-size:20px">${stateIcon}</span>
<div>
<div style="font-weight:600;color:var(--text-primary)">${stateText}</div>
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-top:2px">
${status.hasPassword
? (isTauri ? t('security.tauriHasPassword') : t('security.webHasPassword'))
: (isTauri ? t('security.tauriNoPassword') : t('security.webNoPassword'))}
</div>
</div>
</div>
</div>
`
// 修改密码区域
html += `
<div class="config-section">
<div class="config-section-title">${status.hasPassword ? t('security.changePassword') : t('security.setPassword')}</div>
<form id="form-change-pw" style="max-width:400px">
${status.hasPassword ? `
<div style="margin-bottom:12px">
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">${t('security.currentPassword')}</label>
<input type="password" id="sec-old-pw" class="form-input" placeholder="${t('security.currentPasswordPlaceholder')}" autocomplete="current-password" style="width:100%"
${status.defaultPassword ? `value="${status.defaultPassword}"` : ''}>
${status.defaultPassword ? `<div style="font-size:11px;color:var(--text-tertiary);margin-top:4px">${t('security.defaultFilled')}</div>` : ''}
</div>
` : ''}
<div style="margin-bottom:12px">
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">${t('security.newPassword')}</label>
<input type="password" id="sec-new-pw" class="form-input" placeholder="${t('security.newPasswordPlaceholder')}" autocomplete="new-password" style="width:100%">
<div id="pw-strength" style="margin-top:6px;display:flex;align-items:center;gap:8px;min-height:20px"></div>
</div>
<div style="margin-bottom:16px">
<label style="display:block;font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:4px">${t('security.confirmPassword')}</label>
<input type="password" id="sec-confirm-pw" class="form-input" placeholder="${t('security.confirmPasswordPlaceholder')}" autocomplete="new-password" style="width:100%">
</div>
<button type="submit" class="btn btn-primary btn-sm">${status.hasPassword ? t('security.confirmChange') : t('security.setPassword')}</button>
<span id="change-pw-msg" style="margin-left:12px;font-size:var(--font-size-xs)"></span>
</form>
</div>
`
// 无视风险模式
html += `
<div class="config-section">
<div class="config-section-title" style="display:flex;align-items:center;gap:6px">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
${t('security.ignoreRiskTitle')}
</div>
<div style="padding:12px 16px;background:${status.ignoreRisk ? 'rgba(239,68,68,0.08)' : 'var(--bg-tertiary)'};border-radius:var(--radius-sm);border:1px solid ${status.ignoreRisk ? 'rgba(239,68,68,0.2)' : 'var(--border-primary)'}">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
<div>
<div style="font-weight:500;color:var(--text-primary)">${t('security.ignoreRiskLabel')}</div>
<div style="font-size:var(--font-size-xs);color:var(--text-secondary);margin-top:4px;line-height:1.5">
${t('security.ignoreRiskDesc')}<br>
<strong style="color:var(--error)">${t('security.ignoreRiskWarn')}</strong>
</div>
</div>
<label class="toggle-switch">
<input type="checkbox" id="toggle-ignore-risk" ${status.ignoreRisk ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div id="ignore-risk-confirm" style="display:none;margin-top:12px;padding:12px 16px;background:rgba(239,68,68,0.06);border-radius:var(--radius-sm);border:1px solid rgba(239,68,68,0.15)">
<p style="font-size:var(--font-size-sm);color:var(--error);font-weight:600;margin-bottom:8px">${t('security.ignoreRiskConfirmTitle')}</p>
<p style="font-size:var(--font-size-xs);color:var(--text-secondary);margin-bottom:12px;line-height:1.5">
${t('security.ignoreRiskConfirmDesc')}
</p>
<div style="display:flex;gap:8px">
<button class="btn btn-sm" id="btn-confirm-ignore" style="background:var(--error);color:#fff;border:none">${t('security.ignoreRiskConfirmBtn')}</button>
<button class="btn btn-secondary btn-sm" id="btn-cancel-ignore">${t('common.cancel')}</button>
</div>
</div>
</div>
`
container.innerHTML = html
bindSecurityEvents(container, status)
}
function bindSecurityEvents(container, status) {
// 密码强度实时显示
const newPwInput = container.querySelector('#sec-new-pw')
const strengthEl = container.querySelector('#pw-strength')
if (newPwInput && strengthEl) {
newPwInput.addEventListener('input', () => {
const s = strengthLevel(newPwInput.value)
if (!newPwInput.value) { strengthEl.innerHTML = ''; return }
const bars = [1,2,3,4].map(i =>
`<div style="width:32px;height:4px;border-radius:2px;background:${i <= s.level ? s.color : 'var(--border-primary)'}"></div>`
).join('')
strengthEl.innerHTML = `${bars}<span style="font-size:11px;color:${s.color};font-weight:500">${s.text}</span>`
})
}
// 修改密码表单
const form = container.querySelector('#form-change-pw')
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault()
const oldPw = container.querySelector('#sec-old-pw')?.value || ''
const newPw = container.querySelector('#sec-new-pw')?.value || ''
const confirmPw = container.querySelector('#sec-confirm-pw')?.value || ''
const msgEl = container.querySelector('#change-pw-msg')
const btn = form.querySelector('button[type="submit"]')
if (newPw !== confirmPw) { msgEl.textContent = t('security.passwordMismatch'); msgEl.style.color = 'var(--error)'; return }
btn.disabled = true
btn.textContent = t('security.submitting')
msgEl.textContent = ''
try {
await apiCall('auth_change_password', { oldPassword: oldPw, newPassword: newPw })
msgEl.textContent = t('security.passwordChanged')
msgEl.style.color = 'var(--success)'
toast(t('security.passwordUpdated'), 'success')
// 清除默认密码横幅
sessionStorage.removeItem('clawpanel_must_change_pw')
const banner = document.getElementById('pw-change-banner')
if (banner) banner.remove()
setTimeout(() => loadStatus(container.closest('.page')), 1000)
} catch (err) {
msgEl.textContent = err.message
msgEl.style.color = 'var(--error)'
btn.disabled = false
btn.textContent = status.hasPassword ? t('security.confirmChange') : t('security.setPassword')
}
})
}
// 无视风险模式开关
const toggle = container.querySelector('#toggle-ignore-risk')
const confirmBox = container.querySelector('#ignore-risk-confirm')
if (toggle && confirmBox) {
toggle.addEventListener('change', () => {
if (toggle.checked) {
// 想开启无视风险 → 显示确认框
confirmBox.style.display = 'block'
toggle.checked = false // 先不改,等用户确认
} else {
// 想关闭无视风险 → 直接关闭,刷新页面引导设密码
handleIgnoreRisk(container, false)
}
})
container.querySelector('#btn-confirm-ignore')?.addEventListener('click', () => {
handleIgnoreRisk(container, true)
})
container.querySelector('#btn-cancel-ignore')?.addEventListener('click', () => {
confirmBox.style.display = 'none'
})
}
}
async function handleIgnoreRisk(container, enable) {
try {
await apiCall('auth_ignore_risk', { enable })
if (enable) {
toast(t('security.ignoreRiskEnabled'), 'warning')
} else {
toast(t('security.ignoreRiskDisabled'), 'info')
}
setTimeout(() => loadStatus(container.closest('.page')), 500)
} catch (e) {
toast(t('security.operationFailed') + ': ' + e.message, 'error')
}
}