implement user invites

This commit is contained in:
2026-05-23 19:14:13 +02:00
parent dd6cda2b44
commit bf92d53620
11 changed files with 569 additions and 7 deletions

View File

@@ -72,6 +72,14 @@
"enable_cleanup": false,
"enable_data_export": true,
"enable_user_api_keys": true,
"enable_user_invites": true,
"user_invite_slots": 2,
"invite_criteria": {
"uploads": 10,
"age_days": 10,
"comments": 10,
"tags": 10
},
"cleanup_timeframe_days": 30,
"web_url_upload": true,
"enable_youtube_upload": true,

View File

@@ -825,12 +825,25 @@ CREATE TABLE public.invite_tokens (
used_by integer,
is_used boolean DEFAULT false,
created_by_discord character varying(255) DEFAULT NULL::character varying,
created_by_matrix character varying(255) DEFAULT NULL::character varying
created_by_matrix character varying(255) DEFAULT NULL::character varying,
used_at timestamp with time zone DEFAULT NULL
);
ALTER TABLE public.invite_tokens OWNER TO f0ckm;
--
-- Name: idx_invite_tokens_created_by; Type: INDEX; Schema: public; Owner: f0ckm
--
CREATE INDEX IF NOT EXISTS idx_invite_tokens_created_by ON public.invite_tokens (created_by);
--
-- Name: idx_invite_tokens_used_at; Type: INDEX; Schema: public; Owner: f0ckm
--
CREATE INDEX IF NOT EXISTS idx_invite_tokens_used_at ON public.invite_tokens (used_at) WHERE is_used = true;
--
-- Name: invite_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
--

View File

@@ -724,5 +724,29 @@
"left_hand_desc": "Du weißt bescheid.",
"replying_to": "Antwort an {user}",
"reply": "Antworten"
},
"invites": {
"section_title": "Einladungen",
"section_desc": "Lade neue Nutzer ein. Du musst alle unten stehenden Kriterien erfüllen, um Einladungstokens zu generieren.",
"eligible": "✓ Du bist berechtigt, Einladungen zu generieren.",
"not_eligible": "✗ Du erfüllst noch nicht alle Kriterien.",
"slots_used": "{used} / {total} Einladungsslots genutzt",
"criteria_uploads": "Uploads",
"criteria_age": "Kontoalter",
"criteria_comments": "Kommentare",
"criteria_tags": "Vergebene Tags",
"criteria_days": " Tage",
"generate_btn": "Einladung generieren",
"generating": "Wird generiert…",
"loading": "Wird geladen…",
"no_invites": "Noch keine Einladungstokens generiert.",
"status_unused": "Ungenutzt",
"status_used_by": "Genutzt von {user}",
"copy_btn": "Kopieren",
"copied": "Kopiert!",
"delete_btn": "Löschen",
"delete_confirm": "Diesen Einladungstoken löschen?",
"slot_refreshes_on": "Slot erneuert sich am {date}",
"slot_refreshed": "Slot erneuert"
}
}

View File

@@ -726,5 +726,29 @@
"left_hand_desc": "You know why.",
"replying_to": "Replying to {user}",
"reply": "Reply"
},
"invites": {
"section_title": "Invites",
"section_desc": "Invite new users to join. You must meet all criteria below to generate invite tokens.",
"eligible": "✓ You are eligible to generate invites.",
"not_eligible": "✗ You do not yet meet all criteria.",
"slots_used": "{used} / {total} invite slots used",
"criteria_uploads": "Uploads",
"criteria_age": "Account age",
"criteria_comments": "Comments",
"criteria_tags": "Tags assigned",
"criteria_days": " days",
"generate_btn": "Generate invite",
"generating": "Generating…",
"loading": "Loading…",
"no_invites": "No invite tokens generated yet.",
"status_unused": "Unused",
"status_used_by": "Used by {user}",
"copy_btn": "Copy",
"copied": "Copied!",
"delete_btn": "Delete",
"delete_confirm": "Delete this invite token?",
"slot_refreshes_on": "slot refreshes on {date}",
"slot_refreshed": "slot refreshed"
}
}

View File

@@ -722,5 +722,29 @@
"left_hand_desc": "Je weet wel waarom.",
"replying_to": "Antwoord aan {user}",
"reply": "Antwoorden"
},
"invites": {
"section_title": "Uitnodigingen",
"section_desc": "Nodig nieuwe gebruikers uit. Je moet aan alle onderstaande criteria voldoen om uitnodigingstokens te genereren.",
"eligible": "✓ Je bent bevoegd om uitnodigingen te genereren.",
"not_eligible": "✗ Je voldoet nog niet aan alle criteria.",
"slots_used": "{used} / {total} uitnodigingsslots gebruikt",
"criteria_uploads": "Uploads",
"criteria_age": "Accountleeftijd",
"criteria_comments": "Opmerkingen",
"criteria_tags": "Toegewezen tags",
"criteria_days": " dagen",
"generate_btn": "Uitnodiging genereren",
"generating": "Genereren…",
"loading": "Laden…",
"no_invites": "Nog geen uitnodigingstokens gegenereerd.",
"status_unused": "Ongebruikt",
"status_used_by": "Gebruikt door {user}",
"copy_btn": "Kopiëren",
"copied": "Gekopieërd!",
"delete_btn": "Verwijderen",
"delete_confirm": "Dit uitnodigingstoken verwijderen?",
"slot_refreshes_on": "slot vernieuwd op {date}",
"slot_refreshed": "slot vernieuwd"
}
}

View File

@@ -89,7 +89,7 @@
"password_min_hint": "Muss mindestens 20 Zeichen lang sein.",
"confirm_password": "Kennwort bestätigen",
"email_placeholder": "E-Post",
"invite_token": "Einladungskennzeichen",
"invite_token": "Einladungskots",
"tos_private": "Ich bin mindestens 18 Jahre alt und stimme der Befolgung des Regelwerks zu",
"tos_public": "Ich habe die Nutzungsbedingungen gelesen und akzeptiere diese",
"tos_terms": "Nutzungsbedingungen",
@@ -727,5 +727,29 @@
"left_hand_desc": "Sie wissen schon wieso.",
"replying_to": "Antwort an {user}",
"reply": "Antworten"
},
"invites": {
"section_title": "Einladungswesen",
"section_desc": "Laden Sie neue Nutzer ein, beizutreten. Sie müssen alle nachstehenden Kriterien erfüllen, um Einladungskots zu erzeugen.",
"eligible": "✓ Sie sind berechtigt, Einladungen zu erzeugen.",
"not_eligible": "✗ Sie erfüllen noch nicht alle Kriterien.",
"slots_used": "{used} / {total} Einladungsplätze in Benutzung",
"criteria_uploads": "Aufladierungen",
"criteria_age": "Kontoalter",
"criteria_comments": "Kommentare",
"criteria_tags": "Vergebene Etiketten",
"criteria_days": " Tage",
"generate_btn": "Einladung erzeugen",
"generating": "Erzeugung wird durchgeführt…",
"loading": "Ladung wird aufbereitet…",
"no_invites": "Noch keine Einladungskots erzeugt.",
"status_unused": "Ungebraucht",
"status_used_by": "Gebraucht von {user}",
"copy_btn": "Kopieren",
"copied": "Kopiert!",
"delete_btn": "Löschen",
"delete_confirm": "Diesen Einladungskot löschen?",
"slot_refreshes_on": "Platz erneuert sich am {date}",
"slot_refreshed": "Platz erneuert"
}
}

View File

@@ -811,6 +811,186 @@ export default router => {
}
});
// --- User Invite System ---
// Eligibility: ≥150 uploads, ≥30 days old, ≥66 comments, ≥200 tags
// Slots: configurable (default 2), refresh 30 days after a token is used.
const getInviteCriteria = () => {
const ic = cfg.websrv.invite_criteria || {};
return {
uploads: Number.isFinite(+ic.uploads) ? +ic.uploads : 150,
age_days: Number.isFinite(+ic.age_days) ? +ic.age_days : 30,
comments: Number.isFinite(+ic.comments) ? +ic.comments : 66,
tags: Number.isFinite(+ic.tags) ? +ic.tags : 200,
};
};
const getInviteSlots = () => {
const n = parseInt(cfg.websrv.user_invite_slots);
return Number.isFinite(n) && n > 0 ? n : 2;
};
// GET /api/v2/settings/invites
// Returns eligibility, criteria breakdown, tokens created by this user, and slot usage.
group.get(/\/invites$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
try {
const userId = +req.session.id;
const username = req.session.user;
const totalSlots = getInviteSlots();
const refreshDays = 30;
// 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
const tokens = await db`
SELECT
it.id,
it.token,
it.is_used,
it.used_at,
it.created_at,
u.user AS used_by_name
FROM invite_tokens it
LEFT JOIN "user" u ON u.id = it.used_by
WHERE it.created_by = ${userId}
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);
return res.json({
success: true,
eligible,
criteria,
tokens,
slots_total: totalSlots,
slots_consumed: slotsConsumed,
slots_available: slotsAvailable,
refresh_days: refreshDays,
}, 200);
} catch (e) {
console.error('[INVITES] GET error:', e);
return res.json({ success: false, msg: 'Error fetching invite data' }, 500);
}
});
// POST /api/v2/settings/invites/create
// Generates a new invite token if eligible and slots remain.
group.post(/\/invites\/create$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
try {
const userId = +req.session.id;
const username = req.session.user;
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 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 (!eligible) {
return res.json({ success: false, msg: 'You do not meet the eligibility criteria' }, 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
const token = crypto.randomBytes(16).toString('hex').toUpperCase();
await db`
INSERT INTO invite_tokens (token, created_at, created_by)
VALUES (${token}, ${~~(Date.now() / 1e3)}, ${userId})
`;
console.log(`[INVITES] User ${username} (${userId}) generated invite token ${token}`);
return res.json({ success: true, token }, 200);
} catch (e) {
console.error('[INVITES] Create error:', e);
return res.json({ success: false, msg: 'Error creating invite token' }, 500);
}
});
// POST /api/v2/settings/invites/delete
// Deletes an unused invite token owned by the calling user.
group.post(/\/invites\/delete$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
try {
const { id } = req.post;
if (!id) return res.json({ success: false, msg: 'Missing token ID' }, 400);
const result = await db`
DELETE FROM invite_tokens
WHERE id = ${+id}
AND created_by = ${+req.session.id}
AND is_used = false
RETURNING id
`;
if (result.length === 0) {
return res.json({ success: false, msg: 'Token not found or already used' }, 404);
}
return res.json({ success: true }, 200);
} catch (e) {
console.error('[INVITES] Delete error:', e);
return res.json({ success: false, msg: 'Error deleting invite token' }, 500);
}
});
return group;
});

View File

@@ -186,7 +186,7 @@ export default (router, tpl) => {
if (tokenRow.length > 0) {
await db`
update invite_tokens
set is_used = true, used_by = ${userId}
set is_used = true, used_by = ${userId}, used_at = now()
where id = ${tokenRow[0].id}
`;
}

View File

@@ -51,6 +51,7 @@ export default (router, tpl) => {
enable_swf: cfg.enable_swf,
enable_data_export: cfg.websrv.enable_data_export,
enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false,
enable_user_invites: cfg.websrv.enable_user_invites !== false,
site_domain: cfg.main.url.domain,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {

View File

@@ -15,6 +15,7 @@
<th>Source</th>
<th>Used By</th>
<th>Created</th>
<th>Used At</th>
<th>Actions</th>
</tr>
</thead>
@@ -43,11 +44,12 @@
'</td>' +
'<td data-label="Source">' +
(t.created_by_matrix ? '<span style="color: #0DBD8B">[Matrix] ' + t.created_by_matrix + '</span>' :
(t.created_by_discord ? '<span style="color: #5865F2"><i class="fab fa-discord"></i> ' + t.created_by_discord + '</span>' :
(t.created_by_name ? 'Web/Admin (' + t.created_by_name + ')' : 'Web/Admin'))) +
(t.created_by_discord ? '<span style="color: #5865F2"><i class="fab fa-discord"></i> ' + t.created_by_discord + '</span>' :
(t.created_by_name ? '<span style="color: var(--accent)">👤 User: ' + t.created_by_name + '</span>' : '<span style="color: var(--text-muted)">Admin</span>'))) +
'</td>' +
'<td data-label="Used By">' + (t.used_by_name || '-') + '</td>' +
'<td data-label="Created">' + (t.created_at ? new Date(parseInt(t.created_at) * 1000).toLocaleString() : '-') + '</td>' +
'<td data-label="Used By">' + (t.used_by_name || '') + '</td>' +
'<td data-label="Created">' + (t.created_at ? new Date(parseInt(t.created_at) * 1000).toLocaleString() : '') + '</td>' +
'<td data-label="Used At">' + (t.used_at ? new Date(t.used_at).toLocaleString() : '—') + '</td>' +
'<td data-label="Actions">' +
(!t.is_used ? '<button onclick="deleteToken(' + t.id + ')" class="btn-remove" style="padding: 5px 10px; font-size: 0.8em;">Delete</button>' : '') +
'</td>' +

View File

@@ -474,6 +474,268 @@
</div>
@endif
@if(enable_user_invites)
<h2>{{ 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>
<!-- 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 class="invite-criterion" id="ic-uploads">
<span class="ic-icon"></span>
<span class="ic-label">{{ t('invites.criteria_uploads') }}</span>
<span class="ic-values"></span>
</div>
<div class="invite-criterion" id="ic-age">
<span class="ic-icon"></span>
<span class="ic-label">{{ t('invites.criteria_age') }}</span>
<span class="ic-values"></span>
</div>
<div class="invite-criterion" id="ic-comments">
<span class="ic-icon"></span>
<span class="ic-label">{{ t('invites.criteria_comments') }}</span>
<span class="ic-values"></span>
</div>
<div class="invite-criterion" id="ic-tags">
<span class="ic-icon"></span>
<span class="ic-label">{{ t('invites.criteria_tags') }}</span>
<span class="ic-values"></span>
</div>
</div>
<!-- Eligibility badge + slot indicator -->
<div id="invite-status-line" style="margin-bottom: 16px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap;">
<span id="invite-eligible-badge" style="font-weight: bold; font-size: 0.95em;"></span>
<span id="invite-slots-info" style="color: var(--text-muted); font-size: 0.9em;"></span>
</div>
<!-- Token list -->
<div id="invite-token-list" style="margin-bottom: 16px; display: flex; flex-direction: column; gap: 8px;">
<span class="text-muted" style="font-size: 0.9em;">{{ t('invites.loading') }}</span>
</div>
<!-- Generate button + status -->
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<button type="button" id="btn-gen-invite" class="button button-primary" disabled>{{ t('invites.generate_btn') }}</button>
</div>
<div id="invite-action-status" class="avatar-status" style="margin-top: 10px;"></div>
</div>
<style>
.invite-criterion {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--nav-border-color);
background: rgba(255,255,255,0.03);
font-size: 0.88em;
}
.invite-criterion.met {
border-color: rgba(80, 200, 120, 0.5);
background: rgba(80, 200, 120, 0.07);
}
.invite-criterion.unmet {
border-color: rgba(255, 90, 90, 0.35);
background: rgba(255, 90, 90, 0.05);
}
.ic-icon { font-size: 1.2em; }
.ic-label { font-weight: bold; color: var(--text-muted); }
.ic-values { font-family: monospace; font-size: 1.05em; }
.invite-token-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--nav-border-color);
background: rgba(255,255,255,0.02);
font-size: 0.88em;
}
.invite-token-code {
font-family: monospace;
font-size: 1.05em;
letter-spacing: 0.04em;
color: var(--accent);
user-select: all;
flex: 1;
word-break: break-all;
}
.invite-token-status-used { color: #ff6b6b; }
.invite-token-status-unused { color: #51cf66; }
.invite-refresh-note { font-size: 0.82em; color: var(--text-muted); }
</style>
<!-- i18n strings for invite JS (rendered server-side) -->
<div id="invite-i18n" style="display:none"
data-eligible="{{ t('invites.eligible') }}"
data-not-eligible="{{ t('invites.not_eligible') }}"
data-slots-used="{{ t('invites.slots_used') }}"
data-days="{{ t('invites.criteria_days') }}"
data-no-invites="{{ t('invites.no_invites') }}"
data-status-unused="{{ t('invites.status_unused') }}"
data-status-used-by="{{ t('invites.status_used_by') }}"
data-delete-btn="{{ t('invites.delete_btn') }}"
data-delete-confirm="{{ t('invites.delete_confirm') }}"
data-slot-refreshes-on="{{ t('invites.slot_refreshes_on') }}"
data-slot-refreshed="{{ t('invites.slot_refreshed') }}"
data-generating="{{ t('invites.generating') }}"
></div>
<script>
(function() {
// Read all translated strings from server-rendered data attributes.
// Attributes must be lowercase kebab-case: data-foo-bar → dataset.fooBar
const ds = document.getElementById('invite-i18n')?.dataset || {};
const T = (key, vars) => {
let str = ds[key] || key;
if (vars) Object.entries(vars).forEach(([k, v]) => { str = str.replaceAll('{' + k + '}', String(v)); });
return str;
};
const statusEl = document.getElementById('invite-action-status');
const genBtn = document.getElementById('btn-gen-invite');
const tokenList = document.getElementById('invite-token-list');
const setStatus = (msg, ok) => {
statusEl.textContent = msg;
statusEl.style.color = ok ? '#51cf66' : '#ff6b6b';
};
const formatDate = ts => {
if (!ts) return '—';
const d = (typeof ts === 'number' || /^\d+$/.test(String(ts)))
? new Date(parseInt(ts) * 1000)
: new Date(ts);
return d.toLocaleDateString();
};
const loadInvites = async () => {
try {
const res = await fetch('/api/v2/settings/invites');
const data = await res.json();
if (!data.success) {
tokenList.innerHTML = '<span style="color:#ff6b6b">' + (data.msg || 'Error') + '</span>';
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;
});
// Eligibility badge
const badge = document.getElementById('invite-eligible-badge');
badge.textContent = data.eligible ? T('eligible') : T('notEligible');
badge.style.color = data.eligible ? '#51cf66' : '#ff6b6b';
// Slots info
document.getElementById('invite-slots-info').textContent =
T('slotsUsed', { used: data.slots_consumed, total: data.slots_total });
// Generate button state
genBtn.disabled = !data.eligible || data.slots_available <= 0;
// Token list
if (!data.tokens || data.tokens.length === 0) {
tokenList.innerHTML = '<span class="text-muted" style="font-size:0.9em;">' + T('noInvites') + '</span>';
} else {
tokenList.innerHTML = data.tokens.map(tok => {
const isUsed = tok.is_used;
const usedAt = tok.used_at ? formatDate(tok.used_at) : null;
let refreshNote = '';
if (isUsed && tok.used_at) {
const usedAtMs = (typeof tok.used_at === 'number' || /^\d+$/.test(String(tok.used_at)))
? parseInt(tok.used_at) * 1000
: new Date(tok.used_at).getTime();
const refreshDate = new Date(usedAtMs + 30 * 24 * 60 * 60 * 1000);
refreshNote = '<span class="invite-refresh-note"> — ' +
(refreshDate > new Date()
? T('slotRefreshesOn', { date: refreshDate.toLocaleDateString() })
: T('slotRefreshed')) +
'</span>';
}
const userLink = tok.used_by_name
? '<a href="/user/' + encodeURIComponent(tok.used_by_name.toLowerCase()) + '" style="color:inherit;text-decoration:underline;">' + tok.used_by_name + '</a>'
: '?';
const statusHtml = isUsed
? '<span class="invite-token-status-used">' + T('statusUsedBy', { user: userLink }) + ' (' + (usedAt || '?') + ')</span>' + refreshNote
: '<span class="invite-token-status-unused">' + T('statusUnused') + '</span>';
const deleteBtn = !isUsed
? '<button class="button button-danger" style="padding:3px 10px;font-size:0.8em;" data-invite-delete="' + tok.id + '">' + T('deleteBtn') + '</button>'
: '';
return '<div class="invite-token-row">' +
'<span class="invite-token-code">' + tok.token + '</span>' +
statusHtml + deleteBtn +
'</div>';
}).join('');
tokenList.querySelectorAll('[data-invite-delete]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(T('deleteConfirm'))) return;
const res = await fetch('/api/v2/settings/invites/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify({ id: +btn.dataset.inviteDelete })
});
const d = await res.json();
if (d.success) { loadInvites(); } else { setStatus(d.msg || 'Error', false); }
});
});
}
} catch (e) {
console.error('[Invites] load error:', e);
tokenList.innerHTML = '<span style="color:#ff6b6b">Failed to load</span>';
}
};
genBtn.addEventListener('click', async () => {
const origLabel = genBtn.textContent;
genBtn.disabled = true;
genBtn.textContent = T('generating');
statusEl.textContent = '';
try {
const res = await fetch('/api/v2/settings/invites/create', {
method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
});
const data = await res.json();
if (data.success) {
setStatus('✓', true);
loadInvites();
} else {
setStatus(data.msg || 'Error', false);
genBtn.disabled = false;
}
} catch (e) {
setStatus('Network error', false);
genBtn.disabled = false;
} finally {
genBtn.textContent = origLabel;
}
});
loadInvites();
})();
</script>
@endif
<style>
@keyframes exportDotBounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.3; }