Add optional expiration that starts on first config use.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-07-26 05:07:50 +03:00
co-authored by Cursor
parent 16d14a7256
commit 745b5e5d8c
9 changed files with 245 additions and 35 deletions
+90 -25
View File
@@ -109,10 +109,21 @@
<div class="form-group">
<label class="form-label">{{ _('expiration_date_label') }}</label>
<div class="grid" style="display: grid; grid-template-columns: 1fr 120px auto; gap: var(--space-sm); align-items: center;">
<label style="display:flex; align-items:center; gap:var(--space-sm); margin-bottom:var(--space-sm); cursor:pointer;">
<input type="checkbox" id="newExpireAfterFirstUse" onchange="toggleExpireAfterFirstUse('new')">
{{ _('expire_after_first_use') }}
</label>
<div id="newExpireAbsoluteWrap" class="grid" style="display: grid; grid-template-columns: 1fr 120px auto; gap: var(--space-sm); align-items: center;">
<input class="form-input" type="date" id="newExpirationDate" aria-label="Expiration date">
<select class="form-select" id="newExpirationTime" aria-label="Expiration time"></select>
<button type="button" class="btn btn-secondary btn-sm" onclick="clearDateTimeFields('newExpirationDate', 'newExpirationTime')">Без срока</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="clearDateTimeFields('newExpirationDate', 'newExpirationTime')">{{ _('no_expiration') }}</button>
</div>
<div id="newExpireDaysWrap" style="display:none;">
<div style="display:flex; gap:var(--space-sm); align-items:center;">
<input class="form-input" type="number" id="newExpirationDays" min="1" value="30" style="max-width:120px;">
<span style="color:var(--text-muted);">{{ _('days_short') }}</span>
</div>
<div class="form-hint">{{ _('expire_after_first_use_hint') }}</div>
</div>
</div>
@@ -224,10 +235,21 @@
</div>
<div class="form-group">
<label class="form-label">{{ _('expiration_date_label') }}</label>
<div class="grid" style="display: grid; grid-template-columns: 1fr 120px auto; gap: var(--space-sm); align-items: center;">
<label style="display:flex; align-items:center; gap:var(--space-sm); margin-bottom:var(--space-sm); cursor:pointer;">
<input type="checkbox" id="editExpireAfterFirstUse" onchange="toggleExpireAfterFirstUse('edit')">
{{ _('expire_after_first_use') }}
</label>
<div id="editExpireAbsoluteWrap" class="grid" style="display: grid; grid-template-columns: 1fr 120px auto; gap: var(--space-sm); align-items: center;">
<input class="form-input" type="date" id="editUserExpiration" aria-label="Expiration date">
<select class="form-select" id="editUserExpirationTime" aria-label="Expiration time"></select>
<button type="button" class="btn btn-secondary btn-sm" onclick="clearDateTimeFields('editUserExpiration', 'editUserExpirationTime')">Без срока</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="clearDateTimeFields('editUserExpiration', 'editUserExpirationTime')">{{ _('no_expiration') }}</button>
</div>
<div id="editExpireDaysWrap" style="display:none;">
<div style="display:flex; gap:var(--space-sm); align-items:center;">
<input class="form-input" type="number" id="editExpirationDays" min="1" value="30" style="max-width:120px;">
<span style="color:var(--text-muted);">{{ _('days_short') }}</span>
</div>
<div class="form-hint" id="editExpireDaysHint">{{ _('expire_after_first_use_hint') }}</div>
</div>
</div>
<div class="form-group">
@@ -501,7 +523,7 @@
${u.created_at ? `<span>📅 ${u.created_at.substring(0, 10)}</span>` : ''}
${u.telegramId ? `<span>💬 ${u.telegramId}</span>` : ''}
${u.email ? `<span>📧 ${u.email}</span>` : ''}
${u.expiration_date ? `<span style="color: ${new Date(u.expiration_date) < new Date() ? 'var(--danger)' : 'var(--text-muted)'}">⌛ ${_('expiration_date_label')}: ${new Date(u.expiration_date).toLocaleString()}</span>` : ''}
${formatExpirationBadge(u)}
</div>
${u.description ? `<div class="client-meta" style="font-size: 0.75rem; opacity: 0.8; margin-top: 2px;">📝 ${u.description}</div>` : ''}
${(() => {
@@ -545,7 +567,7 @@
onclick="openAddConnectionForUser('${u.id}', '${u.username}')"
title="${_('add_connection')}">${uiIcon('link')}</button>
<button class="btn btn-secondary btn-sm btn-icon"
onclick='openEditUser({"id": "${u.id}", "username": "${u.username}", "tg": "${u.telegramId || ""}", "email": "${u.email || ""}", "desc": "${u.description || ""}", "limit": "${u.traffic_limit ? (u.traffic_limit / Math.pow(1024, 3)).toFixed(2) : 0}", "strategy": "${u.traffic_reset_strategy || 'never'}", "expiration": "${u.expiration_date || ''}"})'
onclick='openEditUser({"id": "${u.id}", "username": "${u.username}", "tg": "${u.telegramId || ""}", "email": "${u.email || ""}", "desc": "${u.description || ""}", "limit": "${u.traffic_limit ? (u.traffic_limit / Math.pow(1024, 3)).toFixed(2) : 0}", "strategy": "${u.traffic_reset_strategy || 'never'}", "expiration": "${u.expiration_date || ''}", "expire_after_first_use": ${u.expire_after_first_use ? 'true' : 'false'}, "expiration_days": ${u.expiration_days || 0}})'
title="${_('edit')}">${uiIcon('pencil')}</button>
<button class="btn btn-secondary btn-sm btn-icon" onclick="openShareModal(this.dataset)"
data-id="${u.id}" data-username="${u.username}" data-token="${u.share_token || ''}"
@@ -665,8 +687,15 @@
description: document.getElementById('newDescription').value || null,
traffic_limit: parseFloat(document.getElementById('newTrafficLimit').value || '0'),
traffic_reset_strategy: document.getElementById('newTrafficResetStrategy').value,
expiration_date: getDateTimeValue('newExpirationDate', 'newExpirationTime'),
expire_after_first_use: document.getElementById('newExpireAfterFirstUse').checked,
expiration_days: parseInt(document.getElementById('newExpirationDays').value || '0'),
expiration_date: document.getElementById('newExpireAfterFirstUse').checked
? null
: getDateTimeValue('newExpirationDate', 'newExpirationTime'),
};
if (body.expire_after_first_use && (!body.expiration_days || body.expiration_days < 1)) {
throw new Error(_('expire_days_required'));
}
const serverId = document.getElementById('newUserServer').value;
if (serverId !== '') {
body.server_id = parseInt(serverId);
@@ -981,6 +1010,27 @@
return new Date(parsed.getTime() - offsetMs).toISOString().slice(0, 16);
}
function toggleExpireAfterFirstUse(prefix) {
const checked = document.getElementById(prefix === 'new' ? 'newExpireAfterFirstUse' : 'editExpireAfterFirstUse').checked;
const abs = document.getElementById(prefix === 'new' ? 'newExpireAbsoluteWrap' : 'editExpireAbsoluteWrap');
const days = document.getElementById(prefix === 'new' ? 'newExpireDaysWrap' : 'editExpireDaysWrap');
if (abs) abs.style.display = checked ? 'none' : 'grid';
if (days) days.style.display = checked ? '' : 'none';
}
function formatExpirationBadge(u) {
if (u.expire_after_first_use && !u.expiration_date) {
const days = u.expiration_days || 0;
return `<span style="color:var(--text-muted)">⌛ ${_('expire_pending_label').replace('{}', String(days))}</span>`;
}
if (u.expiration_date) {
const expired = new Date(u.expiration_date) < new Date();
const suffix = u.expire_after_first_use ? ` (${_('expire_started_suffix')})` : '';
return `<span style="color: ${expired ? 'var(--danger)' : 'var(--text-muted)'}">⌛ ${_('expiration_date_label')}: ${new Date(u.expiration_date).toLocaleString()}${suffix}</span>`;
}
return '';
}
function openEditUser(data) {
document.getElementById('editUserId').value = data.id;
document.getElementById('editUserTitle').textContent = data.username;
@@ -989,7 +1039,17 @@
document.getElementById('editUserDesc').value = data.desc || '';
document.getElementById('editUserLimit').value = data.limit || '0';
document.getElementById('editUserTrafficResetStrategy').value = data.strategy || 'never';
const afterFirst = !!data.expire_after_first_use;
document.getElementById('editExpireAfterFirstUse').checked = afterFirst;
document.getElementById('editExpirationDays').value = String(data.expiration_days || 30);
setDateTimeValue('editUserExpiration', 'editUserExpirationTime', data.expiration);
toggleExpireAfterFirstUse('edit');
const hint = document.getElementById('editExpireDaysHint');
if (hint) {
hint.textContent = (afterFirst && data.expiration)
? _('expire_already_started_hint').replace('{}', new Date(data.expiration).toLocaleString())
: _('expire_after_first_use_hint');
}
document.getElementById('editUserPass').value = '';
openModal('editUserModal');
}
@@ -997,34 +1057,39 @@
async function saveEditUser() {
const uid = document.getElementById('editUserId').value;
const btn = document.getElementById('saveEditBtn');
const text = document.getElementById('saveEditBtnText');
const spinner = document.getElementById('saveEditSpinner');
const payload = {
telegramId: document.getElementById('editUserTG').value,
email: document.getElementById('editUserEmail').value,
description: document.getElementById('editUserDesc').value,
const afterFirst = document.getElementById('editExpireAfterFirstUse').checked;
const days = parseInt(document.getElementById('editExpirationDays').value || '0');
if (afterFirst && days < 1) {
showToast(_('expire_days_required'), 'error');
return;
}
const body = {
telegramId: document.getElementById('editUserTG').value || null,
email: document.getElementById('editUserEmail').value || null,
description: document.getElementById('editUserDesc').value || null,
traffic_limit: parseFloat(document.getElementById('editUserLimit').value || '0'),
traffic_reset_strategy: document.getElementById('editUserTrafficResetStrategy').value,
expiration_date: getDateTimeValue('editUserExpiration', 'editUserExpirationTime'),
password: document.getElementById('editUserPass').value || null
expire_after_first_use: afterFirst,
expiration_days: afterFirst ? days : 0,
expiration_date: afterFirst ? null : getDateTimeValue('editUserExpiration', 'editUserExpirationTime'),
};
// If countdown already started, keep absolute date unless admin cleared first-use mode
if (afterFirst) {
const existing = getDateTimeValue('editUserExpiration', 'editUserExpirationTime');
if (existing) body.expiration_date = existing;
}
const pwd = document.getElementById('editUserPass').value;
if (pwd) body.password = pwd;
btn.disabled = true;
text.textContent = _('saving');
spinner.classList.remove('hidden');
try {
await apiCall(`/api/users/${uid}/update`, 'POST', payload);
await apiCall(`/api/users/${uid}/update`, 'POST', body);
showToast(_('success'), 'success');
closeModal('editUserModal');
refreshUsersList();
setTimeout(() => window.location.reload(), 400);
} catch (err) {
showToast(`${_('error')}: ` + err.message, 'error');
showToast(`${_('error')}: ${err.message}`, 'error');
} finally {
btn.disabled = false;
text.textContent = _('save');
spinner.classList.add('hidden');
}
}