First Push

This commit is contained in:
喆OvO
2026-08-06 11:11:11 +08:00
commit 0fef309972
551 changed files with 15793 additions and 0 deletions
+395
View File
@@ -0,0 +1,395 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>控制面板 - Cal Display</title>
<link rel="stylesheet" href="/static/css/control.css">
</head>
<body>
<div id="app">
<header>
<h1>Cal Display 控制面板 <span id="current-user-badge" style="font-size:13px;font-weight:400;color:var(--text-dim);margin-left:8px;"></span></h1>
<nav id="tabs">
<button class="tab active" data-tab="calendar">日历</button>
<button class="tab" data-tab="weather">天气</button>
<button class="tab" data-tab="alarms">闹钟</button>
<button class="tab" data-tab="pomodoro">番茄钟</button>
<button class="tab" data-tab="users" id="users-tab" style="display:none;">用户管理</button>
<button class="tab" data-tab="settings">设置</button>
</nav>
</header>
<main>
<!-- Calendar Tab -->
<section id="tab-calendar" class="tab-content active">
<!-- Groups -->
<div class="section">
<h3>日历分组</h3>
<div id="group-list"></div>
<form id="group-form" class="inline-form">
<input type="text" id="group-name" placeholder="分组名称" required>
<input type="color" id="group-color" value="#4A90D9">
<button type="submit">添加分组</button>
</form>
</div>
<!-- Upload ICS -->
<div class="section">
<h3>上传 ICS 文件</h3>
<form id="upload-form" class="inline-form">
<select id="upload-group"></select>
<input type="text" id="upload-name" placeholder="来源名称" required>
<input type="file" id="upload-file" accept=".ics" required>
<button type="submit">上传</button>
</form>
</div>
<!-- Subscribe ICS -->
<div class="section">
<h3>订阅 ICS 链接</h3>
<form id="subscribe-form" class="inline-form">
<select id="subscribe-group"></select>
<input type="text" id="subscribe-name" placeholder="来源名称" required>
<input type="url" id="subscribe-url" placeholder="https://example.com/calendar.ics" required>
<button type="submit">订阅</button>
</form>
</div>
<!-- Reminders -->
<div class="section">
<h3>提醒设置</h3>
<form id="reminder-form" class="inline-form">
<select id="reminder-group"></select>
<input type="number" id="reminder-minutes" value="15" min="0" max="1440" style="width:70px">
<label>分钟前</label>
<label><input type="checkbox" id="reminder-enabled" checked> 启用</label>
<button type="submit">保存</button>
</form>
</div>
<!-- Manual Add Event -->
<div class="section">
<h3>手动添加日程</h3>
<form id="event-form" class="inline-form">
<select id="event-group"></select>
<input type="text" id="event-summary" placeholder="日程标题" required>
<input type="datetime-local" id="event-dtstart" required>
<input type="datetime-local" id="event-dtend">
<input type="text" id="event-location" placeholder="地点 (可选)">
<button type="submit">添加</button>
</form>
</div>
<!-- Group Events -->
<div class="section">
<h3>分组日程管理</h3>
<div class="inline-form" style="margin-bottom:12px;">
<select id="browse-group"></select>
<button type="button" id="browse-events-btn">查看日程</button>
</div>
<div id="group-events-list"></div>
</div>
</section>
<!-- Weather Tab -->
<section id="tab-weather" class="tab-content">
<!-- JWT Key Management -->
<div class="section">
<h3>JWT 密钥管理(和风天气)</h3>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">
生成本地 Ed25519 密钥对,将公钥复制到
<a href="https://console.qweather.com" target="_blank" style="color:var(--accent);">和风天气控制台</a>
→ 凭据管理 → 添加凭据。
</p>
<div class="form-row">
<label>公钥(复制到和风控制台)</label>
<textarea id="weather-public-key" rows="4" readonly
style="width:100%;font-family:monospace;font-size:12px;resize:none;"></textarea>
</div>
<button type="button" id="generate-keys-btn">生成密钥对</button>
<span id="key-gen-status" style="margin-left:10px;font-size:13px;color:var(--text-dim);"></span>
</div>
<!-- City Search -->
<div class="section">
<h3>城市搜索</h3>
<div class="inline-form">
<input type="text" id="weather-city-search" placeholder="输入城市名搜索…" style="min-width:200px;">
<button type="button" id="city-search-btn">搜索</button>
</div>
<div id="city-results" style="margin-top:8px;"></div>
</div>
<!-- QWeather Config -->
<div class="section">
<h3>和风天气配置</h3>
<form id="weather-form">
<!-- JWT fields(推荐;未填时自动回退到 API Key) -->
<div id="jwt-fields">
<div class="form-row">
<label>Credential ID (kid)</label>
<input type="text" id="weather-kid" placeholder="从和风控制台获取">
</div>
<div class="form-row">
<label>Project ID (sub)</label>
<input type="text" id="weather-sub" placeholder="从和风控制台获取">
</div>
<div class="form-row">
<label>专属 API Host(必填!)</label>
<input type="text" id="weather-host" placeholder="如 abc1234.def.qweatherapi.com">
<div style="font-size:12px;color:var(--accent-red);margin-top:4px;">
⚠️ 共享域名已弃用!登录 <a href="https://console.qweather.com" target="_blank" style="color:var(--accent);">和风控制台</a>
→ 产品管理 → 你的项目 → API Host,复制完整域名填入此处
</div>
</div>
</div>
<!-- API Key(可选):和风旧版 API Key,JWT 配置完整时自动优先 JWT -->
<div id="legacy-key-field">
<div class="form-row">
<label>API Key(可选)</label>
<input type="text" id="weather-apikey" placeholder="输入和风 API Key(未配置 JWT 时使用)">
</div>
</div>
<!-- Common fields -->
<div class="form-row">
<label>城市名称</label>
<input type="text" id="weather-city" placeholder="Beijing">
</div>
<div class="form-row">
<label>纬度</label>
<input type="number" id="weather-lat" step="0.0001" placeholder="39.9042">
</div>
<div class="form-row">
<label>经度</label>
<input type="number" id="weather-lon" step="0.0001" placeholder="116.4074">
</div>
<div class="form-row">
<label>单位</label>
<select id="weather-units">
<option value="metric">摄氏度 (metric)</option>
<option value="imperial">华氏度 (imperial)</option>
</select>
</div>
<button type="submit">保存配置</button>
<button type="button" id="weather-refresh" class="btn-secondary">立即刷新天气</button>
<span id="weather-save-status" style="margin-left:10px;font-size:13px;color:var(--text-dim);"></span>
</form>
</div>
<!-- Current weather preview -->
<div class="section">
<h3>当前天气预览</h3>
<div id="weather-preview" style="font-size:14px;color:var(--text-dim);">加载中...</div>
</div>
</section>
<!-- Alarms Tab -->
<section id="tab-alarms" class="tab-content">
<!-- Alarms -->
<div class="section">
<h3>闹钟列表</h3>
<div id="alarm-list"></div>
<form id="alarm-form" class="inline-form">
<input type="text" id="alarm-label" placeholder="标签">
<input type="time" id="alarm-time" required>
<select id="alarm-sound">
<option value="default">默认铃声</option>
</select>
<label><input type="checkbox" id="alarm-once"> 仅一次</label>
<div class="day-picker" id="alarm-days-picker">
<label><input type="checkbox" value="1" checked>一</label>
<label><input type="checkbox" value="2" checked>二</label>
<label><input type="checkbox" value="3" checked>三</label>
<label><input type="checkbox" value="4" checked>四</label>
<label><input type="checkbox" value="5" checked>五</label>
<label><input type="checkbox" value="6" checked>六</label>
<label><input type="checkbox" value="7" checked>日</label>
</div>
<button type="submit">添加闹钟</button>
</form>
</div>
<!-- Ringtone Upload -->
<div class="section">
<h3>自定义铃声</h3>
<form id="ringtone-form" class="inline-form">
<input type="file" id="ringtone-file" accept=".mp3,.wav,.ogg" required>
<button type="submit">上传铃声</button>
</form>
<div id="ringtone-list" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
</div>
</section>
<!-- Pomodoro Tab -->
<section id="tab-pomodoro" class="tab-content">
<div class="section">
<h3>番茄钟设置</h3>
<div id="pomodoro-status" style="margin-bottom:12px;font-size:14px;color:var(--text-dim);"></div>
<form id="pomodoro-form" class="inline-form">
<label>工作 <input type="number" id="pomodoro-work" value="25" min="1" max="120" style="width:70px"> 分钟</label>
<label>休息 <input type="number" id="pomodoro-break" value="5" min="1" max="60" style="width:70px"> 分钟</label>
<label>长休息 <input type="number" id="pomodoro-long-break" value="15" min="1" max="120" style="width:70px"> 分钟</label>
<label>每 <input type="number" id="pomodoro-cycles-before-long" value="4" min="1" max="20" style="width:60px"> 组后长休息</label>
<label><input type="text" id="pomodoro-work-label" placeholder="工作中" style="width:80px"> 工作标签</label>
<label><input type="text" id="pomodoro-break-label" placeholder="休息中" style="width:80px"> 休息标签</label>
<label><input type="checkbox" id="pomodoro-display-controls" checked> 允许显示端控制</label>
<button type="submit">保存设置</button>
</form>
<div style="margin-top:12px;display:flex;gap:8px;">
<button type="button" id="pomodoro-start-btn">开始</button>
<button type="button" id="pomodoro-pause-btn" class="btn-secondary">暂停</button>
<button type="button" id="pomodoro-skip-btn" class="btn-secondary">跳过</button>
<button type="button" id="pomodoro-stop-btn" class="btn-danger">停止</button>
</div>
</div>
</section>
<!-- Users Tab (admin only) -->
<section id="tab-users" class="tab-content">
<div class="section">
<h3>用户管理</h3>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">
仅管理员可用。注册/编辑/删除用户均为高危操作,每次操作前需二次验证管理员身份。
新注册用户的全部数据相互隔离,且不拥有用户管理权限。
</p>
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap;">
<button type="button" id="user-add-admin" class="btn-primary">👨‍💼 管理员代注册</button>
<button type="button" id="user-add-temp" class="btn-secondary">🕐 创建临时账户(用户填写信息模式)</button>
<button type="button" id="user-refresh-btn" class="btn-secondary">刷新列表</button>
</div>
<div id="pending-requests" style="margin-bottom:16px;"></div>
<div id="user-list"></div>
</div>
</section>
<!-- Settings Tab -->
<section id="tab-settings" class="tab-content">
<div class="section">
<h3>主题模式</h3>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">
跟随账户独立设置,显示端实时同步生效。
<strong>自动</strong> 模式按你天气设置中的城市经纬度计算日出日落,白天浅色、夜晚深色。
</p>
<div class="inline-form">
<select id="theme-mode">
<option value="dark">深色模式</option>
<option value="light">浅色模式</option>
<option value="auto">自动(日出日落)</option>
</select>
<button type="button" id="theme-save-btn">保存主题</button>
</div>
</div>
<div class="section">
<h3>显示端模式</h3> <form id="mode-form" class="inline-form">
<select id="display-mode">
<option value="default">默认页面</option>
<option value="custom">自定义 HTML</option>
</select>
<button type="submit">切换</button>
</form>
<div id="html-editor-wrap" style="margin-top:12px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<label style="font-size:13px;color:var(--text-dim);">当前编辑: <strong id="html-editor-label">HTML 0</strong></label>
<div style="display:flex;gap:6px;">
<button type="button" class="small-btn html-tab-btn active" data-idx="0">HTML 0</button>
<button type="button" class="small-btn html-tab-btn" data-idx="1">HTML 1</button>
<button type="button" class="small-btn html-tab-btn" data-idx="2">HTML 2</button>
<button type="button" class="small-btn html-tab-btn" data-idx="3">HTML 3</button>
</div>
</div>
<textarea id="custom-html-editor" rows="8" style="width:100%;padding:10px;border:1px solid var(--border);border-radius:6px;font-family:monospace;font-size:13px;resize:vertical;" placeholder="在此输入 HTML..."></textarea>
<div style="margin-top:8px;display:flex;gap:8px;">
<button type="button" id="save-html-btn">保存当前模板</button>
<button type="button" id="apply-html-btn" class="btn-secondary">使用当前 HTML</button>
<button type="button" id="refresh-display-btn" class="btn-secondary">强制刷新显示端</button>
</div>
</div>
<div style="margin-top:8px;display:flex;gap:8px;">
<button type="button" id="kick-all-btn" class="btn-danger">踢掉所有显示端</button>
</div>
<div id="mode-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
</div>
<div class="section">
<h3>时区设置</h3>
<div style="font-size:13px;color:var(--text-dim);margin-bottom:10px;">
所有本地时间将根据此设置显示。使用 <a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" target="_blank" style="color:var(--accent);">IANA 时区名称</a> 或 UTC+X 格式。
</div>
<form id="timezone-form" class="inline-form">
<input type="text" id="timezone-input" value="Asia/Shanghai" style="min-width:220px;" placeholder="Asia/Shanghai">
<button type="submit">保存时区</button>
<span id="timezone-msg" style="margin-left:10px;font-size:13px;color:var(--text-dim);"></span>
</form>
</div>
<div class="section">
<h3>手动推送提醒</h3>
<form id="notify-form" class="inline-form">
<input type="text" id="notify-title" placeholder="标题" value="提醒" required>
<input type="text" id="notify-message" placeholder="内容" required>
<label><input type="checkbox" id="notify-sound" checked> 播放声音</label>
<button type="submit">推送</button>
</form>
<div id="notify-msg" style="margin-top:8px;font-size:13px;color:var(--text-dim);"></div>
</div>
<div class="section">
<h3>双因素认证 (MFA)</h3>
<div id="mfa-status" style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">加载中...</div>
<!-- Passkey -->
<div style="margin-bottom:16px;padding:14px;border:1px solid var(--border);border-radius:8px;">
<h4 style="margin-bottom:8px;font-size:14px;">🔑 Passkey (设备密钥)</h4>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:8px;">
使用指纹、面容或安全密钥快速登录。
</p>
<button type="button" id="mfa-register-passkey" class="btn-secondary">注册 Passkey</button>
<button type="button" id="mfa-remove-passkey" class="btn-danger" style="display:none;">移除 Passkey</button>
<div id="mfa-passkey-status" style="font-size:13px;color:var(--text-dim);margin-top:6px;"></div>
</div>
<!-- TOTP -->
<div style="margin-bottom:16px;padding:14px;border:1px solid var(--border);border-radius:8px;">
<h4 style="margin-bottom:8px;font-size:14px;">📱 TOTP (身份验证器)</h4>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:8px;">
使用 Google Authenticator、Microsoft Authenticator 等应用。
</p>
<button type="button" id="mfa-setup-totp" class="btn-secondary">设置 TOTP</button>
<button type="button" id="mfa-remove-totp" class="btn-danger" style="display:none;">停用 TOTP</button>
<div id="mfa-totp-area" style="display:none;margin-top:12px;padding:12px;background:#fafafa;border-radius:8px;">
<div id="mfa-totp-qr" style="text-align:center;"></div>
<div id="mfa-totp-secret" style="font-family:monospace;font-size:12px;text-align:center;margin:8px 0;"></div>
<div class="inline-form">
<input type="text" id="mfa-totp-code" placeholder="6 位验证码" maxlength="6" inputmode="numeric" style="width:140px;">
<button type="button" id="mfa-totp-verify-btn">验证</button>
</div>
<div id="mfa-totp-msg" style="font-size:13px;margin-top:6px;"></div>
</div>
<div id="mfa-totp-status" style="font-size:13px;color:var(--text-dim);margin-top:6px;"></div>
</div>
<!-- Recovery codes -->
<div id="mfa-recovery-section" style="display:none;margin-bottom:16px;padding:14px;background:#fff8e1;border:1px solid #ffe082;border-radius:8px;">
<h4 style="font-size:14px;color:#e65100;margin-bottom:8px;">🔐 恢复码</h4>
<p style="font-size:13px;color:#e65100;margin-bottom:8px;">每个恢复码仅可使用一次。请妥善保管。</p>
<div id="mfa-recovery-codes" style="font-family:monospace;font-size:13px;line-height:2;"></div>
<div style="margin-top:8px;display:flex;gap:8px;">
<button type="button" id="mfa-copy-recovery" class="small-btn">复制</button>
<button type="button" id="mfa-regenerate-recovery" class="small-btn">重新生成</button>
</div>
<div id="mfa-recovery-msg" style="font-size:13px;color:var(--accent-red);margin-top:6px;"></div>
</div>
</div>
<div class="section">
<h3>退出登录</h3>
<button type="button" id="logout-btn" class="btn-danger">退出登录</button>
</div>
</section>
</main>
</div>
<script src="/static/js/theme.js"></script>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/control.js"></script>
</body>
</html>
+248
View File
@@ -0,0 +1,248 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Cal Display</title>
<link rel="stylesheet" href="/static/css/display.css">
</head>
<body>
<div id="app">
<!-- Left sidebar: small cards -->
<div id="sidebar">
<div class="card card-time" data-card="time" id="card-time">
<div class="card-left">
<div class="card-icon"><svg class="card-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/></svg></div>
<div class="card-label">时间</div>
</div>
<div class="card-value" id="time-value">--:--</div>
</div>
<div class="card card-weather" data-card="weather" id="card-weather">
<div class="card-left">
<img class="card-icon card-weather-icon" id="weather-card-icon" src="" alt="" style="display:none;">
<div class="card-label">天气</div>
</div>
<div class="card-value" id="weather-temp">--°</div>
</div>
<div class="card card-calendar" data-card="calendar" id="card-calendar">
<div class="card-left">
<div class="card-icon"><svg class="card-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/></svg></div>
<div class="card-label">日历</div>
</div>
<div class="card-value" id="calendar-next">无日程</div>
</div>
<div class="card card-alarm" data-card="alarm" id="card-alarm">
<div class="card-left">
<div class="card-icon"><svg class="card-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></div>
<div class="card-label">闹钟</div>
</div>
<div class="card-value" id="alarm-next">无闹钟</div>
</div>
<div class="card card-countdown" data-card="countdown" id="card-countdown">
<div class="card-left">
<div class="card-icon"><svg class="card-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="14" r="8"/><path d="M9.5 2h5"/><path d="M12 2v4"/><path d="M12 14l3.5-2.5"/></svg></div>
<div class="card-label">倒计时</div>
</div>
<div class="card-value" id="countdown-next">无</div>
</div>
<div class="card card-pomodoro" data-card="pomodoro" id="card-pomodoro">
<div class="pomo-sidebar-row">
<div class="pomo-sidebar-btns" id="pomo-sidebar-btns">
<button id="pomo-card-start" class="pomo-mini-btn pomo-mini-primary">开始</button>
<button id="pomo-card-pause" class="pomo-mini-btn pomo-mini-secondary" style="display:none;">暂停</button>
<button id="pomo-card-skip" class="pomo-mini-btn pomo-mini-secondary" style="display:none;">跳过</button>
<button id="pomo-card-stop" class="pomo-mini-btn pomo-mini-danger" style="display:none;">停止</button>
</div>
<div class="pomo-sidebar-info">
<div class="card-left">
<div class="card-icon"><svg class="card-icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="15.5" r="6.5"/><path d="M12 9v-2.5"/><path d="M12 6.5c-2.2-.3-3.4-1.4-3.7-3 1.4.2 2.5 1 3.7 3z"/><path d="M12 6.5c2.2-.3 3.4-1.4 3.7-3-1.4.2-2.5 1-3.7 3z"/></svg></div>
<div class="card-label">番茄钟</div>
</div>
<div class="card-value" id="pomodoro-value">--:--</div>
</div>
</div>
</div>
</div>
<!-- Right panel: expanded content -->
<div id="panel">
<div class="panel-header-btns">
<button id="refresh-btn" class="lock-btn" title="刷新">&#8635;</button>
<button id="fullscreen-btn" class="lock-btn" title="全屏">&#9974;</button>
<button id="lock-btn" class="lock-btn" title="锁定">&#128274;</button>
</div>
<div id="panel-time" class="panel-content">
<h2>时间</h2>
<div id="detailed-time" class="big-clock">--:--:--</div>
<div class="date-str" id="date-str"></div>
</div>
<div id="panel-weather" class="panel-content">
<h2>天气</h2>
<div class="weather-main">
<span class="weather-temp-big" id="weather-temp-big">--°</span>
<span class="weather-desc" id="weather-desc"></span>
<img class="weather-icon" id="weather-icon" src="" alt="" style="display:none;">
</div>
<div class="weather-details" id="weather-details"></div>
<div class="hourly-section">
<div class="hourly-title">逐小时预报</div>
<div class="hourly-list" id="hourly-list"></div>
</div>
<!-- Minutely Precipitation -->
<div class="hourly-section" id="minutely-section" style="display:none;">
<div class="section-title-row">
<span class="hourly-title">分钟级降水</span>
<button class="mini-refresh-btn" id="minutely-refresh-btn">刷新</button>
</div>
<div id="minutely-content"></div>
</div>
<!-- Weather Warnings -->
<div class="hourly-section" id="warning-section" style="display:none;">
<div class="section-title-row">
<span class="hourly-title">天气预警</span>
<button class="mini-refresh-btn" id="warning-refresh-btn">刷新</button>
</div>
<div id="warning-content"></div>
</div>
</div>
<div id="panel-calendar" class="panel-content">
<h2>日程</h2>
<div class="event-list" id="event-list"></div>
</div>
<div id="panel-alarm" class="panel-content">
<h2>闹钟</h2>
<div class="alarm-list" id="alarm-list"></div>
<button id="alarm-add-btn" class="panel-add-btn">+ 添加闹钟</button>
</div>
<div id="panel-countdown" class="panel-content">
<h2>倒计时</h2>
<div class="countdown-list" id="countdown-list"></div>
<button id="countdown-add-btn" class="panel-add-btn">+ 添加倒计时</button>
</div>
<div id="panel-pomodoro" class="panel-content">
<div class="pomo-bg-wrap">
<div class="pomo-bg-img"></div>
<div class="pomo-content">
<h2>番茄钟</h2>
<div id="pomodoro-display">
<div class="pomo-glass">
<div id="pomodoro-phase" class="pomo-phase-text">工作中</div>
<div id="pomodoro-timer" class="big-clock">--:--</div>
<div id="pomodoro-cycles">已完成 0 组</div>
<div id="pomodoro-next-break" class="pomo-next-break">下次: 短休息</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Alarm triggered overlay (hidden by default) -->
<div id="alarm-overlay" class="alarm-overlay hidden">
<div class="alarm-overlay-box">
<div class="alarm-overlay-icon">&#128276;</div>
<div class="alarm-overlay-title">闹钟</div>
<div class="alarm-overlay-info" id="alarm-overlay-info"></div>
<button id="alarm-dismiss" class="alarm-dismiss-btn">关闭闹钟</button>
</div>
</div>
<!-- Pomodoro triggered overlay (hidden by default) -->
<div id="pomodoro-overlay" class="alarm-overlay hidden">
<div class="alarm-overlay-box">
<div class="alarm-overlay-icon">&#127813;</div>
<div class="alarm-overlay-title" id="pomodoro-overlay-title">番茄钟</div>
<div class="alarm-overlay-info" id="pomodoro-overlay-info"></div>
<button id="pomodoro-dismiss" class="alarm-dismiss-btn">关闭铃声</button>
</div>
</div>
<!-- Custom notification overlay (hidden by default) -->
<div id="custom-notify-overlay" class="alarm-overlay hidden">
<div class="alarm-overlay-box">
<div class="alarm-overlay-icon" id="custom-notify-icon">&#128276;</div>
<div class="alarm-overlay-title" id="custom-notify-title">提醒</div>
<div class="alarm-overlay-info" id="custom-notify-info"></div>
<button id="custom-notify-dismiss" class="alarm-dismiss-btn">知道了</button>
</div>
</div>
<!-- Alarm add/edit modal -->
<div id="alarm-modal" class="modal-overlay hidden">
<div class="modal-box">
<div class="modal-title" id="alarm-modal-title">添加闹钟</div>
<div class="modal-body">
<!-- Name (read-only when editing, auto-generated when adding) -->
<div class="modal-field">
<div class="modal-label">名称</div>
<div class="alarm-modal-name" id="alarm-modal-name">闹钟 1</div>
</div>
<!-- Mouse-only time picker: hours + minutes -->
<div class="modal-field">
<div class="modal-label">时间</div>
<div class="time-picker">
<div class="tp-col">
<button class="tp-btn tp-up" data-target="hours">&#9650;</button>
<div class="tp-value" id="tp-hours">08</div>
<button class="tp-btn tp-down" data-target="hours">&#9660;</button>
</div>
<div class="tp-sep">:</div>
<div class="tp-col">
<button class="tp-btn tp-up" data-target="minutes">&#9650;</button>
<div class="tp-value" id="tp-minutes">00</div>
<button class="tp-btn tp-down" data-target="minutes">&#9660;</button>
</div>
<div class="tp-sep">:</div>
<div class="tp-col">
<button class="tp-btn tp-up" data-target="seconds">&#9650;</button>
<div class="tp-value" id="tp-seconds">00</div>
<button class="tp-btn tp-down" data-target="seconds">&#9660;</button>
</div>
</div>
</div>
<!-- Once-only toggle (alarm mode only) -->
<div class="modal-field" id="alarm-once-field">
<label class="toggle-label">
<input type="checkbox" id="alarm-modal-once">
<span class="toggle-slider"></span>
<span>仅一次</span>
</label>
</div>
<!-- Day-of-week picker (hidden when once is on) -->
<div class="modal-field" id="alarm-days-field">
<div class="modal-label">重复</div>
<div class="day-picker" id="alarm-modal-days">
<button class="day-btn active" data-day="1">一</button>
<button class="day-btn active" data-day="2">二</button>
<button class="day-btn active" data-day="3">三</button>
<button class="day-btn active" data-day="4">四</button>
<button class="day-btn active" data-day="5">五</button>
<button class="day-btn active" data-day="6">六</button>
<button class="day-btn active" data-day="7">日</button>
</div>
</div>
<!-- Enabled toggle -->
<div class="modal-field">
<label class="toggle-label">
<input type="checkbox" id="alarm-modal-enabled" checked>
<span class="toggle-slider"></span>
<span>启用</span>
</label>
</div>
</div>
<div class="modal-actions">
<button id="alarm-modal-save" class="pomo-btn pomo-btn-primary">保存</button>
<button id="alarm-modal-cancel" class="pomo-btn pomo-btn-secondary">取消</button>
</div>
</div>
</div>
<!-- Hidden audio for custom ringtone -->
<audio id="alarm-audio" loop preload="auto" style="display:none"></audio>
</div>
<script src="/static/js/theme.js"></script>
<script src="/static/js/display.js"></script>
</body>
</html>
+172
View File
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Cal Display - Custom</title>
<style>
:root {
--bg: #0f1117;
--text: #e0e0e0;
--text-dim: #888;
--accent: #4A90D9;
--radius: 12px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100vw; height: 100vh; height: 100dvh;
overflow: hidden;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
user-select: none; -webkit-user-select: none; touch-action: manipulation;
}
#custom-container {
width: 100%; height: 100%;
padding: clamp(10px, 2vw, 28px);
overflow-y: auto;
position: relative;
}
.alarm-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
width: 100vw; height: 100vh; height: 100dvh;
background: rgba(15, 17, 23, 0.92);
display: flex; align-items: center; justify-content: center;
z-index: 100;
}
.alarm-overlay.hidden { display: none; }
.alarm-overlay-box {
text-align: center;
padding: clamp(24px, 5vw, 56px) clamp(32px, 6.5vw, 72px);
background: var(--card-bg); border-radius: clamp(12px, 2vw, 24px);
border: 2px solid #E05555;
animation: overlay-in 0.3s ease;
}
@keyframes overlay-in {
from { transform: scale(0.85); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.alarm-overlay-icon { font-size: clamp(40px, 7vw, 80px); margin-bottom: clamp(8px, 1.5vh, 16px); }
.alarm-overlay-title { font-size: clamp(18px, 2.75vw, 36px); font-weight: 700; margin-bottom: clamp(6px, 1vh, 12px); color: #E05555; }
.alarm-overlay-info { font-size: clamp(14px, 2vw, 26px); color: var(--text-dim); margin-bottom: clamp(16px, 3vh, 32px); line-height: 1.6; }
.alarm-dismiss-btn {
padding: clamp(10px, 1.75vw, 20px) clamp(32px, 6vw, 64px);
font-size: clamp(14px, 2.25vw, 26px); font-weight: 600;
background: #E05555; color: #fff;
border: none; border-radius: clamp(8px, 1.25vw, 14px); cursor: pointer;
font-family: inherit;
}
.alarm-dismiss-btn:active { opacity: 0.8; }
</style>
</head>
<body>
<div id="custom-container">
{{ custom_html | safe }}
</div>
<!-- Custom notification overlay (hidden by default) -->
<div id="custom-notify-overlay" class="alarm-overlay hidden">
<div class="alarm-overlay-box">
<div class="alarm-overlay-icon" id="custom-notify-icon">&#128276;</div>
<div class="alarm-overlay-title" id="custom-notify-title">提醒</div>
<div class="alarm-overlay-info" id="custom-notify-info"></div>
<button id="custom-notify-dismiss" class="alarm-dismiss-btn">知道了</button>
</div>
</div>
<script>
(function () {
'use strict';
// Custom notification overlay
var customNotifyActive = false;
var customNotifyAutoDismiss = null;
var customNotifyAudioCtx = null;
function showCustomNotification(data) {
if (customNotifyActive) return;
customNotifyActive = true;
var overlay = document.getElementById('custom-notify-overlay');
var iconEl = document.getElementById('custom-notify-icon');
var titleEl = document.getElementById('custom-notify-title');
var infoEl = document.getElementById('custom-notify-info');
if (iconEl) iconEl.innerHTML = data.icon || '&#128276;';
if (titleEl) titleEl.textContent = data.title || '提醒';
if (infoEl) infoEl.textContent = data.message || '';
if (overlay) overlay.classList.remove('hidden');
if (data.sound !== false) {
playNotifySound();
}
customNotifyAutoDismiss = setTimeout(function () {
dismissCustomNotification();
}, 5000);
}
function playNotifySound() {
try {
var ctx = new (window.AudioContext || window.webkitAudioContext)();
customNotifyAudioCtx = ctx;
var t = ctx.currentTime;
[0, 0.3, 0.6].forEach(function (offset) {
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.setValueAtTime(1.0, t + offset);
gain.gain.exponentialRampToValueAtTime(0.001, t + offset + 0.15);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(t + offset);
osc.stop(t + offset + 0.15);
});
setTimeout(function () { ctx.close(); }, 2000);
} catch (_) {}
}
function dismissCustomNotification() {
customNotifyActive = false;
var overlay = document.getElementById('custom-notify-overlay');
if (overlay) overlay.classList.add('hidden');
if (customNotifyAudioCtx) {
try { customNotifyAudioCtx.close(); } catch (_) {}
customNotifyAudioCtx = null;
}
if (customNotifyAutoDismiss) {
clearTimeout(customNotifyAutoDismiss);
customNotifyAutoDismiss = null;
}
}
var customNotifyDismissBtn = document.getElementById('custom-notify-dismiss');
if (customNotifyDismissBtn) {
customNotifyDismissBtn.addEventListener('click', dismissCustomNotification);
}
// SSE: listen for control commands (force_refresh / mode_change / custom_notification)
function connectSSE() {
var es = new EventSource('/api/events');
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
location.reload();
} else if (msg.type === 'custom_notification') {
showCustomNotification(msg.data);
}
} catch (_) {}
};
es.onerror = function () {
es.close();
setTimeout(connectSSE, 5000);
};
}
connectSSE();
})();
</script>
</body>
</html>
+434
View File
@@ -0,0 +1,434 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>登录 - Cal Display</title>
<style>
:root {
--bg: #0f1117;
--card-bg: #1a1d2e;
--text: #e0e0e0;
--text-dim: #888;
--accent: #4A90D9;
--accent-green: #4CAF50;
--accent-red: #E05555;
--radius: 12px;
}
/* 浅色主题覆盖 */
.theme-light {
--bg: #e8edf4;
--card-bg: #ffffff;
--text: #1c2333;
--text-dim: #5b6a7e;
--accent: #2f6fb2;
--accent-green: #3a9450;
--accent-red: #c74545;
--radius: 12px;
}
/* 主题切换动画 */
html.theme-anim, html.theme-anim * {
transition: background-color 0.55s ease, color 0.55s ease,
border-color 0.55s ease, box-shadow 0.55s ease !important;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100vw; height: 100vh; height: 100dvh;
overflow: hidden;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
display: flex; align-items: center; justify-content: center;
user-select: none; -webkit-user-select: none; touch-action: manipulation;
}
.login-box {
width: min(92vw, 420px);
background: var(--card-bg);
border-radius: var(--radius);
padding: clamp(20px, 4vh, 36px) clamp(20px, 4vw, 32px);
text-align: center;
}
.login-box h1 { font-size: clamp(18px, 3vw, 26px); margin-bottom: 4px; font-weight: 600; }
.subtitle { font-size: clamp(12px, 1.8vw, 16px); color: var(--text-dim); margin-bottom: clamp(16px, 3vh, 24px); }
/* TOTP dots */
.totp-dots {
display: flex; justify-content: center; gap: clamp(8px, 1.5vw, 14px);
margin-bottom: clamp(16px, 3vh, 28px);
}
.totp-dot {
width: clamp(14px, 2.5vw, 18px); height: clamp(14px, 2.5vw, 18px);
border-radius: 50%;
border: 2px solid var(--text-dim);
background: transparent;
transition: background 0.15s, border-color 0.15s;
}
.totp-dot.filled { background: var(--accent); border-color: var(--accent); }
.totp-dot.error { background: var(--accent-red); border-color: var(--accent-red); }
/* Numpad */
.numpad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: clamp(10px, 2vw, 16px);
margin-bottom: 16px;
}
.numpad button {
font-family: inherit;
font-size: clamp(22px, 4vw, 32px);
font-weight: 500;
padding: clamp(12px, 2.5vh, 20px) 0;
border-radius: var(--radius);
border: none;
background: rgba(255,255,255,0.06);
color: var(--text);
cursor: pointer;
transition: background 0.1s;
}
.numpad button:active { background: rgba(255,255,255,0.14); }
.numpad .btn-empty { background: transparent; pointer-events: none; }
.numpad .btn-back { font-size: clamp(16px, 3vw, 24px); color: var(--text-dim); }
/* Alternative methods */
.alt-methods {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.alt-btn {
width: 100%;
padding: clamp(10px, 1.5vh, 14px);
font-size: clamp(13px, 1.8vw, 16px);
font-weight: 500;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
background: rgba(255,255,255,0.04);
color: var(--text-dim);
cursor: pointer;
font-family: inherit;
transition: background 0.1s, color 0.1s;
}
.alt-btn:active { background: rgba(255,255,255,0.10); color: var(--text); }
.alt-btn .icon { margin-right: 6px; }
/* Recovery code input mode */
.recovery-mode { display: none; }
.recovery-mode.visible { display: block; }
.recovery-mode input[type="text"] {
width: 100%;
padding: 14px;
font-size: 20px;
text-align: center;
letter-spacing: 2px;
border-radius: 8px;
border: 1px solid var(--text-dim);
background: rgba(255,255,255,0.04);
color: var(--text);
margin-bottom: 12px;
font-family: monospace;
}
.recovery-mode button {
width: 100%;
padding: 12px;
font-size: 16px;
border: none;
border-radius: 8px;
background: var(--accent-green);
color: #fff;
cursor: pointer;
font-family: inherit;
}
.msg { min-height: 22px; font-size: 14px; color: var(--accent-red); margin-top: 10px; }
.back-link {
display: inline-block;
margin-top: 12px;
font-size: 13px;
color: var(--accent);
cursor: pointer;
}
/* ═══ 浅色主题覆盖(登录页半透明白元素)═══ */
.theme-light .numpad button { background: #e4e9f0; }
.theme-light .numpad button:active { background: #d7dfe8; }
.theme-light .alt-btn { background: #f2f5fa; border-color: #d7dfe8; }
.theme-light .alt-btn:active { background: #e4e9f0; }
.theme-light .recovery-mode input[type="text"] { background: #fafafa; border-color: #d7dfe8; }
</style>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
<body>
<div class="login-box" id="login-box">
<h1>请输入验证码</h1>
<p class="subtitle" id="subtitle">输入 6 位验证码或临时密码</p>
<!-- TOTP dots -->
<div class="totp-dots" id="totp-dots">
<div class="totp-dot" data-i="0"></div>
<div class="totp-dot" data-i="1"></div>
<div class="totp-dot" data-i="2"></div>
<div class="totp-dot" data-i="3"></div>
<div class="totp-dot" data-i="4"></div>
<div class="totp-dot" data-i="5"></div>
</div>
<!-- TOTP Numpad -->
<div class="numpad" id="numpad">
<button data-key="1">1</button>
<button data-key="2">2</button>
<button data-key="3">3</button>
<button data-key="4">4</button>
<button data-key="5">5</button>
<button data-key="6">6</button>
<button data-key="7">7</button>
<button data-key="8">8</button>
<button data-key="9">9</button>
<button class="btn-empty"></button>
<button data-key="0">0</button>
<button class="btn-back" data-key="back">⌫</button>
</div>
<!-- Alternative methods -->
<div class="alt-methods" id="alt-methods">
<button class="alt-btn" id="passkey-alt-btn"><span class="icon">🔑</span>使用 Passkey 登录</button>
<button class="alt-btn" id="recovery-alt-btn"><span class="icon">🔐</span>使用恢复码</button>
</div>
<div class="msg" id="msg"></div>
<!-- Recovery code input -->
<div class="recovery-mode" id="recovery-mode">
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">输入一次性恢复码</p>
<input type="text" id="recovery-input" placeholder="ABCD-EFGHIJ" maxlength="15" autocomplete="off">
<button id="recovery-submit">验证恢复码</button>
<div class="back-link" id="back-to-totp">← 返回 TOTP</div>
</div>
<div style="margin-top:16px;font-size:12px;color:var(--text-dim);">
<a href="/register-request" style="color:var(--text-dim);">还没有账号?申请账号</a>
</div>
</div>
<script>
(function () {
'use strict';
var code = '';
var dots = document.querySelectorAll('.totp-dot');
var msgEl = document.getElementById('msg');
var redirect = new URLSearchParams(location.search).get('next') || '/';
var view = 'display';
// ─── Redirect ─────────────────────────
function _redirect(url) {
location.href = url;
}
// ─── Auth status check ──────────────────
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/auth/status');
xhr.onload = function () {
if (xhr.status === 200) {
var status = JSON.parse(xhr.responseText);
if (status.authenticated) {
_redirect(redirect);
}
}
};
xhr.send();
// ─── TOTP dots ──────────────────────────
function updateDots(ok) {
dots.forEach(function (d, i) {
d.classList.toggle('filled', i < code.length);
d.classList.toggle('error', ok === false);
});
if (ok === false) {
setTimeout(function () {
dots.forEach(function (d) { d.classList.remove('error'); });
}, 600);
}
}
function submitCode() {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/login');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status === 200) {
var res = JSON.parse(xhr.responseText || '{}');
if (res.method === 'temp_password' || res.must_configure_mfa) {
_redirect('/mfa-setup');
} else {
_redirect(redirect);
}
} else {
code = '';
updateDots(false);
msgEl.textContent = '验证码错误,请重试';
}
};
xhr.onerror = function () { msgEl.textContent = '网络错误'; };
var payload = { code: code, view: view };
xhr.send(JSON.stringify(payload));
}
document.getElementById('numpad').addEventListener('click', function (e) {
var key = e.target.dataset.key;
if (!key) return;
msgEl.textContent = '';
if (key === 'back') {
code = code.slice(0, -1);
updateDots();
return;
}
if (code.length < 6) {
code += key;
updateDots();
if (code.length === 6) {
submitCode();
}
}
});
// ─── Passkey ────────────────────────────
function triggerPasskey() {
msgEl.textContent = '正在请求安全密钥...';
var btn = document.getElementById('passkey-alt-btn');
btn.disabled = true;
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/webauthn/login/begin');
xhr.onload = function () {
if (xhr.status !== 200) {
msgEl.textContent = 'Passkey 不可用';
btn.disabled = false;
return;
}
var options = JSON.parse(xhr.responseText);
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
PasskeyBridge.assertion(options)
.then(function (credential) {
var verify = new XMLHttpRequest();
verify.open('POST', '/api/auth/webauthn/login/complete');
verify.setRequestHeader('Content-Type', 'application/json');
verify.onload = function () {
var res = JSON.parse(verify.responseText || '{}');
if (verify.status === 200) {
msgEl.textContent = '✅ 登录成功!';
setTimeout(function () { _redirect(redirect); }, 300);
} else {
msgEl.textContent = '❌ ' + (res.error || '验证失败');
btn.disabled = false;
}
};
var p = { credential: credential, view: view };
verify.send(JSON.stringify(p));
})
.catch(function (err) {
msgEl.textContent = '⏸️ ' + (err.message || '操作取消');
btn.disabled = false;
});
};
xhr.send();
}
// Auto-trigger Passkey on page load (with a small delay)
setTimeout(triggerPasskey, 800);
// ─── Passkey button (manual trigger) ──────
document.getElementById('passkey-alt-btn').addEventListener('click', triggerPasskey);
// ─── Recovery code ──────────────────────
document.getElementById('recovery-alt-btn').addEventListener('click', function () {
document.getElementById('totp-dots').style.display = 'none';
document.getElementById('numpad').style.display = 'none';
document.getElementById('alt-methods').style.display = 'none';
document.getElementById('recovery-mode').classList.add('visible');
document.getElementById('subtitle').textContent = '输入一次性恢复码';
});
document.getElementById('back-to-totp').addEventListener('click', function () {
document.getElementById('totp-dots').style.display = '';
document.getElementById('numpad').style.display = '';
document.getElementById('alt-methods').style.display = '';
document.getElementById('recovery-mode').classList.remove('visible');
document.getElementById('subtitle').textContent = 'TOTP 6 位验证码';
document.getElementById('recovery-input').value = '';
});
document.getElementById('recovery-submit').addEventListener('click', function () {
var code = document.getElementById('recovery-input').value;
msgEl.textContent = '';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/login/recovery');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) {
_redirect(redirect);
} else {
msgEl.textContent = res.error || '恢复码错误';
}
};
var payload = { code: code, view: view };
xhr.send(JSON.stringify(payload));
});
document.getElementById('recovery-input').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
document.getElementById('recovery-submit').click();
}
});
// ─── SSE background listener ────────────
(function () {
function connectSSE() {
var es = new EventSource('/api/events');
es.onmessage = function (e) {
try {
var msg = JSON.parse(e.data);
if (msg.type === 'force_refresh' || msg.type === 'display_mode_changed') {
location.href = '/';
} else if (msg.type === 'kick_all') {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/logout');
xhr.onload = function () { location.reload(); };
xhr.send();
}
} catch (_) {}
};
es.onerror = function () {
es.close();
setTimeout(connectSSE, 5000);
};
}
connectSSE();
})();
// ─── Helpers ────────────────────────────
// Convert base64url (from server/webauthn) to ArrayBuffer
function _base64ToArrayBuffer(b64) {
var base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) base64 += '=';
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
// Convert ArrayBuffer to base64url (for WebAuthn API)
function _arrayBufferToBase64(buf) {
var bytes = new Uint8Array(buf);
var binary = '';
for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
})();
</script>
</body>
</html>
+401
View File
@@ -0,0 +1,401 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - Cal Display 控制端</title>
<style>
:root {
--bg: #0f1117;
--card-bg: #1a1d2e;
--text: #e0e0e0;
--text-dim: #888;
--accent: #4A90D9;
--accent-green: #4CAF50;
--accent-red: #E05555;
--radius: 12px;
--input-bg: #242838;
--input-border: rgba(255,255,255,0.14);
--divider: rgba(255,255,255,0.16);
--card-border: rgba(255,255,255,0.10);
--soft-bg: rgba(255,255,255,0.06);
--shadow: 0 4px 24px rgba(0,0,0,0.30);
}
/* 浅色主题覆盖 */
.theme-light {
--bg: #e8edf4;
--card-bg: #ffffff;
--text: #1c2333;
--text-dim: #5b6a7e;
--accent: #2f6fb2;
--accent-green: #3a9450;
--accent-red: #c74545;
--radius: 12px;
--input-bg: #fafafa;
--input-border: #d7dfe8;
--divider: #d7dfe8;
--card-border: transparent;
--soft-bg: #eef2f7;
--shadow: 0 2px 12px rgba(30,40,60,0.08);
}
/* 主题切换动画 */
html.theme-anim, html.theme-anim * {
transition: background-color 0.55s ease, color 0.55s ease,
border-color 0.55s ease, box-shadow 0.55s ease !important;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
min-height: 100vh;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
display: flex; align-items: center; justify-content: center;
}
.login-box {
width: min(92vw, 400px);
background: var(--card-bg);
border-radius: var(--radius);
padding: 32px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
text-align: center;
}
.login-box h1 { font-size: 22px; margin-bottom: 8px; font-weight: 600; }
.login-box .subtitle { font-size: 14px; color: var(--text-dim); margin-bottom: 24px; }
/* Passkey section */
.passkey-section { margin-bottom: 20px; }
.passkey-btn {
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 600;
border: 2px solid var(--accent);
border-radius: var(--radius);
background: var(--accent);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: opacity 0.15s;
}
.passkey-btn:hover { opacity: 0.9; }
.passkey-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.passkey-btn .icon { font-size: 22px; }
.passkey-status { font-size: 13px; color: var(--text-dim); margin-top: 8px; min-height: 20px; }
/* Divider */
.divider {
display: flex;
align-items: center;
gap: 12px;
margin: 20px 0;
color: var(--text-dim);
font-size: 13px;
}
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: #e0e0e0; }
/* TOTP form */
.totp-section { display: none; }
.totp-section.visible { display: block; }
.totp-section input[type="text"] {
width: 100%;
padding: 12px;
font-size: 24px;
text-align: center;
letter-spacing: 8px;
border-radius: 8px;
border: 1px solid #ddd;
background: #fafafa;
color: var(--text);
margin-bottom: 12px;
font-family: monospace;
}
.totp-section button {
width: 100%;
padding: 12px;
font-size: 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.totp-section button:hover { opacity: 0.9; }
/* Recovery code section */
.recovery-link {
display: inline-block;
margin-top: 12px;
font-size: 13px;
color: var(--accent);
cursor: pointer;
text-decoration: underline;
}
.recovery-link:hover { opacity: 0.8; }
.recovery-section { display: none; }
.recovery-section.visible { display: block; }
.recovery-section input[type="text"] {
width: 100%;
padding: 12px;
font-size: 18px;
text-align: center;
letter-spacing: 2px;
border-radius: 8px;
border: 1px solid #ddd;
background: #fafafa;
color: var(--text);
margin-bottom: 12px;
font-family: monospace;
}
.recovery-section button {
width: 100%;
padding: 12px;
font-size: 16px;
border: none;
border-radius: 8px;
background: var(--accent-green);
color: #fff;
cursor: pointer;
}
.recovery-section button:hover { opacity: 0.9; }
.msg { min-height: 22px; font-size: 14px; color: var(--accent-red); margin-top: 10px; }
/* ═══ 主题适配(登录页)═══ */
body { background: var(--bg); }
.login-box { background: var(--card-bg); box-shadow: var(--shadow); border: 1px solid var(--card-border); }
.totp-section input[type="text"], .recovery-section input[type="text"] {
background: var(--input-bg); border-color: var(--input-border); color: var(--text);
}
.divider::before, .divider::after { background: var(--divider); }
</style>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
<body>
<div class="login-box">
<h1>🔐 控制端登录</h1>
<p class="subtitle" id="subtitle">正在准备登录...</p>
<!-- Passkey -->
<div class="passkey-section" id="passkey-section">
<button class="passkey-btn" id="passkey-btn">
<span class="icon">🔑</span>
<span>使用 Passkey 登录</span>
</button>
<div class="passkey-status" id="passkey-status">点击上方按钮或等待自动调起</div>
</div>
<div class="divider" id="divider">或</div>
<!-- Code input (TOTP or temp password) -->
<div class="totp-section" id="code-section">
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">输入验证码或临时密码 (6位数字)</p>
<form id="code-form">
<input type="text" id="code-input" placeholder="000000" maxlength="6" inputmode="numeric" pattern="[0-9]*" autocomplete="off" required>
<button type="submit">登录</button>
<div class="msg" id="code-msg"></div>
</form>
<span class="recovery-link" id="show-recovery">使用恢复码</span>
</div>
<!-- Recovery Code -->
<div class="recovery-section" id="recovery-section">
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px;">输入一次性恢复码 (格式: XXXX-XXXXXX)</p>
<form id="recovery-form">
<input type="text" id="recovery-input" placeholder="ABCD-EFGHIJ" maxlength="15" autocomplete="off" required>
<button type="submit">验证</button>
<div class="msg" id="recovery-msg"></div>
</form>
<span class="recovery-link" id="show-totp">返回 TOTP</span>
</div>
<div style="margin-top:20px;font-size:13px;">
<a href="/register-request" style="color:var(--accent);text-decoration:none;">还没有账号?申请账号</a>
</div>
</div>
<script>
(function () {
'use strict';
var redirect = new URLSearchParams(location.search).get('next') || '/control';
var view = 'control';
// ─── Redirect ─────────────────────────
function _redirect(url) {
location.href = url;
}
// ─── Check auth status ──────────────────
checkStatus(function (status) {
if (status.authenticated) {
_redirect(redirect);
return;
}
if (status.temp_pending) {
_redirect('/mfa-setup');
return;
}
// Show unified login: Passkey + 6-digit input (accepts TOTP or temp password)
showLoginForm(status.mfa_configured);
});
function checkStatus(callback) {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/auth/status');
xhr.onload = function () {
if (xhr.status === 200) callback(JSON.parse(xhr.responseText));
};
xhr.send();
}
function showLoginForm(mfaConfigured) {
document.getElementById('passkey-section').style.display = 'block';
document.getElementById('divider').style.display = 'flex';
document.getElementById('code-section').classList.add('visible');
if (mfaConfigured) {
document.getElementById('subtitle').textContent = '使用 Passkey 或输入 TOTP 验证码';
} else {
document.getElementById('subtitle').textContent = '首次使用?输入控制台显示的临时密码 (6位)';
}
// Auto-trigger Passkey if available
setTimeout(triggerPasskey, 500);
}
// ─── Unified Login (TOTP / Temp Password) ─
document.getElementById('code-form').addEventListener('submit', function (e) {
e.preventDefault();
var code = document.getElementById('code-input').value;
var msg = document.getElementById('code-msg');
msg.textContent = '';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/login');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) {
if (res.method === 'temp_password' || res.must_configure_mfa) {
_redirect('/mfa-setup');
} else {
_redirect(redirect);
}
} else {
msg.textContent = res.error || '验证失败';
document.getElementById('code-input').value = '';
}
};
var payload = { code: code, view: view };
xhr.send(JSON.stringify(payload));
});
// Auto-submit on 6 digits
document.getElementById('code-input').addEventListener('input', function () {
if (this.value.length === 6) {
document.getElementById('code-form').dispatchEvent(new Event('submit'));
}
});
// ─── Recovery Code Login ────────────────
document.getElementById('show-recovery').addEventListener('click', function () {
document.getElementById('code-section').classList.remove('visible');
document.getElementById('recovery-section').classList.add('visible');
document.getElementById('subtitle').textContent = '输入一次性恢复码';
});
document.getElementById('show-totp').addEventListener('click', function () {
document.getElementById('recovery-section').classList.remove('visible');
document.getElementById('code-section').classList.add('visible');
document.getElementById('subtitle').textContent = '输入验证码或临时密码 (6位数字)';
});
document.getElementById('recovery-form').addEventListener('submit', function (e) {
e.preventDefault();
var code = document.getElementById('recovery-input').value;
var msg = document.getElementById('recovery-msg');
msg.textContent = '';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/login/recovery');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) {
_redirect(redirect);
} else {
msg.textContent = res.error || '恢复码错误';
document.getElementById('recovery-input').value = '';
}
};
var payload = { code: code, view: view };
xhr.send(JSON.stringify(payload));
});
// ─── Passkey Login ──────────────────────
function triggerPasskey() {
var statusEl = document.getElementById('passkey-status');
var btn = document.getElementById('passkey-btn');
statusEl.textContent = '正在请求安全密钥...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/webauthn/login/begin');
xhr.onload = function () {
if (xhr.status !== 200) {
statusEl.textContent = 'Passkey 不可用,请使用其他方式';
return;
}
var options = JSON.parse(xhr.responseText);
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
PasskeyBridge.assertion(options)
.then(function (credential) {
var verify = new XMLHttpRequest();
verify.open('POST', '/api/auth/webauthn/login/complete');
verify.setRequestHeader('Content-Type', 'application/json');
verify.onload = function () {
var res = JSON.parse(verify.responseText || '{}');
if (verify.status === 200) {
statusEl.textContent = '✅ 登录成功!';
_redirect(redirect);
} else {
statusEl.textContent = '❌ ' + (res.error || '验证失败');
document.getElementById('code-input').focus();
}
};
var p = { credential: credential, view: view };
verify.send(JSON.stringify(p));
})
.catch(function (err) {
statusEl.textContent = '⏸️ ' + (err.message || '操作取消');
document.getElementById('code-input').focus();
});
};
xhr.send();
}
document.getElementById('passkey-btn').addEventListener('click', triggerPasskey);
// ─── Helpers ────────────────────────────
function _base64ToArrayBuffer(b64) {
var base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) base64 += '=';
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
function _arrayBufferToBase64(buf) {
var bytes = new Uint8Array(buf);
var binary = '';
for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
})();
</script>
</body>
</html>
+456
View File
@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>安全设置 - Cal Display</title>
<style>
:root {
--bg: #0f1117;
--card-bg: #1a1d2e;
--text: #e0e0e0;
--text-dim: #888;
--accent: #4A90D9;
--accent-green: #4CAF50;
--accent-red: #E05555;
--radius: 12px;
--input-bg: #242838;
--input-border: rgba(255,255,255,0.14);
--divider: rgba(255,255,255,0.16);
--card-border: rgba(255,255,255,0.10);
--soft-bg: rgba(255,255,255,0.06);
--shadow: 0 4px 24px rgba(0,0,0,0.30);
}
/* 浅色主题覆盖 */
.theme-light {
--bg: #e8edf4;
--card-bg: #ffffff;
--text: #1c2333;
--text-dim: #5b6a7e;
--accent: #2f6fb2;
--accent-green: #3a9450;
--accent-red: #c74545;
--radius: 12px;
--input-bg: #fafafa;
--input-border: #d7dfe8;
--divider: #d7dfe8;
--card-border: transparent;
--soft-bg: #eef2f7;
--shadow: 0 2px 12px rgba(30,40,60,0.08);
}
/* 主题切换动画 */
html.theme-anim, html.theme-anim * {
transition: background-color 0.55s ease, color 0.55s ease,
border-color 0.55s ease, box-shadow 0.55s ease !important;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
min-height: 100vh;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
display: flex; align-items: center; justify-content: center;
}
.setup-box {
width: min(92vw, 500px);
background: var(--card-bg);
border-radius: var(--radius);
padding: 28px 32px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.setup-box h1 { font-size: 22px; margin-bottom: 6px; font-weight: 600; }
.setup-box .subtitle { font-size: 14px; color: var(--text-dim); margin-bottom: 24px; }
.method-card {
border: 2px solid #e0e0e0;
border-radius: var(--radius);
padding: 20px;
margin-bottom: 16px;
transition: border-color 0.2s;
}
.method-card.active { border-color: var(--accent); }
.method-card.done { border-color: var(--accent-green); }
.method-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.method-header h3 { font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.method-header .badge {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.badge-done { background: #e8f5e9; color: #2e7d32; }
.badge-pending { background: #fff3e0; color: #e65100; }
.method-body { font-size: 14px; color: var(--text-dim); line-height: 1.6; }
.method-body .btn {
display: inline-block;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
margin-top: 12px;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { opacity: 0.9; }
.btn-success { background: var(--accent-green); color: #fff; }
.btn-success:hover { opacity: 0.9; }
.btn-secondary { background: #eee; color: var(--text); }
.btn-secondary:hover { opacity: 0.9; }
.totp-setup input { font-size: 24px; letter-spacing: 8px; text-align: center; padding: 10px; width: 100%; border: 1px solid #ddd; border-radius: 8px; font-family: monospace; margin-top: 8px; }
.totp-setup .qr-wrap { text-align: center; margin: 12px 0; }
.totp-setup .qr-wrap img { max-width: 200px; border-radius: 8px; }
.totp-setup .secret-display { font-family: monospace; font-size: 14px; background: #f5f5f5; padding: 8px; border-radius: 6px; text-align: center; margin: 8px 0; word-break: break-all; }
.passkey-status { font-size: 13px; color: var(--text-dim); margin-top: 8px; min-height: 20px; }
.recovery-section { margin-top: 20px; padding: 16px; background: #fff8e1; border-radius: 8px; border: 1px solid #ffe082; display: none; }
.recovery-section.visible { display: block; }
.recovery-section h4 { font-size: 14px; color: #e65100; margin-bottom: 8px; }
.recovery-codes { font-family: monospace; font-size: 14px; line-height: 2; }
.recovery-codes code { display: inline-block; background: #fff; padding: 2px 8px; border-radius: 4px; margin: 2px; border: 1px solid #e0e0e0; }
.finish-section { margin-top: 20px; text-align: center; }
.finish-section .btn { padding: 14px 40px; font-size: 16px; }
.msg { font-size: 13px; color: var(--accent-red); margin-top: 8px; min-height: 20px; }
.success { color: var(--accent-green); }
/* ═══ 主题适配(MFA 设置页)═══ */
body { background: var(--bg); }
.setup-box { background: var(--card-bg); box-shadow: var(--shadow); border: 1px solid var(--card-border); }
.method-card { border-color: var(--divider); }
.method-card.active { border-color: var(--accent); }
.totp-setup input { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
.secret-display { background: var(--soft-bg); }
.btn-secondary { background: var(--soft-bg); color: var(--text); }
.recovery-section { background: rgba(255,200,87,0.10); border-color: rgba(255,200,87,0.30); }
.badge-done { background: rgba(74,175,80,0.18); color: #7bc47f; }
.badge-pending { background: rgba(230,81,0,0.20); color: #ffb27a; }
.theme-light .recovery-section { background: #fff8e1; border-color: #ffe082; }
.theme-light .badge-done { background: #e8f5e9; color: #2e7d32; }
.theme-light .badge-pending { background: #fff3e0; color: #e65100; }
</style>
<script src="/static/js/passkey_bridge.js"></script>
<script src="/static/js/theme.js"></script>
</head>
<body>
<div class="setup-box">
<h1>🔐 安全设置</h1>
<p class="subtitle">请至少配置一种双重认证方式以保护你的设备</p>
<!-- Passkey -->
<div class="method-card" id="passkey-card">
<div class="method-header">
<h3>🔑 Passkey (生物/设备密钥)</h3>
<span class="badge badge-pending" id="passkey-badge">未配置</span>
</div>
<div class="method-body">
<p>使用设备内置的安全模块(指纹、面容、PIN 或安全密钥)快速登录。</p>
<button class="btn btn-primary" id="register-passkey-btn">注册 Passkey</button>
<div class="passkey-status" id="passkey-status"></div>
</div>
</div>
<!-- TOTP -->
<div class="method-card" id="totp-card">
<div class="method-header">
<h3>📱 TOTP (身份验证器应用)</h3>
<span class="badge badge-pending" id="totp-badge">未配置</span>
</div>
<div class="method-body">
<p>使用 Google Authenticator、Microsoft Authenticator 等应用扫描二维码。</p>
<button class="btn btn-secondary" id="setup-totp-btn">设置 TOTP</button>
<div id="totp-setup-area" style="display:none;" class="totp-setup">
<div class="qr-wrap" id="totp-qr-wrap"></div>
<div class="secret-display" id="totp-secret-display"></div>
<p style="font-size:13px;color:var(--text-dim);">在应用中添加后,输入显示的 6 位验证码确认:</p>
<input type="text" id="totp-verify-input" maxlength="6" inputmode="numeric" pattern="[0-9]*" placeholder="000000">
<button class="btn btn-success" id="totp-verify-btn">验证并启用</button>
<div id="totp-msg" class="msg"></div>
</div>
</div>
</div>
<!-- Recovery Codes (shown after first MFA configured) -->
<div class="recovery-section" id="recovery-section">
<h4>⚠️ 恢复码(请妥善保管)</h4>
<p style="font-size:13px;color:#e65100;margin-bottom:8px;">每个恢复码仅可使用一次。建议截图或打印保存。</p>
<div class="recovery-codes" id="recovery-codes"></div>
<button class="btn btn-secondary" id="copy-recovery-btn" style="margin-top:8px;">📋 复制</button>
</div>
<!-- Finish -->
<div class="finish-section">
<button class="btn btn-success" id="finish-btn">✅ 完成设置,进入控制面板</button>
<div id="finish-msg" class="msg" style="margin-top:8px;"></div>
</div>
</div>
<script>
(function () {
'use strict';
var passkeyDone = false;
var totpDone = false;
// ─── Check MFA status ──────────────────
function checkMFAStatus() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/auth/mfa/status');
xhr.onload = function () {
if (xhr.status === 200) {
var s = JSON.parse(xhr.responseText);
// Temp accounts must configure ONE login method to activate the account.
if (s.is_temp_account) {
document.querySelector('.setup-box h1').textContent = '🔐 激活你的账号';
document.querySelector('.subtitle').textContent =
'请配置至少一种登录方式(Passkey 或 TOTP)。配置并验证完成后,你的临时验证码将失效,账号立即正式生效。';
}
if (s.passkey_enabled) { passkeyDone = true; updatePasskeyUI(true); }
if (s.totp_enabled) { totpDone = true; updateTOTPUI(true); }
if (s.recovery_codes_remaining > 0) {
showRecoveryCodes();
}
updateFinishBtn();
} else if (xhr.status === 401) {
// Not logged in — redirect to login
location.href = '/login?view=control&next=/mfa-setup';
}
};
xhr.send();
}
checkMFAStatus();
// ─── Passkey ───────────────────────────
function updatePasskeyUI(done) {
var card = document.getElementById('passkey-card');
var badge = document.getElementById('passkey-badge');
if (done) {
card.classList.add('done');
badge.textContent = '✅ 已配置';
badge.className = 'badge badge-done';
document.getElementById('register-passkey-btn').textContent = '重新注册';
}
}
document.getElementById('register-passkey-btn').addEventListener('click', function () {
var statusEl = document.getElementById('passkey-status');
var btn = this;
btn.disabled = true;
statusEl.textContent = '正在准备注册...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/webauthn/register/begin');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status !== 200) {
statusEl.textContent = '❌ 获取注册参数失败';
btn.disabled = false;
return;
}
var options = JSON.parse(xhr.responseText);
// Passkey 中间件:浏览器走 WebAuthn;iOS App 走原生 ASAuthorizationController
PasskeyBridge.creation(options)
.then(function (credential) {
credential.user_name = options.user && options.user.name ? options.user.name : 'user';
var verify = new XMLHttpRequest();
verify.open('POST', '/api/auth/webauthn/register/complete');
verify.setRequestHeader('Content-Type', 'application/json');
verify.onload = function () {
var res = JSON.parse(verify.responseText || '{}');
if (verify.status === 200) {
statusEl.textContent = '✅ Passkey 注册成功!';
passkeyDone = true;
updatePasskeyUI(true);
checkRecoveryAndFinish();
if (res.finalized) {
statusEl.textContent = '✅ 账号已激活!即将进入控制面板...';
setTimeout(function () { location.href = '/'; }, 2500);
}
} else {
statusEl.textContent = '❌ ' + (res.error || '注册失败');
btn.disabled = false;
}
};
verify.send(JSON.stringify({ credential: credential }));
})
.catch(function (err) {
statusEl.textContent = '⏸️ ' + (err.message || '操作取消');
btn.disabled = false;
});
};
xhr.send();
});
// ─── TOTP ──────────────────────────────
function updateTOTPUI(done) {
var card = document.getElementById('totp-card');
var badge = document.getElementById('totp-badge');
if (done) {
card.classList.add('done');
badge.textContent = '✅ 已配置';
badge.className = 'badge badge-done';
document.getElementById('setup-totp-btn').textContent = '重新设置';
}
}
document.getElementById('setup-totp-btn').addEventListener('click', function () {
var area = document.getElementById('totp-setup-area');
if (area.style.display === 'block') {
area.style.display = 'none';
return;
}
area.style.display = 'block';
document.getElementById('setup-totp-btn').textContent = '取消';
// Request TOTP setup from server
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/totp/setup');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status !== 200) return;
var data = JSON.parse(xhr.responseText);
document.getElementById('totp-qr-wrap').innerHTML = '<img src="data:image/png;base64,' + data.qr_base64 + '" alt="TOTP QR Code">';
document.getElementById('totp-secret-display').textContent = '密钥: ' + data.secret;
};
xhr.send();
});
document.getElementById('totp-verify-btn').addEventListener('click', function () {
var code = document.getElementById('totp-verify-input').value;
var msg = document.getElementById('totp-msg');
msg.textContent = '';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/totp/verify');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200) {
msg.className = 'msg success';
msg.textContent = '✅ TOTP 已启用!';
totpDone = true;
updateTOTPUI(true);
document.getElementById('setup-totp-btn').textContent = '重新设置';
// Show recovery codes
if (res.recovery_codes && res.recovery_codes.length > 0) {
showRecoveryCodesList(res.recovery_codes);
} else {
checkMFAStatus();
}
checkRecoveryAndFinish();
if (res.finalized) {
msg.textContent = '✅ 账号已激活!即将进入控制面板...';
setTimeout(function () { location.href = '/'; }, 2500);
}
} else {
msg.textContent = res.error || '验证失败';
}
};
xhr.send(JSON.stringify({ code: code }));
});
// ─── Recovery codes ────────────────────
function showRecoveryCodesList(codes) {
var section = document.getElementById('recovery-section');
var container = document.getElementById('recovery-codes');
section.classList.add('visible');
container.innerHTML = codes.map(function (c) { return '<code>' + c + '</code>'; }).join(' ');
}
function showRecoveryCodes() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/auth/recovery-codes');
xhr.onload = function () {
if (xhr.status === 200) {
var data = JSON.parse(xhr.responseText);
if (data.codes && data.codes.length > 0) {
showRecoveryCodesList(data.codes);
}
}
};
xhr.send();
}
function checkRecoveryAndFinish() {
// After any MFA is set up, fetch recovery codes
showRecoveryCodes();
updateFinishBtn();
}
document.getElementById('copy-recovery-btn').addEventListener('click', function () {
var text = document.getElementById('recovery-codes').textContent;
navigator.clipboard.writeText(text).then(function () {
document.getElementById('copy-recovery-btn').textContent = '✅ 已复制';
setTimeout(function () {
document.getElementById('copy-recovery-btn').textContent = '📋 复制';
}, 2000);
});
});
// ─── Finish ────────────────────────────
function updateFinishBtn() {
var btn = document.getElementById('finish-btn');
if (passkeyDone || totpDone) {
btn.disabled = false;
btn.textContent = '✅ 完成设置,进入控制面板';
} else {
btn.disabled = true;
btn.textContent = '⚠️ 请至少配置一种认证方式';
}
}
document.getElementById('finish-btn').addEventListener('click', function () {
var msg = document.getElementById('finish-msg');
msg.textContent = '正在完成设置...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/mfa/complete');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
if (xhr.status === 200) {
location.href = '/';
} else {
var res = JSON.parse(xhr.responseText || '{}');
msg.textContent = res.error || '完成失败,请重试';
}
};
xhr.send();
});
// ─── Helpers ────────────────────────────
// Convert base64url (from server/webauthn) to ArrayBuffer
function _base64ToArrayBuffer(b64) {
var base64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) base64 += '=';
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes.buffer;
}
// Convert ArrayBuffer to base64url (for WebAuthn API)
function _arrayBufferToBase64(buf) {
var bytes = new Uint8Array(buf);
var binary = '';
for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
})();
</script>
</body>
</html>
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Passkey 验证</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
min-height: 100vh;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: #0f1117;
color: #fff;
display: flex; align-items: center; justify-content: center;
}
.box { text-align: center; padding: 24px; max-width: 420px; }
.icon { font-size: 44px; margin-bottom: 16px; }
h1 { font-size: 20px; margin-bottom: 10px; font-weight: 600; }
p { font-size: 14px; color: rgba(255,255,255,.55); line-height: 1.7; }
/* 浅色主题覆盖 */
.theme-light { background: #e8edf4; }
.theme-light body { background: #e8edf4; }
.theme-light .box { color: #1c2333; }
.theme-light p { color: rgba(28,35,51,0.65); }
/* 主题切换动画 */
html.theme-anim, html.theme-anim * {
transition: background-color 0.55s ease, color 0.55s ease !important;
}
</style>
<script src="/static/js/theme.js"></script>
</head>
<body>
<div class="box">
<div class="icon">🔑</div>
<h1>Passkey 验证</h1>
<p id="tip">正在准备安全验证…</p>
</div>
<script src="/static/js/passkey_bridge.js"></script>
<script>
// 本页由 iOS 套壳 App 通过 ASWebAuthenticationSession 小窗加载(Safari 环境,
// 原生支持 WebAuthn/Passkey,无需 App 关联域名)。页面从服务端一次性 stash
// 取回 {op, options},走标准 WebAuthn 仪式,完成后跳回 dashboard://passkey。
(function () {
'use strict';
var tip = document.getElementById('tip');
var params = new URLSearchParams(location.search);
var op = params.get('op');
var sid = params.get('sid');
if (!op || !sid) {
tip.textContent = '缺少 Passkey 参数,请返回重试。';
return;
}
fetch('/api/passkey/stash/' + encodeURIComponent(sid))
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.options) throw new Error(data.error || 'Passkey 参数已失效');
var options = data.options;
tip.textContent = '请在系统 Passkey 面板中完成验证…';
// 中间件:本页运行于小窗(Safari 环境),PasskeyBridge 判定为浏览器模式,
// 直接调用 navigator.credentials;返回纯 base64url JSON credential。
return (op === 'creation' ? PasskeyBridge.creation(options) : PasskeyBridge.assertion(options))
.then(function (credential) {
var user_name = (options.user && options.user.name) ? options.user.name : '';
// credential 较大,POST 回服务端;回调 URL 只带短 sid(避免 URL 截断)
return fetch('/api/passkey/stash/' + encodeURIComponent(sid) + '/result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential: credential, user_name: user_name })
}).then(function (r) { return r.json(); })
.then(function (res) {
if (!res.ok) throw new Error(res.error || '结果保存失败');
location.href = 'dashboard://passkey?sid=' + encodeURIComponent(sid);
});
});
})
.catch(function (err) {
location.href = 'dashboard://passkey?error=' + encodeURIComponent((err && err.message) || 'Passkey 验证失败');
});
})();
</script>
</body>
</html>
+151
View File
@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>申请账号 - Cal Display</title>
<style>
:root {
--bg: #0f1117;
--card-bg: #1a1d2e;
--text: #e0e0e0;
--text-dim: #888;
--accent: #4A90D9;
--accent-green: #4CAF50;
--accent-red: #E05555;
--radius: 12px;
--input-bg: #242838;
--input-border: rgba(255,255,255,0.14);
--divider: rgba(255,255,255,0.16);
--card-border: rgba(255,255,255,0.10);
--soft-bg: rgba(255,255,255,0.06);
--shadow: 0 4px 24px rgba(0,0,0,0.30);
}
/* 浅色主题覆盖 */
.theme-light {
--bg: #e8edf4;
--card-bg: #ffffff;
--text: #1c2333;
--text-dim: #5b6a7e;
--accent: #2f6fb2;
--accent-green: #3a9450;
--accent-red: #c74545;
--radius: 12px;
--input-bg: #fafafa;
--input-border: #d7dfe8;
--divider: #d7dfe8;
--card-border: transparent;
--soft-bg: #eef2f7;
--shadow: 0 2px 12px rgba(30,40,60,0.08);
}
/* 主题切换动画 */
html.theme-anim, html.theme-anim * {
transition: background-color 0.55s ease, color 0.55s ease,
border-color 0.55s ease, box-shadow 0.55s ease !important;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
min-height: 100vh;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
display: flex; align-items: center; justify-content: center;
}
.box {
width: min(92vw, 420px);
background: var(--card-bg);
border-radius: var(--radius);
padding: 32px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
text-align: center;
}
.box h1 { font-size: 22px; margin-bottom: 8px; font-weight: 600; }
.box .subtitle { font-size: 14px; color: var(--text-dim); margin-bottom: 24px; line-height: 1.6; }
input[type="text"] {
width: 100%;
padding: 12px;
font-size: 16px;
border-radius: 8px;
border: 1px solid #ddd;
background: #fafafa;
margin-bottom: 14px;
}
button {
width: 100%;
padding: 13px;
font-size: 16px;
border: none;
border-radius: 8px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.msg { min-height: 22px; font-size: 14px; margin-top: 12px; }
.msg.ok { color: var(--accent-green); }
.msg.err { color: var(--accent-red); }
.back { display: inline-block; margin-top: 16px; font-size: 13px; color: var(--accent); text-decoration: none; }
/* ═══ 主题适配(注册申请页)═══ */
body { background: var(--bg); }
.box { background: var(--card-bg); box-shadow: var(--shadow); border: 1px solid var(--card-border); }
.box input[type="text"] { background: var(--input-bg); border-color: var(--input-border); color: var(--text); }
</style>
<script src="/static/js/theme.js"></script>
</head>
<body>
<div class="box">
<h1>📋 申请账号</h1>
<p class="subtitle">填写你的称呼,提交后等待管理员确认。管理员确认后你会收到一个临时验证码,用它登录并配置你自己的登录方式(Passkey 或 TOTP)。</p>
<input type="text" id="remark-input" placeholder="你的称呼 / 备注" maxlength="64" autocomplete="off">
<button id="submit-btn">提交申请</button>
<div class="msg" id="msg"></div>
<a class="back" href="/login">← 返回登录</a>
</div>
<script>
(function () {
'use strict';
var btn = document.getElementById('submit-btn');
var input = document.getElementById('remark-input');
var msg = document.getElementById('msg');
function submit() {
var remark = input.value.trim();
if (!remark) { msg.className = 'msg err'; msg.textContent = '请填写你的称呼'; return; }
btn.disabled = true;
msg.textContent = '提交中...';
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/auth/register-request');
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
var res = JSON.parse(xhr.responseText || '{}');
if (xhr.status === 200 || xhr.status === 201) {
msg.className = 'msg ok';
msg.textContent = res.message || '✅ 已提交申请,请等待管理员确认';
input.disabled = true;
btn.disabled = true;
btn.textContent = '已提交';
} else {
msg.className = 'msg err';
msg.textContent = res.error || '提交失败,请稍后再试';
btn.disabled = false;
}
};
xhr.onerror = function () {
msg.className = 'msg err';
msg.textContent = '网络错误,请重试';
btn.disabled = false;
};
xhr.send(JSON.stringify({ remark: remark }));
}
btn.addEventListener('click', submit);
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') submit(); });
})();
</script>
</body>
</html>