fix inviting
This commit is contained in:
@@ -747,6 +747,7 @@
|
||||
"delete_btn": "Löschen",
|
||||
"delete_confirm": "Diesen Einladungstoken löschen?",
|
||||
"slot_refreshes_on": "Slot erneuert sich am {date}",
|
||||
"slot_refreshed": "Slot erneuert"
|
||||
"slot_refreshed": "Slot erneuert",
|
||||
"admin_desc": "Du bist Admin, leg los."
|
||||
}
|
||||
}
|
||||
@@ -749,6 +749,7 @@
|
||||
"delete_btn": "Delete",
|
||||
"delete_confirm": "Delete this invite token?",
|
||||
"slot_refreshes_on": "slot refreshes on {date}",
|
||||
"slot_refreshed": "slot refreshed"
|
||||
"slot_refreshed": "slot refreshed",
|
||||
"admin_desc": "You are an admin, go ahead."
|
||||
}
|
||||
}
|
||||
@@ -745,6 +745,7 @@
|
||||
"delete_btn": "Verwijderen",
|
||||
"delete_confirm": "Dit uitnodigingstoken verwijderen?",
|
||||
"slot_refreshes_on": "slot vernieuwd op {date}",
|
||||
"slot_refreshed": "slot vernieuwd"
|
||||
"slot_refreshed": "slot vernieuwd",
|
||||
"admin_desc": "Je bent admin, ga je gang."
|
||||
}
|
||||
}
|
||||
@@ -750,6 +750,7 @@
|
||||
"delete_btn": "Löschen",
|
||||
"delete_confirm": "Diesen Einladungskot löschen?",
|
||||
"slot_refreshes_on": "Platz erneuert sich am {date}",
|
||||
"slot_refreshed": "Platz erneuert"
|
||||
"slot_refreshed": "Platz erneuert",
|
||||
"admin_desc": "Du bist Admin, mach weiter."
|
||||
}
|
||||
}
|
||||
@@ -842,29 +842,9 @@ export default router => {
|
||||
const username = req.session.user;
|
||||
const totalSlots = getInviteSlots();
|
||||
const refreshDays = 30;
|
||||
const isAdmin = !!req.session.admin;
|
||||
|
||||
// Gather eligibility stats in one query
|
||||
const [stats] = await db`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count,
|
||||
EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days,
|
||||
(SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count,
|
||||
(SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count
|
||||
FROM "user" u
|
||||
WHERE u.id = ${userId}
|
||||
`;
|
||||
|
||||
const INVITE_CRITERIA = getInviteCriteria();
|
||||
const criteria = {
|
||||
uploads: { current: stats.upload_count, required: INVITE_CRITERIA.uploads, met: stats.upload_count >= INVITE_CRITERIA.uploads },
|
||||
age_days: { current: Math.floor(stats.age_days), required: INVITE_CRITERIA.age_days, met: stats.age_days >= INVITE_CRITERIA.age_days },
|
||||
comments: { current: stats.comment_count, required: INVITE_CRITERIA.comments, met: stats.comment_count >= INVITE_CRITERIA.comments },
|
||||
tags: { current: stats.tag_count, required: INVITE_CRITERIA.tags, met: stats.tag_count >= INVITE_CRITERIA.tags },
|
||||
};
|
||||
|
||||
const eligible = Object.values(criteria).every(c => c.met);
|
||||
|
||||
// Fetch all tokens this user created, join used_by name
|
||||
// Always fetch this user's token history
|
||||
const tokens = await db`
|
||||
SELECT
|
||||
it.id,
|
||||
@@ -879,19 +859,50 @@ export default router => {
|
||||
ORDER BY it.created_at DESC
|
||||
`;
|
||||
|
||||
// Slots consumed = tokens used within the last 30 days
|
||||
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
|
||||
const slotsConsumed = tokens.filter(t => t.is_used && t.used_at && new Date(t.used_at) > cutoff).length;
|
||||
const slotsAvailable = Math.max(0, totalSlots - slotsConsumed);
|
||||
let eligible, criteria, slotsConsumed, slotsAvailable;
|
||||
|
||||
if (isAdmin) {
|
||||
// Admins bypass all criteria and slot limits
|
||||
eligible = true;
|
||||
criteria = null;
|
||||
slotsConsumed = 0;
|
||||
slotsAvailable = Infinity;
|
||||
} else {
|
||||
// Gather eligibility stats in one query
|
||||
const [stats] = await db`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count,
|
||||
EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days,
|
||||
(SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count,
|
||||
(SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count
|
||||
FROM "user" u
|
||||
WHERE u.id = ${userId}
|
||||
`;
|
||||
|
||||
const INVITE_CRITERIA = getInviteCriteria();
|
||||
criteria = {
|
||||
uploads: { current: stats.upload_count, required: INVITE_CRITERIA.uploads, met: stats.upload_count >= INVITE_CRITERIA.uploads },
|
||||
age_days: { current: Math.floor(stats.age_days), required: INVITE_CRITERIA.age_days, met: stats.age_days >= INVITE_CRITERIA.age_days },
|
||||
comments: { current: stats.comment_count, required: INVITE_CRITERIA.comments, met: stats.comment_count >= INVITE_CRITERIA.comments },
|
||||
tags: { current: stats.tag_count, required: INVITE_CRITERIA.tags, met: stats.tag_count >= INVITE_CRITERIA.tags },
|
||||
};
|
||||
eligible = Object.values(criteria).every(c => c.met);
|
||||
|
||||
// Slots consumed = tokens used within the last 30 days
|
||||
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
|
||||
slotsConsumed = tokens.filter(t => t.is_used && t.used_at && new Date(t.used_at) > cutoff).length;
|
||||
slotsAvailable = Math.max(0, totalSlots - slotsConsumed);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
is_admin: isAdmin,
|
||||
eligible,
|
||||
criteria,
|
||||
tokens,
|
||||
slots_total: totalSlots,
|
||||
slots_consumed: slotsConsumed,
|
||||
slots_available: slotsAvailable,
|
||||
slots_total: isAdmin ? null : totalSlots,
|
||||
slots_consumed: isAdmin ? null : slotsConsumed,
|
||||
slots_available: isAdmin ? null : slotsAvailable,
|
||||
refresh_days: refreshDays,
|
||||
}, 200);
|
||||
} catch (e) {
|
||||
@@ -912,39 +923,43 @@ export default router => {
|
||||
const totalSlots = getInviteSlots();
|
||||
const refreshDays = 30;
|
||||
|
||||
// Eligibility check
|
||||
const [stats] = await db`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count,
|
||||
EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days,
|
||||
(SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count,
|
||||
(SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count
|
||||
FROM "user" u WHERE u.id = ${userId}
|
||||
`;
|
||||
const isAdmin = !!req.session.admin;
|
||||
|
||||
const INVITE_CRITERIA = getInviteCriteria();
|
||||
const eligible =
|
||||
stats.upload_count >= INVITE_CRITERIA.uploads &&
|
||||
stats.age_days >= INVITE_CRITERIA.age_days &&
|
||||
stats.comment_count >= INVITE_CRITERIA.comments &&
|
||||
stats.tag_count >= INVITE_CRITERIA.tags;
|
||||
if (!isAdmin) {
|
||||
// Eligibility check
|
||||
const [stats] = await db`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count,
|
||||
EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days,
|
||||
(SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count,
|
||||
(SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count
|
||||
FROM "user" u WHERE u.id = ${userId}
|
||||
`;
|
||||
|
||||
if (!eligible) {
|
||||
return res.json({ success: false, msg: 'You do not meet the eligibility criteria' }, 403);
|
||||
}
|
||||
const INVITE_CRITERIA = getInviteCriteria();
|
||||
const eligible =
|
||||
stats.upload_count >= INVITE_CRITERIA.uploads &&
|
||||
stats.age_days >= INVITE_CRITERIA.age_days &&
|
||||
stats.comment_count >= INVITE_CRITERIA.comments &&
|
||||
stats.tag_count >= INVITE_CRITERIA.tags;
|
||||
|
||||
// Check available slots (used within last 30 days)
|
||||
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
|
||||
const [{ slots_consumed }] = await db`
|
||||
SELECT COUNT(*)::int AS slots_consumed
|
||||
FROM invite_tokens
|
||||
WHERE created_by = ${userId}
|
||||
AND is_used = true
|
||||
AND used_at > ${cutoff}
|
||||
`;
|
||||
if (!eligible) {
|
||||
return res.json({ success: false, msg: 'You do not meet the eligibility criteria' }, 403);
|
||||
}
|
||||
|
||||
if (slots_consumed >= totalSlots) {
|
||||
return res.json({ success: false, msg: 'No invite slots available. Slots refresh 30 days after use.' }, 403);
|
||||
// Check available slots (used within last 30 days)
|
||||
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
|
||||
const [{ slots_consumed }] = await db`
|
||||
SELECT COUNT(*)::int AS slots_consumed
|
||||
FROM invite_tokens
|
||||
WHERE created_by = ${userId}
|
||||
AND is_used = true
|
||||
AND used_at > ${cutoff}
|
||||
`;
|
||||
|
||||
if (slots_consumed >= totalSlots) {
|
||||
return res.json({ success: false, msg: 'No invite slots available. Slots refresh 30 days after use.' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate token
|
||||
|
||||
@@ -3,7 +3,76 @@
|
||||
<div id="main">
|
||||
<div class="settings">
|
||||
<h1>{{ t('settings.title') }}</h1>
|
||||
<h2>{{ t('settings.avatar') }}</h2>
|
||||
|
||||
<!-- Quick navigation -->
|
||||
<nav id="settings-quicknav" aria-label="Settings sections">
|
||||
<a href="/settings#sec-avatar">{{ t('settings.avatar') }}</a>
|
||||
<a href="/settings#sec-preferences">{{ t('settings.preferences') }}</a>
|
||||
@if(enable_data_export)
|
||||
<a href="/settings#sec-export">{{ t('settings.export_data_title') }}</a>
|
||||
@endif
|
||||
<a href="/settings#sec-account">{{ t('settings.account') }}</a>
|
||||
@if(matrix_enabled)
|
||||
<a href="/settings#sec-linked">{{ t('settings.linked_accounts') }}</a>
|
||||
@endif
|
||||
@if(enable_user_api_keys)
|
||||
<a href="/settings#sec-apikey">API Key</a>
|
||||
@endif
|
||||
@if(enable_user_invites)
|
||||
<a href="/settings#sec-invites">{{ t('invites.section_title') }}</a>
|
||||
@endif
|
||||
</nav>
|
||||
<style>
|
||||
#settings-quicknav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 28px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid var(--nav-border-color);
|
||||
border-radius: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
#settings-quicknav a {
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--nav-border-color);
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#settings-quicknav a:hover,
|
||||
#settings-quicknav a.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
const nav = document.getElementById('settings-quicknav');
|
||||
if (!nav) return;
|
||||
const links = Array.from(nav.querySelectorAll('a[href^="#"]'));
|
||||
const targets = links.map(l => document.getElementById(l.getAttribute('href').slice(1))).filter(Boolean);
|
||||
if (!targets.length) return;
|
||||
const obs = new IntersectionObserver(entries => {
|
||||
entries.forEach(e => {
|
||||
const link = nav.querySelector('a[href="#' + e.target.id + '"]');
|
||||
if (link) link.classList.toggle('active', e.isIntersecting);
|
||||
});
|
||||
}, { rootMargin: '-10% 0px -80% 0px', threshold: 0 });
|
||||
targets.forEach(t => obs.observe(t));
|
||||
})();
|
||||
</script>
|
||||
|
||||
<h2 id="sec-avatar">{{ t('settings.avatar') }}</h2>
|
||||
<div class="avatar-settings-wrapper">
|
||||
<div class="avatar-preview-container">
|
||||
<div class="avatar-preview-label">{{ t('settings.current_avatar') }}</div>
|
||||
@@ -75,7 +144,7 @@
|
||||
</div>
|
||||
<small class="text-muted">{{ t('settings.username_color_hint') }}</small>
|
||||
</div>
|
||||
<h2>{{ t('settings.preferences') }}</h2>
|
||||
<h2 id="sec-preferences">{{ t('settings.preferences') }}</h2>
|
||||
<div class="preferences-settings-wrapper">
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
@@ -298,7 +367,7 @@
|
||||
|
||||
</div>
|
||||
@if(enable_data_export)
|
||||
<h2>{{ t('settings.export_data_title') || 'Export Data' }}</h2>
|
||||
<h2 id="sec-export">{{ t('settings.export_data_title') || 'Export Data' }}</h2>
|
||||
<div class="export-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<p>{{ t('settings.export_data_desc') || 'Download a copy of your data. This process happens entirely in your browser to protect your privacy and save server resources.' }}</p>
|
||||
|
||||
@@ -339,7 +408,7 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<h2>{{ t('settings.account') }}</h2>
|
||||
<h2 id="sec-account">{{ t('settings.account') }}</h2>
|
||||
<div class="account-settings-wrapper"
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<table class="table account-info-table"
|
||||
@@ -410,7 +479,7 @@
|
||||
</div>
|
||||
</div>
|
||||
@if(matrix_enabled)
|
||||
<h2>{{ t('settings.linked_accounts') }}</h2>
|
||||
<h2 id="sec-linked">{{ t('settings.linked_accounts') }}</h2>
|
||||
<div class="linked-accounts-wrapper">
|
||||
<p>{{ t('settings.matrix_link_desc') }}</p>
|
||||
|
||||
@@ -441,7 +510,7 @@
|
||||
@endif
|
||||
|
||||
@if(enable_user_api_keys)
|
||||
<h2>Upload API Key</h2>
|
||||
<h2 id="sec-apikey">Upload API Key</h2>
|
||||
<div id="api-key-section" class="account-settings-wrapper"
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<p style="color: var(--text-muted); margin-bottom: 15px;">
|
||||
@@ -475,11 +544,11 @@
|
||||
@endif
|
||||
|
||||
@if(enable_user_invites)
|
||||
<h2>{{ t('invites.section_title') }}</h2>
|
||||
<h2 id="sec-invites">{{ t('invites.section_title') }}</h2>
|
||||
<div id="invite-section" class="account-settings-wrapper"
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
|
||||
<p style="color: var(--text-muted); margin-bottom: 15px;">{{ t('invites.section_desc') }}</p>
|
||||
<p id="invite-section-desc" style="color: var(--text-muted); margin-bottom: 15px;">{{ t('invites.section_desc') }}</p>
|
||||
|
||||
<!-- Criteria grid -->
|
||||
<div id="invite-criteria-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 18px;">
|
||||
@@ -584,6 +653,7 @@
|
||||
data-slot-refreshes-on="{{ t('invites.slot_refreshes_on') }}"
|
||||
data-slot-refreshed="{{ t('invites.slot_refreshed') }}"
|
||||
data-generating="{{ t('invites.generating') }}"
|
||||
data-admin-desc="{{ t('invites.admin_desc') }}"
|
||||
></div>
|
||||
|
||||
<script>
|
||||
@@ -623,22 +693,32 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Update criteria grid
|
||||
const criteriaMap = {
|
||||
'ic-uploads': data.criteria.uploads,
|
||||
'ic-age': data.criteria.age_days,
|
||||
'ic-comments': data.criteria.comments,
|
||||
'ic-tags': data.criteria.tags,
|
||||
};
|
||||
Object.entries(criteriaMap).forEach(([id, c]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.classList.toggle('met', c.met);
|
||||
el.classList.toggle('unmet', !c.met);
|
||||
el.querySelector('.ic-icon').textContent = c.met ? '✓' : '✗';
|
||||
const unit = id === 'ic-age' ? ' ' + T('days') : '';
|
||||
el.querySelector('.ic-values').textContent = c.current + ' / ' + c.required + unit;
|
||||
});
|
||||
if (data.is_admin) {
|
||||
// Swap description and hide criteria/slots UI for admins
|
||||
const desc = document.getElementById('invite-section-desc');
|
||||
if (desc) desc.textContent = T('adminDesc');
|
||||
const grid = document.getElementById('invite-criteria-grid');
|
||||
const statusLine = document.getElementById('invite-status-line');
|
||||
if (grid) grid.style.display = 'none';
|
||||
if (statusLine) statusLine.style.display = 'none';
|
||||
} else {
|
||||
// Update criteria grid
|
||||
const criteriaMap = {
|
||||
'ic-uploads': data.criteria.uploads,
|
||||
'ic-age': data.criteria.age_days,
|
||||
'ic-comments': data.criteria.comments,
|
||||
'ic-tags': data.criteria.tags,
|
||||
};
|
||||
Object.entries(criteriaMap).forEach(([id, c]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.classList.toggle('met', c.met);
|
||||
el.classList.toggle('unmet', !c.met);
|
||||
el.querySelector('.ic-icon').textContent = c.met ? '✓' : '✗';
|
||||
const unit = id === 'ic-age' ? ' ' + T('days') : '';
|
||||
el.querySelector('.ic-values').textContent = c.current + ' / ' + c.required + unit;
|
||||
});
|
||||
}
|
||||
|
||||
// Eligibility badge
|
||||
const badge = document.getElementById('invite-eligible-badge');
|
||||
@@ -650,7 +730,7 @@
|
||||
T('slotsUsed', { used: data.slots_consumed, total: data.slots_total });
|
||||
|
||||
// Generate button state
|
||||
genBtn.disabled = !data.eligible || data.slots_available <= 0;
|
||||
genBtn.disabled = !data.eligible || (!data.is_admin && data.slots_available <= 0);
|
||||
|
||||
// Token list
|
||||
if (!data.tokens || data.tokens.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user