implement user invites
This commit is contained in:
@@ -72,6 +72,14 @@
|
|||||||
"enable_cleanup": false,
|
"enable_cleanup": false,
|
||||||
"enable_data_export": true,
|
"enable_data_export": true,
|
||||||
"enable_user_api_keys": 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,
|
"cleanup_timeframe_days": 30,
|
||||||
"web_url_upload": true,
|
"web_url_upload": true,
|
||||||
"enable_youtube_upload": true,
|
"enable_youtube_upload": true,
|
||||||
|
|||||||
@@ -825,12 +825,25 @@ CREATE TABLE public.invite_tokens (
|
|||||||
used_by integer,
|
used_by integer,
|
||||||
is_used boolean DEFAULT false,
|
is_used boolean DEFAULT false,
|
||||||
created_by_discord character varying(255) DEFAULT NULL::character varying,
|
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;
|
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
|
-- Name: invite_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -724,5 +724,29 @@
|
|||||||
"left_hand_desc": "Du weißt bescheid.",
|
"left_hand_desc": "Du weißt bescheid.",
|
||||||
"replying_to": "Antwort an {user}",
|
"replying_to": "Antwort an {user}",
|
||||||
"reply": "Antworten"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -726,5 +726,29 @@
|
|||||||
"left_hand_desc": "You know why.",
|
"left_hand_desc": "You know why.",
|
||||||
"replying_to": "Replying to {user}",
|
"replying_to": "Replying to {user}",
|
||||||
"reply": "Reply"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -722,5 +722,29 @@
|
|||||||
"left_hand_desc": "Je weet wel waarom.",
|
"left_hand_desc": "Je weet wel waarom.",
|
||||||
"replying_to": "Antwoord aan {user}",
|
"replying_to": "Antwoord aan {user}",
|
||||||
"reply": "Antwoorden"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"password_min_hint": "Muss mindestens 20 Zeichen lang sein.",
|
"password_min_hint": "Muss mindestens 20 Zeichen lang sein.",
|
||||||
"confirm_password": "Kennwort bestätigen",
|
"confirm_password": "Kennwort bestätigen",
|
||||||
"email_placeholder": "E-Post",
|
"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_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_public": "Ich habe die Nutzungsbedingungen gelesen und akzeptiere diese",
|
||||||
"tos_terms": "Nutzungsbedingungen",
|
"tos_terms": "Nutzungsbedingungen",
|
||||||
@@ -727,5 +727,29 @@
|
|||||||
"left_hand_desc": "Sie wissen schon wieso.",
|
"left_hand_desc": "Sie wissen schon wieso.",
|
||||||
"replying_to": "Antwort an {user}",
|
"replying_to": "Antwort an {user}",
|
||||||
"reply": "Antworten"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
return group;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default (router, tpl) => {
|
|||||||
if (tokenRow.length > 0) {
|
if (tokenRow.length > 0) {
|
||||||
await db`
|
await db`
|
||||||
update invite_tokens
|
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}
|
where id = ${tokenRow[0].id}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default (router, tpl) => {
|
|||||||
enable_swf: cfg.enable_swf,
|
enable_swf: cfg.enable_swf,
|
||||||
enable_data_export: cfg.websrv.enable_data_export,
|
enable_data_export: cfg.websrv.enable_data_export,
|
||||||
enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false,
|
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,
|
site_domain: cfg.main.url.domain,
|
||||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||||
page_meta: {
|
page_meta: {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<th>Source</th>
|
<th>Source</th>
|
||||||
<th>Used By</th>
|
<th>Used By</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
|
<th>Used At</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -43,11 +44,12 @@
|
|||||||
'</td>' +
|
'</td>' +
|
||||||
'<td data-label="Source">' +
|
'<td data-label="Source">' +
|
||||||
(t.created_by_matrix ? '<span style="color: #0DBD8B">[Matrix] ' + t.created_by_matrix + '</span>' :
|
(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_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_name ? '<span style="color: var(--accent)">👤 User: ' + t.created_by_name + '</span>' : '<span style="color: var(--text-muted)">Admin</span>'))) +
|
||||||
'</td>' +
|
'</td>' +
|
||||||
'<td data-label="Used By">' + (t.used_by_name || '-') + '</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="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">' +
|
'<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>' : '') +
|
(!t.is_used ? '<button onclick="deleteToken(' + t.id + ')" class="btn-remove" style="padding: 5px 10px; font-size: 0.8em;">Delete</button>' : '') +
|
||||||
'</td>' +
|
'</td>' +
|
||||||
|
|||||||
@@ -474,6 +474,268 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@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>
|
<style>
|
||||||
@keyframes exportDotBounce {
|
@keyframes exportDotBounce {
|
||||||
0%, 80%, 100% { transform: translateY(0); opacity: 0.3; }
|
0%, 80%, 100% { transform: translateY(0); opacity: 0.3; }
|
||||||
|
|||||||
Reference in New Issue
Block a user