feat: 授权延长——同事一键申请,管理员批准时决定时长;时长支持 30 分钟与自定义
同事端 - 用过邀请链接的设备再次打开链接时显示自己的授权状态:有效时可申请延长, 访问结束(到期或通道关闭)后 2 小时内也可以;超过 2 小时或被撤销则提示重新获取邀请 - 「申请延长」只有一个按钮,提交后复用审批状态页,批准后显示新的到期时间 - 用过链接的设备不再自动重新放通,统一走申请延长 网关 - 授权结束后保留 2 小时的记录(通道关闭后仍保留,随状态文件持久化);撤销的不能延长, 删除通道 / 解散组时一并清除(unregister 带 deleted 标记) - 新增 extend_lease:有效授权从原到期时间顺延,已结束的从现在起算,不受通道最长开启时长截断, 并批准该设备的延长申请;指标里带上全部可延长设备与申请(包括已关闭的通道) - 授权时长支持分钟数("30m"、"90m",5 分钟到 30 天) Agent / App / 网页面板 - POST .../leases/extend:通道已关闭时先自动开启(组则全组开启),并让通道至少开到授权结束 - 授权管理新增「申请延长」「最近结束 · 2 小时内可延长」,已授权设备可直接「延长」; 卡片、菜单栏和系统通知提示延长申请,菜单栏可直接选时长批准 - 邀请的可访问时长、通道最长开启时长增加 30 分钟与自定义;网页面板弹窗层级高于侧边抽屉 - 邀请分享文案提示「授权结束后 2 小时内可重新打开此链接申请延长」
This commit is contained in:
1 parent
9a675ba96e
commit
dceb59c0ca
16 files changed
+1707
-75
No files matched your search
@@ -240,7 +240,7 @@
|
||||
.modal-overlay {
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(8px);
|
||||
display: none; align-items: center; justify-content: center; z-index: 1000;
|
||||
display: none; align-items: center; justify-content: center; z-index: 1002;
|
||||
}
|
||||
.modal-overlay.open { display: flex; }
|
||||
.modal-card {
|
||||
@@ -339,6 +339,9 @@
|
||||
.dim-toggle { display: inline-flex; border: 1px solid var(--card-border); border-radius: 6px; overflow: hidden; float: right; }
|
||||
.dim-toggle button { background: none; border: 0; color: var(--text-muted); font-size: 11px; padding: 2px 10px; cursor: pointer; }
|
||||
.dim-toggle button.on { background: rgba(56, 189, 248, 0.18); color: var(--text); }
|
||||
.chip-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip-btn { background: rgba(148, 163, 184, 0.1); border: 1px solid var(--card-border); color: var(--text); font-size: 12px; padding: 4px 10px; border-radius: 6px; cursor: pointer; }
|
||||
.chip-btn.on { background: rgba(56, 189, 248, 0.18); border-color: #38bdf8; color: #38bdf8; font-weight: 600; }
|
||||
.add-member { background: none; border: 0; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 8px 0; }
|
||||
.add-member:hover { color: var(--text); }
|
||||
@media (max-width: 860px) { .member-name, .member-meta { display: none; } }
|
||||
@@ -499,12 +502,14 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>最长开启时长(可选,到时无论是否在用都会关闭)</label>
|
||||
<select id="tunnelMaxDuration">
|
||||
<select id="tunnelMaxDuration" onchange="onCustomDuration(this, false)">
|
||||
<option value="0" selected>不限制</option>
|
||||
<option value="30">30 分钟</option>
|
||||
<option value="120">2 小时</option>
|
||||
<option value="480">8 小时</option>
|
||||
<option value="1440">1 天</option>
|
||||
<option value="10080">7 天</option>
|
||||
<option value="custom">自定义…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -536,6 +541,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Extend access(批准延长申请 / 主动延长) -->
|
||||
<div class="modal-overlay" id="extendModal">
|
||||
<div class="modal-card" style="width: 420px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="extendTitle">延长访问</h3>
|
||||
<button class="btn-secondary btn-sm" onclick="closeExtendModal()">✕</button>
|
||||
</div>
|
||||
<div class="input-hint" id="extendHint" style="margin: -8px 0 14px;"></div>
|
||||
<div class="form-group">
|
||||
<label>延长多久</label>
|
||||
<div class="chip-row" id="extendChips"></div>
|
||||
<div class="input-row" style="margin-top: 8px;">
|
||||
<input type="number" id="extendAmount" min="1" placeholder="自定义数量" oninput="updateExtend()">
|
||||
<select id="extendUnit" style="max-width: 100px;" onchange="updateExtend()">
|
||||
<option value="1">分钟</option>
|
||||
<option value="60">小时</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeExtendModal()">取消</button>
|
||||
<button type="button" class="btn-primary" id="extendConfirm" onclick="confirmExtend()">延长</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Invite -->
|
||||
<div class="modal-overlay" id="inviteModal">
|
||||
<div class="modal-card">
|
||||
@@ -566,12 +597,14 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>授权后可访问</label>
|
||||
<select id="inviteLease">
|
||||
<select id="inviteLease" onchange="onCustomDuration(this, true)">
|
||||
<option value="30m">30 分钟</option>
|
||||
<option value="1h">1 小时</option>
|
||||
<option value="2h" selected>2 小时</option>
|
||||
<option value="8h">8 小时</option>
|
||||
<option value="today">当天有效</option>
|
||||
<option value="tunnel">通道开启期间</option>
|
||||
<option value="custom">自定义…</option>
|
||||
</select>
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top:10px; font-weight: normal;">
|
||||
<input type="checkbox" id="inviteApproval" style="width:auto;"> 同事打开链接后需要我批准才放通
|
||||
@@ -716,7 +749,30 @@
|
||||
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60), sec = secs % 60;
|
||||
return h > 0 ? `${h}小时${m}分` : (m > 0 ? `${m}分${sec}秒` : `${sec}秒`);
|
||||
};
|
||||
const fmtMinutes = m => m >= 1440 && m % 1440 === 0 ? `${m / 1440} 天` : (m >= 60 && m % 60 === 0 ? `${m / 60} 小时` : `${m} 分钟`);
|
||||
const fmtMinutes = m => m >= 1440 && m % 1440 === 0 ? `${m / 1440} 天`
|
||||
: (m >= 60 && m % 60 === 0 ? `${m / 60} 小时` : (m > 60 ? `${Math.floor(m / 60)} 小时 ${m % 60} 分钟` : `${m} 分钟`));
|
||||
|
||||
// 自定义时长:输入数量 + 单位(如 90、90m、1.5h),返回分钟数;取消或无效返回 0
|
||||
function askMinutes() {
|
||||
const v = (prompt('自定义时长(如 90 表示 90 分钟,3h 表示 3 小时)') || '').trim().toLowerCase();
|
||||
const m = /^(\d+(?:\.\d+)?)\s*(h|小时|m|分钟)?$/.exec(v);
|
||||
if (!m) return 0;
|
||||
const minutes = Math.round(parseFloat(m[1]) * (m[2] === 'h' || m[2] === '小时' ? 60 : 1));
|
||||
return minutes >= 5 && minutes <= 43200 ? minutes : 0;
|
||||
}
|
||||
|
||||
// 下拉框选「自定义…」:询问时长并加入为一个选项;lease 为 true 时取值形如 "90m"
|
||||
function onCustomDuration(sel, lease) {
|
||||
if (sel.value !== 'custom') { sel.dataset.prev = sel.value; return; }
|
||||
const m = askMinutes();
|
||||
if (!m) { sel.value = sel.dataset.prev || (lease ? '2h' : '0'); return; }
|
||||
const value = lease ? `${m}m` : String(m);
|
||||
if (![...sel.options].some(o => o.value === value)) {
|
||||
sel.add(new Option(fmtMinutes(m), value), sel.querySelector('option[value="custom"]'));
|
||||
}
|
||||
sel.value = value;
|
||||
sel.dataset.prev = value;
|
||||
}
|
||||
const ago = v => (Date.now() - new Date(v)) / 1000;
|
||||
|
||||
let totalRateHistory = null;
|
||||
@@ -779,6 +835,8 @@
|
||||
let html = '';
|
||||
if (t.max_bandwidth_mb) html += `<span class="chip purple">限速 ${t.max_bandwidth_mb} MB/s</span>`;
|
||||
if ((m.pending || []).length) html += `<span class="chip orange" onclick="openDrawer('t:${id}')">🔔 待审批 ${m.pending.length}</span>`;
|
||||
const asks = (t.renewals || []).filter(r => r.request).length;
|
||||
if (asks) html += `<span class="chip orange" onclick="openDrawer('t:${id}')">⏱ 申请延长 ${asks}</span>`;
|
||||
if ((m.dynamic_leases || []).length) html += `<span class="chip green" onclick="openDrawer('t:${id}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`;
|
||||
return html;
|
||||
}
|
||||
@@ -1522,19 +1580,19 @@
|
||||
}
|
||||
function scopeInfo(key) {
|
||||
const s = parseScope(key);
|
||||
let name, members, m;
|
||||
let name, members, m, renewals;
|
||||
if (s.group) {
|
||||
const g = currentGroups.find(x => x.id === s.id);
|
||||
if (!g) return null;
|
||||
name = g.name; members = groupMembers(g); m = g.metrics || {};
|
||||
name = g.name; members = groupMembers(g); m = g.metrics || {}; renewals = g.renewals;
|
||||
} else {
|
||||
const t = currentTunnels.find(x => x.id === s.id);
|
||||
if (!t) return null;
|
||||
name = t.name; members = [t]; m = t.metrics || {};
|
||||
name = t.name; members = [t]; m = t.metrics || {}; renewals = t.renewals;
|
||||
}
|
||||
return {
|
||||
group: s.group, name, members, enabled: members.some(t => t.enabled),
|
||||
leases: m.dynamic_leases || [], invites: m.invites || [], pending: m.pending || [],
|
||||
leases: m.dynamic_leases || [], invites: m.invites || [], pending: m.pending || [], renewals: renewals || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1557,7 +1615,10 @@
|
||||
|
||||
const cardStyle = 'background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; display:flex; justify-content:space-between; align-items:center; gap: 10px;';
|
||||
const pad2 = n => String(n).padStart(2, '0');
|
||||
const leaseTitle = d => ({ '1h': '1 小时', '8h': '8 小时', today: '当天有效', tunnel: '通道开启期间' }[d] || '2 小时');
|
||||
const leaseTitle = d => ({ '1h': '1 小时', '8h': '8 小时', today: '当天有效', tunnel: '通道开启期间' }[d]
|
||||
|| (/^\d+m$/.test(d || '') ? fmtMinutes(parseInt(d, 10)) : '2 小时'));
|
||||
const fmtClock = v => { const d = new Date(v); const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
return d.toDateString() === new Date().toDateString() ? hm : `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${hm}`; };
|
||||
const fmtDT = v => {
|
||||
const d = new Date(v);
|
||||
const hm = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
@@ -1567,9 +1628,31 @@
|
||||
function renderLeases(key) {
|
||||
const info = scopeInfo(key);
|
||||
if (!info) return { pending: '', auth: '' };
|
||||
const { leases, pending, invites } = info;
|
||||
const { leases, pending, invites, renewals } = info;
|
||||
const k = escapeHtml(key);
|
||||
let html = '';
|
||||
const closedTag = info.enabled ? '' : ' <span style="font-size: 11px; color: var(--text-muted);">· 通道已关闭,批准后自动开启</span>';
|
||||
const extendBtn = (r, asking) => `<button class="${asking ? 'btn-primary' : 'btn-secondary'} btn-sm" onclick='openExtendModal(${escapeHtml(JSON.stringify(key))}, ${escapeHtml(JSON.stringify(r.ip))}, ${escapeHtml(JSON.stringify(r.name || ''))}, ${asking})'>${asking ? '批准延长…' : '延长…'}</button>`;
|
||||
const renewDetail = r => [
|
||||
r.request ? `申请于 ${fmtClock(r.request.at)}` : '',
|
||||
r.ended_at ? `访问已于 ${fmtClock(r.ended_at)} 结束` : '',
|
||||
r.renew_before ? `${fmtClock(r.renew_before)} 前可延长` : '',
|
||||
!r.ended_at ? '当前仍有效,批准后在原到期时间上顺延' : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
const renewCard = r => `
|
||||
<div style="${cardStyle}">
|
||||
<div><b>${escapeHtml(r.name || '未署名')}</b> <code style="color:#38bdf8;">${escapeHtml(r.ip)}</code>${closedTag}
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${renewDetail(r)}</div></div>
|
||||
<div class="drawer-row-actions">
|
||||
${r.request ? `<button class="btn-secondary btn-sm" onclick="decideRequest('${k}', '${escapeHtml(r.request.id)}', false)">拒绝</button>` : ''}
|
||||
${extendBtn(r, !!r.request)}
|
||||
</div>
|
||||
</div>`;
|
||||
const asks = renewals.filter(r => r.request), ended = renewals.filter(r => !r.request && r.ended_at);
|
||||
if (asks.length) {
|
||||
html += `<b style="font-size:13px; display:block; margin: 4px 0 10px; color:#fbbf24;">申请延长 (${asks.length})</b>`;
|
||||
html += asks.map(renewCard).join('');
|
||||
}
|
||||
|
||||
if (pending.length) {
|
||||
html += `<b style="font-size:13px; display:block; margin: 4px 0 10px; color:#fbbf24;">待审批 (${pending.length})</b>`;
|
||||
@@ -1607,10 +1690,18 @@
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">${l.awaiting_connect
|
||||
? `已授权,尚未连接 · 首次连接后可访问 ${leaseTitle(l.duration)} · 请在 ${fmtDT(l.expires_at)} 前连接`
|
||||
: `${l.started_at ? '首次连接 ' + fmtDT(l.started_at) + ' · ' : ''}有效期至 ${fmtDT(l.expires_at)} · 剩余约 ${mins} 分钟`}</div></div>
|
||||
<button class="btn-danger btn-sm" onclick="revokeLease('${k}', '${escapeHtml(l.ip)}')">撤销</button>
|
||||
<div class="drawer-row-actions">
|
||||
${l.awaiting_connect ? '' : extendBtn(l, false)}
|
||||
<button class="btn-danger btn-sm" onclick="revokeLease('${k}', '${escapeHtml(l.ip)}')">撤销</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
if (ended.length) {
|
||||
html += `<b style="font-size:13px; display:block; margin: 8px 0 10px;">最近结束 · 2 小时内可延长 (${ended.length})</b>`;
|
||||
html += ended.map(renewCard).join('');
|
||||
}
|
||||
|
||||
html += `<b style="font-size:13px; display:block; margin: 8px 0 10px;">有效的邀请链接 (${invites.length})</b>`;
|
||||
html += invites.length === 0
|
||||
? '<div style="color: var(--text-muted); font-size: 12px; margin-bottom: 14px;">暂无有效的邀请链接</div>'
|
||||
@@ -1716,6 +1807,63 @@
|
||||
document.getElementById('inviteResult').style.display = 'block';
|
||||
}
|
||||
|
||||
// ---- 延长访问:批准延长申请或主动延长,确定时长后立即生效;通道已关闭时会自动重新开启 ----
|
||||
let extendTarget = null;
|
||||
const EXTEND_OPTIONS = [[30, '30 分钟'], [60, '1 小时'], [120, '2 小时'], [480, '8 小时']];
|
||||
|
||||
function openExtendModal(key, ip, name, asking) {
|
||||
const info = scopeInfo(key);
|
||||
extendTarget = { key, ip };
|
||||
document.getElementById('extendTitle').innerText = `${asking ? '批准延长' : '延长访问'} · ${name || ip}`;
|
||||
document.getElementById('extendHint').innerText = (info && !info.enabled ? '通道当前已关闭,延长后会自动重新开启。' : '')
|
||||
+ '访问还没结束时从原到期时间顺延;已结束时从现在开始计算。';
|
||||
document.getElementById('extendChips').innerHTML = EXTEND_OPTIONS.map(([m, t]) =>
|
||||
`<button type="button" class="chip-btn" data-m="${m}" onclick="setExtendMinutes(${m})">${t}</button>`).join('');
|
||||
setExtendMinutes(60);
|
||||
document.getElementById('extendModal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeExtendModal() {
|
||||
document.getElementById('extendModal').classList.remove('open');
|
||||
extendTarget = null;
|
||||
}
|
||||
|
||||
function setExtendMinutes(m) {
|
||||
const hours = m >= 60 && m % 60 === 0;
|
||||
document.getElementById('extendAmount').value = hours ? m / 60 : m;
|
||||
document.getElementById('extendUnit').value = hours ? '60' : '1';
|
||||
updateExtend();
|
||||
}
|
||||
|
||||
function extendMinutes() {
|
||||
const n = parseInt(document.getElementById('extendAmount').value, 10);
|
||||
const m = n * parseInt(document.getElementById('extendUnit').value, 10);
|
||||
return n > 0 && m <= 43200 ? m : 0;
|
||||
}
|
||||
|
||||
function updateExtend() {
|
||||
const m = extendMinutes();
|
||||
document.querySelectorAll('#extendChips .chip-btn').forEach(b => b.classList.toggle('on', +b.dataset.m === m));
|
||||
const btn = document.getElementById('extendConfirm');
|
||||
btn.disabled = !m;
|
||||
btn.innerText = m ? `延长 ${fmtMinutes(m)}` : '请输入 1 分钟到 30 天';
|
||||
}
|
||||
|
||||
async function confirmExtend() {
|
||||
const m = extendMinutes();
|
||||
if (!extendTarget || !m) return;
|
||||
const btn = document.getElementById('extendConfirm');
|
||||
btn.disabled = true;
|
||||
btn.innerText = '处理中…';
|
||||
const res = await fetch(`${scopeBase(extendTarget.key)}/leases/extend`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ip: extendTarget.ip, minutes: m })
|
||||
});
|
||||
if (!res.ok) { alert('延长失败: ' + await apiError(res)); updateExtend(); return; }
|
||||
closeExtendModal();
|
||||
await fetchTunnels();
|
||||
}
|
||||
|
||||
async function revokeLease(key, ip) {
|
||||
if (!confirm(ip ? `撤销 ${ip} 的访问权限?该设备的连接会被立即断开。` : '撤销全部已授权设备?')) return;
|
||||
const res = await fetch(`${scopeBase(key)}/leases/revoke`, {
|
||||
@@ -1874,6 +2022,8 @@
|
||||
}
|
||||
const m = g.metrics || {}, key = escapeHtml('g:' + g.id);
|
||||
if ((m.pending || []).length) parts.push(`<span class="chip orange" onclick="openDrawer('${key}')">🔔 待审批 ${m.pending.length}</span>`);
|
||||
const asks = (g.renewals || []).filter(r => r.request).length;
|
||||
if (asks) parts.push(`<span class="chip orange" onclick="openDrawer('${key}')">⏱ 申请延长 ${asks}</span>`);
|
||||
if ((m.dynamic_leases || []).length) parts.push(`<span class="chip green" onclick="openDrawer('${key}')">已授权 ${m.dynamic_leases.length} 台 ›</span>`);
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user