fix inviting

This commit is contained in:
2026-05-23 19:32:50 +02:00
parent bf92d53620
commit c6ff4fa703
6 changed files with 186 additions and 87 deletions

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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."
}
}

View File

@@ -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

View File

@@ -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) {