fix inviting
This commit is contained in:
@@ -747,6 +747,7 @@
|
|||||||
"delete_btn": "Löschen",
|
"delete_btn": "Löschen",
|
||||||
"delete_confirm": "Diesen Einladungstoken löschen?",
|
"delete_confirm": "Diesen Einladungstoken löschen?",
|
||||||
"slot_refreshes_on": "Slot erneuert sich am {date}",
|
"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_btn": "Delete",
|
||||||
"delete_confirm": "Delete this invite token?",
|
"delete_confirm": "Delete this invite token?",
|
||||||
"slot_refreshes_on": "slot refreshes on {date}",
|
"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_btn": "Verwijderen",
|
||||||
"delete_confirm": "Dit uitnodigingstoken verwijderen?",
|
"delete_confirm": "Dit uitnodigingstoken verwijderen?",
|
||||||
"slot_refreshes_on": "slot vernieuwd op {date}",
|
"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_btn": "Löschen",
|
||||||
"delete_confirm": "Diesen Einladungskot löschen?",
|
"delete_confirm": "Diesen Einladungskot löschen?",
|
||||||
"slot_refreshes_on": "Platz erneuert sich am {date}",
|
"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 username = req.session.user;
|
||||||
const totalSlots = getInviteSlots();
|
const totalSlots = getInviteSlots();
|
||||||
const refreshDays = 30;
|
const refreshDays = 30;
|
||||||
|
const isAdmin = !!req.session.admin;
|
||||||
|
|
||||||
// Gather eligibility stats in one query
|
// Always fetch this user's token history
|
||||||
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
|
|
||||||
const tokens = await db`
|
const tokens = await db`
|
||||||
SELECT
|
SELECT
|
||||||
it.id,
|
it.id,
|
||||||
@@ -879,19 +859,50 @@ export default router => {
|
|||||||
ORDER BY it.created_at DESC
|
ORDER BY it.created_at DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
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
|
// Slots consumed = tokens used within the last 30 days
|
||||||
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
|
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;
|
slotsConsumed = tokens.filter(t => t.is_used && t.used_at && new Date(t.used_at) > cutoff).length;
|
||||||
const slotsAvailable = Math.max(0, totalSlots - slotsConsumed);
|
slotsAvailable = Math.max(0, totalSlots - slotsConsumed);
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
is_admin: isAdmin,
|
||||||
eligible,
|
eligible,
|
||||||
criteria,
|
criteria,
|
||||||
tokens,
|
tokens,
|
||||||
slots_total: totalSlots,
|
slots_total: isAdmin ? null : totalSlots,
|
||||||
slots_consumed: slotsConsumed,
|
slots_consumed: isAdmin ? null : slotsConsumed,
|
||||||
slots_available: slotsAvailable,
|
slots_available: isAdmin ? null : slotsAvailable,
|
||||||
refresh_days: refreshDays,
|
refresh_days: refreshDays,
|
||||||
}, 200);
|
}, 200);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -912,6 +923,9 @@ export default router => {
|
|||||||
const totalSlots = getInviteSlots();
|
const totalSlots = getInviteSlots();
|
||||||
const refreshDays = 30;
|
const refreshDays = 30;
|
||||||
|
|
||||||
|
const isAdmin = !!req.session.admin;
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
// Eligibility check
|
// Eligibility check
|
||||||
const [stats] = await db`
|
const [stats] = await db`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -946,6 +960,7 @@ export default router => {
|
|||||||
if (slots_consumed >= totalSlots) {
|
if (slots_consumed >= totalSlots) {
|
||||||
return res.json({ success: false, msg: 'No invite slots available. Slots refresh 30 days after use.' }, 403);
|
return res.json({ success: false, msg: 'No invite slots available. Slots refresh 30 days after use.' }, 403);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate token
|
// Generate token
|
||||||
const token = crypto.randomBytes(16).toString('hex').toUpperCase();
|
const token = crypto.randomBytes(16).toString('hex').toUpperCase();
|
||||||
|
|||||||
@@ -3,7 +3,76 @@
|
|||||||
<div id="main">
|
<div id="main">
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
<h1>{{ t('settings.title') }}</h1>
|
<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-settings-wrapper">
|
||||||
<div class="avatar-preview-container">
|
<div class="avatar-preview-container">
|
||||||
<div class="avatar-preview-label">{{ t('settings.current_avatar') }}</div>
|
<div class="avatar-preview-label">{{ t('settings.current_avatar') }}</div>
|
||||||
@@ -75,7 +144,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<small class="text-muted">{{ t('settings.username_color_hint') }}</small>
|
<small class="text-muted">{{ t('settings.username_color_hint') }}</small>
|
||||||
</div>
|
</div>
|
||||||
<h2>{{ t('settings.preferences') }}</h2>
|
<h2 id="sec-preferences">{{ t('settings.preferences') }}</h2>
|
||||||
<div class="preferences-settings-wrapper">
|
<div class="preferences-settings-wrapper">
|
||||||
<fieldset
|
<fieldset
|
||||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||||
@@ -298,7 +367,7 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
@if(enable_data_export)
|
@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;">
|
<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>
|
<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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<h2>{{ t('settings.account') }}</h2>
|
<h2 id="sec-account">{{ t('settings.account') }}</h2>
|
||||||
<div class="account-settings-wrapper"
|
<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;">
|
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"
|
<table class="table account-info-table"
|
||||||
@@ -410,7 +479,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if(matrix_enabled)
|
@if(matrix_enabled)
|
||||||
<h2>{{ t('settings.linked_accounts') }}</h2>
|
<h2 id="sec-linked">{{ t('settings.linked_accounts') }}</h2>
|
||||||
<div class="linked-accounts-wrapper">
|
<div class="linked-accounts-wrapper">
|
||||||
<p>{{ t('settings.matrix_link_desc') }}</p>
|
<p>{{ t('settings.matrix_link_desc') }}</p>
|
||||||
|
|
||||||
@@ -441,7 +510,7 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(enable_user_api_keys)
|
@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"
|
<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;">
|
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;">
|
<p style="color: var(--text-muted); margin-bottom: 15px;">
|
||||||
@@ -475,11 +544,11 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(enable_user_invites)
|
@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"
|
<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;">
|
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 -->
|
<!-- Criteria grid -->
|
||||||
<div id="invite-criteria-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 18px;">
|
<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-refreshes-on="{{ t('invites.slot_refreshes_on') }}"
|
||||||
data-slot-refreshed="{{ t('invites.slot_refreshed') }}"
|
data-slot-refreshed="{{ t('invites.slot_refreshed') }}"
|
||||||
data-generating="{{ t('invites.generating') }}"
|
data-generating="{{ t('invites.generating') }}"
|
||||||
|
data-admin-desc="{{ t('invites.admin_desc') }}"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -623,6 +693,15 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Update criteria grid
|
||||||
const criteriaMap = {
|
const criteriaMap = {
|
||||||
'ic-uploads': data.criteria.uploads,
|
'ic-uploads': data.criteria.uploads,
|
||||||
@@ -639,6 +718,7 @@
|
|||||||
const unit = id === 'ic-age' ? ' ' + T('days') : '';
|
const unit = id === 'ic-age' ? ' ' + T('days') : '';
|
||||||
el.querySelector('.ic-values').textContent = c.current + ' / ' + c.required + unit;
|
el.querySelector('.ic-values').textContent = c.current + ' / ' + c.required + unit;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Eligibility badge
|
// Eligibility badge
|
||||||
const badge = document.getElementById('invite-eligible-badge');
|
const badge = document.getElementById('invite-eligible-badge');
|
||||||
@@ -650,7 +730,7 @@
|
|||||||
T('slotsUsed', { used: data.slots_consumed, total: data.slots_total });
|
T('slotsUsed', { used: data.slots_consumed, total: data.slots_total });
|
||||||
|
|
||||||
// Generate button state
|
// Generate button state
|
||||||
genBtn.disabled = !data.eligible || data.slots_available <= 0;
|
genBtn.disabled = !data.eligible || (!data.is_admin && data.slots_available <= 0);
|
||||||
|
|
||||||
// Token list
|
// Token list
|
||||||
if (!data.tokens || data.tokens.length === 0) {
|
if (!data.tokens || data.tokens.length === 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user