diff --git a/migrations/004_temp_roles.cjs b/migrations/004_temp_roles.cjs new file mode 100644 index 000000000..3f752db52 --- /dev/null +++ b/migrations/004_temp_roles.cjs @@ -0,0 +1,43 @@ +/** + * Migration: Temporary Role Assignments + * + * Creates the temp_roles table to track roles assigned with an expiry. + * The scheduler polls this table and removes roles when they expire. + */ + +'use strict'; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS temp_roles ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + user_tag TEXT NOT NULL, + role_id TEXT NOT NULL, + role_name TEXT NOT NULL, + moderator_id TEXT NOT NULL, + moderator_tag TEXT NOT NULL, + reason TEXT, + duration TEXT NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + removed BOOLEAN NOT NULL DEFAULT FALSE, + removed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + + pgm.sql('CREATE INDEX IF NOT EXISTS idx_temp_roles_guild ON temp_roles(guild_id)'); + pgm.sql( + "CREATE INDEX IF NOT EXISTS idx_temp_roles_pending ON temp_roles(removed, expires_at) WHERE removed = FALSE", + ); + pgm.sql( + 'CREATE INDEX IF NOT EXISTS idx_temp_roles_guild_user ON temp_roles(guild_id, user_id, removed)', + ); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.sql('DROP TABLE IF EXISTS temp_roles CASCADE'); +}; diff --git a/src/api/index.js b/src/api/index.js index 99272cb44..ff03014a3 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -19,7 +19,7 @@ import membersRouter from './routes/members.js'; import moderationRouter from './routes/moderation.js'; import notificationsRouter from './routes/notifications.js'; import performanceRouter from './routes/performance.js'; -import ticketsRouter from './routes/tickets.js'; +import tempRolesRouter from './routes/tempRoles.js'; import webhooksRouter from './routes/webhooks.js'; const router = Router(); @@ -56,6 +56,8 @@ router.use('/guilds', requireAuth(), auditLogMiddleware(), guildsRouter); // Moderation routes — require API secret or OAuth2 JWT router.use('/moderation', requireAuth(), auditLogMiddleware(), moderationRouter); +// Temp role routes — require API secret or OAuth2 JWT +router.use('/temp-roles', requireAuth(), auditLogMiddleware(), tempRolesRouter); // Audit log routes — require API secret or OAuth2 JWT // GET-only; no audit middleware needed (reads are not mutating actions) diff --git a/src/api/routes/tempRoles.js b/src/api/routes/tempRoles.js new file mode 100644 index 000000000..1696af096 --- /dev/null +++ b/src/api/routes/tempRoles.js @@ -0,0 +1,250 @@ +/** + * Temp Roles API Routes + * Exposes temporary role assignment data for the web dashboard. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/128 + */ + +import { Router } from 'express'; +import { info, error as logError } from '../../logger.js'; +import { + assignTempRole, + listTempRoles, + revokeTempRoleById, +} from '../../modules/tempRoleHandler.js'; +import { formatDuration, parseDuration } from '../../utils/duration.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { parsePagination, requireGuildModerator } from './guilds.js'; + +const router = Router(); + +/** Rate limiter — 120 req / 15 min per IP */ +const tempRoleRateLimit = rateLimit({ windowMs: 15 * 60 * 1000, max: 120 }); + +/** + * Adapt ?guildId= query param to :id path param for requireGuildModerator. + * Only use on routes that need guild id in params (GET list). + */ +function adaptGuildIdParam(req, _res, next) { + if (req.query.guildId) { + req.params.id = req.query.guildId; + } + next(); +} + +/** + * Adapt req.body.guildId to :id path param for requireGuildModerator. + * Used for POST route where guildId is in the body, not query string. + */ +function adaptBodyGuildId(req, _res, next) { + if (req.body?.guildId) { + req.params.id = req.body.guildId; + } + next(); +} + +router.use(tempRoleRateLimit); + +// ─── GET /temp-roles ────────────────────────────────────────────────────────── + +/** + * @openapi + * /temp-roles: + * get: + * tags: [TempRoles] + * summary: List active temp role assignments + * parameters: + * - in: query + * name: guildId + * required: true + * schema: { type: string } + * - in: query + * name: userId + * schema: { type: string } + * - in: query + * name: page + * schema: { type: integer, default: 1 } + * - in: query + * name: limit + * schema: { type: integer, default: 25, maximum: 100 } + * responses: + * "200": + * description: Paginated list of active temp roles + */ +router.get('/', adaptGuildIdParam, requireGuildModerator, async (req, res) => { + try { + const guildId = req.query.guildId; + + // Validate guildId is present and is a string + if (!guildId || typeof guildId !== 'string') { + return res.status(400).json({ error: 'guildId is required and must be a string' }); + } + + const userId = req.query.userId || undefined; + const { page, limit, offset } = parsePagination(req.query); + + const { rows, total } = await listTempRoles(guildId, { userId, limit, offset }); + + return res.json({ + data: rows, + pagination: { page, limit, total, pages: Math.ceil(total / limit) }, + }); + } catch (err) { + logError('GET /temp-roles failed', { error: err.message }); + return res.status(500).json({ error: 'Failed to fetch temp roles' }); + } +}); + +// ─── DELETE /temp-roles/:id ─────────────────────────────────────────────────── + +/** + * @openapi + * /temp-roles/{id}: + * delete: + * tags: [TempRoles] + * summary: Revoke a temp role by record ID + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: integer } + * - in: query + * name: guildId + * required: true + * schema: { type: string } + * responses: + * "200": { description: Revoked } + * "404": { description: Not found or already removed } + */ +router.delete('/:id', async (req, res) => { + try { + const guildId = req.query.guildId; + + // Validate guildId is present and is a string + if (!guildId || typeof guildId !== 'string') { + return res.status(400).json({ error: 'guildId is required and must be a string' }); + } + + const id = Number.parseInt(req.params.id, 10); + + if (!Number.isInteger(id) || id <= 0) { + return res.status(400).json({ error: 'Invalid id' }); + } + + // Revoke by specific record id (not by user/role which can affect multiple rows) + const updated = await revokeTempRoleById(id, guildId); + if (!updated) { + return res.status(404).json({ error: 'Temp role not found or already removed' }); + } + + // Best-effort Discord role removal + try { + const client = res.app.locals.client; + if (client) { + const guild = await client.guilds.fetch(guildId); + const member = await guild.members.fetch(updated.user_id).catch(() => null); + if (member) { + await member.roles.remove(updated.role_id, 'Temp role revoked via dashboard'); + } + } + } catch (discordErr) { + logError('Dashboard revoke: Discord role removal failed', { error: discordErr.message }); + } + + info('Temp role revoked via dashboard', { + guildId, + userId: updated.user_id, + roleId: updated.role_id, + moderatorId: req.user?.id, + }); + + return res.json({ success: true, data: updated }); + } catch (err) { + logError('DELETE /temp-roles/:id failed', { error: err.message }); + return res.status(500).json({ error: 'Failed to revoke temp role' }); + } +}); + +// ─── POST /temp-roles ───────────────────────────────────────────────────────── + +/** + * @openapi + * /temp-roles: + * post: + * tags: [TempRoles] + * summary: Assign a temp role via the dashboard + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [guildId, userId, roleId, duration] + * properties: + * guildId: { type: string } + * userId: { type: string } + * roleId: { type: string } + * duration: { type: string, example: "7d" } + * reason: { type: string } + * responses: + * "201": { description: Assigned } + * "400": { description: Invalid input } + */ +router.post('/', adaptBodyGuildId, requireGuildModerator, async (req, res) => { + try { + const { guildId, userId, roleId, duration: durationStr, reason } = req.body || {}; + + if (!guildId || !userId || !roleId || !durationStr) { + return res.status(400).json({ error: 'guildId, userId, roleId, and duration are required' }); + } + + const durationMs = parseDuration(durationStr); + if (!durationMs) { + return res.status(400).json({ error: 'Invalid duration. Use e.g. 1h, 7d, 2w.' }); + } + + const client = res.app.locals.client; + if (!client) { + return res.status(503).json({ error: 'Discord client not available' }); + } + + let guild, member, role; + try { + guild = await client.guilds.fetch(guildId); + member = await guild.members.fetch(userId); + role = await guild.roles.fetch(roleId); + } catch { + return res.status(400).json({ error: 'Invalid guild, user, or role' }); + } + + if (!role) { + return res.status(400).json({ error: 'Role not found' }); + } + + // Assign in Discord + await member.roles.add(roleId, reason || 'Temp role assigned via dashboard'); + + const expiresAt = new Date(Date.now() + durationMs); + const duration = formatDuration(durationMs); + + const record = await assignTempRole({ + guildId, + userId, + userTag: member.user.tag, + roleId, + roleName: role.name, + moderatorId: req.user?.id || 'dashboard', + moderatorTag: req.user?.tag || 'Dashboard', + duration, + expiresAt, + reason: reason || null, + }); + + return res.status(201).json({ success: true, data: record }); + } catch (err) { + logError('POST /temp-roles failed', { error: err.message }); + return res.status(500).json({ error: 'Failed to assign temp role' }); + } +}); + +export default router; diff --git a/src/commands/temprole.js b/src/commands/temprole.js new file mode 100644 index 000000000..08ae67c1e --- /dev/null +++ b/src/commands/temprole.js @@ -0,0 +1,247 @@ +/** + * Temprole Command + * Assign a role to a user that automatically expires after a set duration. + * + * Subcommands: + * assign — Assign a temporary role + * revoke — Remove a temporary role early + * list — List active temp roles in the server (or for a specific user) + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/128 + */ + +import { EmbedBuilder, SlashCommandBuilder, time } from 'discord.js'; +import { info, warn } from '../logger.js'; +import { assignTempRole, listTempRoles, revokeTempRole } from '../modules/tempRoleHandler.js'; +import { formatDuration, parseDuration } from '../utils/duration.js'; +import { safeEditReply } from '../utils/safeSend.js'; + +export const data = new SlashCommandBuilder() + .setName('temprole') + .setDescription('Manage temporary role assignments') + .addSubcommand((sub) => + sub + .setName('assign') + .setDescription('Assign a role that expires after a set duration') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addRoleOption((opt) => + opt.setName('role').setDescription('Role to assign').setRequired(true), + ) + .addStringOption((opt) => + opt.setName('duration').setDescription('Duration (e.g. 1h, 7d, 2w)').setRequired(true), + ) + .addStringOption((opt) => + opt.setName('reason').setDescription('Reason for assignment').setRequired(false), + ), + ) + .addSubcommand((sub) => + sub + .setName('revoke') + .setDescription('Remove a temporary role before it expires') + .addUserOption((opt) => opt.setName('user').setDescription('Target user').setRequired(true)) + .addRoleOption((opt) => + opt.setName('role').setDescription('Role to revoke').setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('list') + .setDescription('List active temporary role assignments') + .addUserOption((opt) => + opt.setName('user').setDescription('Filter by user (optional)').setRequired(false), + ), + ); + +export const adminOnly = true; + +/** + * Execute the temprole command. + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + await interaction.deferReply({ ephemeral: true }); + + const sub = interaction.options.getSubcommand(); + + if (sub === 'assign') { + await handleAssign(interaction); + } else if (sub === 'revoke') { + await handleRevoke(interaction); + } else if (sub === 'list') { + await handleList(interaction); + } +} + +/** + * Handle the assign subcommand. + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleAssign(interaction) { + try { + const user = interaction.options.getUser('user'); + const role = interaction.options.getRole('role'); + const durationStr = interaction.options.getString('duration'); + const reason = interaction.options.getString('reason'); + + const durationMs = parseDuration(durationStr); + if (!durationMs) { + return await safeEditReply( + interaction, + '❌ Invalid duration format. Use e.g. `1h`, `7d`, `2w`.', + ); + } + + // Fetch member + let member; + try { + member = await interaction.guild.members.fetch(user.id); + } catch { + return await safeEditReply(interaction, '❌ That user is not in this server.'); + } + + // Bot hierarchy check + const botMember = interaction.guild.members.me; + if (role.position >= botMember.roles.highest.position) { + return await safeEditReply( + interaction, + '❌ I cannot assign that role — it is higher than or equal to my highest role.', + ); + } + + // Moderator hierarchy check + if (role.position >= interaction.member.roles.highest.position) { + return await safeEditReply( + interaction, + '❌ You cannot assign a role equal to or higher than your own highest role.', + ); + } + + // Assign the role in Discord + await member.roles.add(role.id, reason || 'Temp role assigned via /temprole'); + + const expiresAt = new Date(Date.now() + durationMs); + const duration = formatDuration(durationMs); + + // Persist to DB + await assignTempRole({ + guildId: interaction.guildId, + userId: user.id, + userTag: user.tag, + roleId: role.id, + roleName: role.name, + moderatorId: interaction.user.id, + moderatorTag: interaction.user.tag, + duration, + expiresAt, + reason, + }); + + info('Temp role assigned via command', { + guildId: interaction.guildId, + userId: user.id, + roleId: role.id, + duration, + moderator: interaction.user.tag, + }); + + await safeEditReply( + interaction, + `✅ **${user.tag}** has been given the **${role.name}** role for **${duration}**. It will be removed ${time(expiresAt, 'R')}.`, + ); + } catch (err) { + warn('Temprole assign failed', { error: err.message, guildId: interaction.guildId }); + await safeEditReply( + interaction, + '❌ An error occurred while assigning the role. Please try again.', + ); + } +} + +/** + * Handle the revoke subcommand. + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleRevoke(interaction) { + try { + const user = interaction.options.getUser('user'); + const role = interaction.options.getRole('role'); + + // Find active temp role record + const record = await revokeTempRole(interaction.guildId, user.id, role.id); + if (!record) { + return await safeEditReply( + interaction, + `❌ No active temporary role assignment found for **${user.tag}** with role **${role.name}**.`, + ); + } + + // Remove from Discord (best-effort — member may have left) + try { + const member = await interaction.guild.members.fetch(user.id); + await member.roles.remove(role.id, 'Temp role manually revoked via /temprole revoke'); + } catch { + // Member left or role already removed — DB record is already marked removed + } + + info('Temp role manually revoked', { + guildId: interaction.guildId, + userId: user.id, + roleId: role.id, + moderator: interaction.user.tag, + }); + + await safeEditReply( + interaction, + `✅ Temporary role **${role.name}** has been revoked from **${user.tag}**.`, + ); + } catch (err) { + warn('Temprole revoke failed', { error: err.message, guildId: interaction.guildId }); + await safeEditReply( + interaction, + '❌ An error occurred while revoking the role. Please try again.', + ); + } +} + +/** + * Handle the list subcommand. + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleList(interaction) { + try { + const user = interaction.options.getUser('user'); + + const { rows, total } = await listTempRoles(interaction.guildId, { + userId: user?.id, + limit: 10, + }); + + if (rows.length === 0) { + const suffix = user ? ` for **${user.tag}**` : ''; + return await safeEditReply(interaction, `📋 No active temporary role assignments${suffix}.`); + } + + const embed = new EmbedBuilder() + .setTitle('Active Temporary Roles') + .setColor(0x5865f2) + .setDescription( + rows + .map( + (r) => + `<@${r.user_id}> → <@&${r.role_id}> — expires ${time(new Date(r.expires_at), 'R')}` + + (r.reason ? `\n *Reason: ${r.reason}*` : ''), + ) + .join('\n'), + ) + .setFooter({ text: `Showing ${rows.length} of ${total} active assignments` }) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } catch (err) { + warn('Temprole list failed', { error: err.message, guildId: interaction.guildId }); + await safeEditReply( + interaction, + '❌ An error occurred while fetching the list. Please try again.', + ); + } +} diff --git a/src/index.js b/src/index.js index 7f8e8915c..bb135721b 100644 --- a/src/index.js +++ b/src/index.js @@ -54,6 +54,7 @@ import { startTempbanScheduler, stopTempbanScheduler } from './modules/moderatio import { loadOptOuts } from './modules/optout.js'; import { PerformanceMonitor } from './modules/performanceMonitor.js'; import { startScheduler, stopScheduler } from './modules/scheduler.js'; +import { startTempRoleScheduler, stopTempRoleScheduler } from './modules/tempRoleHandler.js'; import { startTriage, stopTriage } from './modules/triage.js'; import { startVoiceFlush, stopVoiceFlush } from './modules/voice.js'; import { fireEventAllGuilds } from './modules/webhookNotifier.js'; @@ -324,6 +325,7 @@ async function gracefulShutdown(signal) { stopTriage(); stopConversationCleanup(); stopTempbanScheduler(); + stopTempRoleScheduler(); stopScheduler(); stopGithubFeed(); stopScheduledBackups(); @@ -538,6 +540,7 @@ async function startup() { // Start tempban scheduler for automatic unbans (DB required) if (dbPool) { startTempbanScheduler(client); + startTempRoleScheduler(client); startScheduler(client); startGithubFeed(client); startScheduledBackups(); diff --git a/src/modules/tempRoleHandler.js b/src/modules/tempRoleHandler.js new file mode 100644 index 000000000..82642ba0b --- /dev/null +++ b/src/modules/tempRoleHandler.js @@ -0,0 +1,282 @@ +/** + * Temporary Role Handler + * + * Core logic for assigning roles with an expiry date, polling for expired + * assignments, and removing roles automatically when they expire. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/128 + */ + +import { getPool } from '../db.js'; +import { info, error as logError } from '../logger.js'; + +/** @type {ReturnType | null} */ +let schedulerInterval = null; + +/** @type {boolean} */ +let pollInFlight = false; + +/** + * Assign a temporary role to a user. + * + * @param {object} params + * @param {string} params.guildId - Discord guild ID + * @param {string} params.userId - Target user ID + * @param {string} params.userTag - Target user tag + * @param {string} params.roleId - Role ID to assign + * @param {string} params.roleName - Role name (for display) + * @param {string} params.moderatorId - Moderator user ID + * @param {string} params.moderatorTag - Moderator user tag + * @param {string} params.duration - Human-readable duration string + * @param {Date} params.expiresAt - Expiry timestamp + * @param {string|null} [params.reason] - Optional reason + * @returns {Promise} Created temp_role row + * @throws {Error} If database operation fails + */ +export async function assignTempRole({ + guildId, + userId, + userTag, + roleId, + roleName, + moderatorId, + moderatorTag, + duration, + expiresAt, + reason = null, +}) { + try { + const pool = getPool(); + const { rows } = await pool.query( + `INSERT INTO temp_roles + (guild_id, user_id, user_tag, role_id, role_name, moderator_id, moderator_tag, reason, duration, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + guildId, + userId, + userTag, + roleId, + roleName, + moderatorId, + moderatorTag, + reason, + duration, + expiresAt, + ], + ); + + info('Temp role assigned', { guildId, userId, roleId, roleName, duration }); + return rows[0]; + } catch (err) { + logError('Failed to assign temp role', { error: err.message, guildId, userId, roleId }); + throw new Error(`Failed to assign temp role: ${err.message}`); + } +} + +/** + * Revoke a temporary role early (before expiry) by record ID. + * This is the preferred method for dashboard-initiated revokes. + * + * @param {number} id - Record ID to revoke + * @param {string} guildId - Discord guild ID (for validation) + * @returns {Promise} Updated row or null if not found + * @throws {Error} If database operation fails + */ +export async function revokeTempRoleById(id, guildId) { + try { + const pool = getPool(); + const { rows } = await pool.query( + `UPDATE temp_roles + SET removed = TRUE, removed_at = NOW() + WHERE id = $1 AND guild_id = $2 AND removed = FALSE + RETURNING *`, + [id, guildId], + ); + + if (rows.length > 0) { + info('Temp role revoked by ID', { + id, + guildId, + userId: rows[0].user_id, + roleId: rows[0].role_id, + }); + } + + return rows[0] || null; + } catch (err) { + logError('Failed to revoke temp role by ID', { error: err.message, id, guildId }); + throw new Error(`Failed to revoke temp role: ${err.message}`); + } +} + +/** + * Revoke a temporary role early (before expiry). + * Note: This can affect multiple rows if the same user has the same role multiple times. + * Prefer revokeTempRoleById for precise revocation. + * + * @param {string} guildId - Discord guild ID + * @param {string} userId - Target user ID + * @param {string} roleId - Role ID to revoke + * @returns {Promise} Updated row or null if not found + * @throws {Error} If database operation fails + */ +export async function revokeTempRole(guildId, userId, roleId) { + try { + const pool = getPool(); + const { rows } = await pool.query( + `UPDATE temp_roles + SET removed = TRUE, removed_at = NOW() + WHERE guild_id = $1 AND user_id = $2 AND role_id = $3 AND removed = FALSE + RETURNING *`, + [guildId, userId, roleId], + ); + + if (rows.length > 0) { + info('Temp role revoked early', { guildId, userId, roleId }); + } + + return rows[0] || null; + } catch (err) { + logError('Failed to revoke temp role', { error: err.message, guildId, userId, roleId }); + throw new Error(`Failed to revoke temp role: ${err.message}`); + } +} + +/** + * List active (non-expired, non-removed) temp role assignments for a guild. + * Optionally filter by user. + * + * @param {string} guildId - Discord guild ID + * @param {object} [opts] + * @param {string} [opts.userId] - Filter by user ID + * @param {number} [opts.limit] - Max results (default 25) + * @param {number} [opts.offset] - Offset for pagination (default 0) + * @returns {Promise<{rows: object[], total: number}>} + * @throws {Error} If database operation fails + */ +export async function listTempRoles(guildId, { userId, limit = 25, offset = 0 } = {}) { + try { + const pool = getPool(); + + const conditions = ['guild_id = $1', 'removed = FALSE', 'expires_at > NOW()']; + const values = [guildId]; + + if (userId) { + conditions.push(`user_id = $${values.length + 1}`); + values.push(userId); + } + + const where = conditions.join(' AND '); + + const [{ rows }, { rows: countRows }] = await Promise.all([ + pool.query( + `SELECT * FROM temp_roles WHERE ${where} ORDER BY expires_at ASC LIMIT $${values.length + 1} OFFSET $${values.length + 2}`, + [...values, limit, offset], + ), + pool.query(`SELECT COUNT(*)::integer AS total FROM temp_roles WHERE ${where}`, values), + ]); + + return { rows, total: countRows[0]?.total || 0 }; + } catch (err) { + logError('Failed to list temp roles', { error: err.message, guildId }); + throw new Error(`Failed to list temp roles: ${err.message}`); + } +} + +/** + * Poll for expired temp roles and remove them from Discord. + * + * @param {import('discord.js').Client} client - Discord client + */ +async function pollExpiredTempRoles(client) { + if (pollInFlight) return; + pollInFlight = true; + + try { + const pool = getPool(); + const { rows } = await pool.query( + `SELECT * FROM temp_roles + WHERE removed = FALSE AND expires_at <= NOW() + ORDER BY expires_at ASC + LIMIT 50`, + ); + + for (const row of rows) { + // Optimistic lock — claim before processing to prevent double-processing + const claim = await pool.query( + `UPDATE temp_roles SET removed = TRUE, removed_at = NOW() + WHERE id = $1 AND removed = FALSE RETURNING id`, + [row.id], + ); + if (claim.rows.length === 0) continue; + + try { + const guild = await client.guilds.fetch(row.guild_id); + const member = await guild.members.fetch(row.user_id).catch(() => null); + + if (member) { + await member.roles.remove(row.role_id, 'Temp role expired'); + info('Temp role expired and removed', { + guildId: row.guild_id, + userId: row.user_id, + roleId: row.role_id, + roleName: row.role_name, + }); + } else { + info('Temp role expired — member no longer in guild', { + guildId: row.guild_id, + userId: row.user_id, + roleId: row.role_id, + }); + } + } catch (err) { + logError('Failed to remove expired temp role', { + error: err.message, + id: row.id, + guildId: row.guild_id, + userId: row.user_id, + roleId: row.role_id, + }); + } + } + } catch (err) { + logError('Temp role scheduler poll error', { error: err.message }); + } finally { + pollInFlight = false; + } +} + +/** + * Start the temp role expiry scheduler. + * Polls every 60 seconds and immediately on startup. + * + * @param {import('discord.js').Client} client - Discord client + */ +export function startTempRoleScheduler(client) { + if (schedulerInterval) return; + + // Immediate pass on startup to catch missed removals + pollExpiredTempRoles(client).catch((err) => { + logError('Initial temp role poll failed', { error: err.message }); + }); + + schedulerInterval = setInterval(() => { + pollExpiredTempRoles(client).catch((err) => { + logError('Temp role poll failed', { error: err.message }); + }); + }, 60_000); + + info('Temp role scheduler started'); +} + +/** + * Stop the temp role scheduler. + */ +export function stopTempRoleScheduler() { + if (schedulerInterval) { + clearInterval(schedulerInterval); + schedulerInterval = null; + info('Temp role scheduler stopped'); + } +} diff --git a/tests/commands/temprole.test.js b/tests/commands/temprole.test.js new file mode 100644 index 000000000..5870d69e3 --- /dev/null +++ b/tests/commands/temprole.test.js @@ -0,0 +1,217 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: vi.fn(), + safeReply: vi.fn(), + safeFollowUp: vi.fn(), + safeEditReply: (t, opts) => t.editReply(opts), +})); + +vi.mock('../../src/modules/tempRoleHandler.js', () => ({ + assignTempRole: vi.fn().mockResolvedValue({ id: 1 }), + revokeTempRole: vi.fn().mockResolvedValue({ id: 1, removed: true }), + listTempRoles: vi.fn().mockResolvedValue({ rows: [], total: 0 }), +})); + +vi.mock('../../src/utils/duration.js', () => ({ + parseDuration: vi.fn().mockReturnValue(86400000), + formatDuration: vi.fn().mockReturnValue('1 day'), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +import { adminOnly, data, execute } from '../../src/commands/temprole.js'; +import { + assignTempRole, + listTempRoles, + revokeTempRole, +} from '../../src/modules/tempRoleHandler.js'; +import { parseDuration } from '../../src/utils/duration.js'; + +// Minimal Discord mock helpers +const mockRole = { id: 'role1', name: 'VIP', position: 3 }; +const mockUser = { id: 'user1', tag: 'User#0001' }; +const mockMember = { + id: 'user1', + user: mockUser, + roles: { + add: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + highest: { position: 5 }, + }, +}; + +function createInteraction(subcommand, overrides = {}) { + return { + options: { + getSubcommand: vi.fn().mockReturnValue(subcommand), + getUser: vi.fn().mockReturnValue(mockUser), + getRole: vi.fn().mockReturnValue(mockRole), + getString: vi.fn().mockImplementation((name) => { + if (name === 'duration') return '1d'; + if (name === 'reason') return 'test reason'; + return null; + }), + }, + guild: { + id: 'guild1', + name: 'Test Server', + members: { + fetch: vi.fn().mockResolvedValue(mockMember), + me: { roles: { highest: { position: 10 } } }, + }, + }, + member: { roles: { highest: { position: 10 } } }, + user: { id: 'mod1', tag: 'Mod#0001' }, + guildId: 'guild1', + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('temprole command', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('exports name "temprole"', () => { + expect(data.name).toBe('temprole'); + }); + + it('exports adminOnly = true', () => { + expect(adminOnly).toBe(true); + }); + + // ── assign ────────────────────────────────────────────────────────────── + + describe('assign subcommand', () => { + it('assigns a role successfully', async () => { + const interaction = createInteraction('assign'); + await execute(interaction); + + expect(interaction.guild.members.fetch).toHaveBeenCalledWith(mockUser.id); + expect(mockMember.roles.add).toHaveBeenCalledWith('role1', expect.any(String)); + expect(assignTempRole).toHaveBeenCalledWith( + expect.objectContaining({ + guildId: 'guild1', + userId: 'user1', + roleId: 'role1', + roleName: 'VIP', + }), + ); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('✅')); + }); + + it('rejects invalid duration', async () => { + parseDuration.mockReturnValueOnce(null); + const interaction = createInteraction('assign'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('Invalid duration'), + ); + expect(assignTempRole).not.toHaveBeenCalled(); + }); + + it('rejects when user is not in guild', async () => { + const interaction = createInteraction('assign'); + interaction.guild.members.fetch = vi.fn().mockRejectedValue(new Error('Unknown Member')); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('not in this server'), + ); + }); + + it('rejects when role is too high for bot', async () => { + const interaction = createInteraction('assign'); + // Role position 10 >= bot highest position 10 + interaction.options.getRole = vi.fn().mockReturnValue({ ...mockRole, position: 10 }); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('cannot assign that role'), + ); + }); + + it('rejects when role is too high for moderator', async () => { + const interaction = createInteraction('assign'); + // Role position 10 >= moderator highest position 10 + interaction.member.roles.highest.position = 5; + interaction.options.getRole = vi.fn().mockReturnValue({ ...mockRole, position: 5 }); + + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('cannot assign a role equal to or higher'), + ); + }); + }); + + // ── revoke ────────────────────────────────────────────────────────────── + + describe('revoke subcommand', () => { + it('revokes a temp role successfully', async () => { + const interaction = createInteraction('revoke'); + await execute(interaction); + + expect(revokeTempRole).toHaveBeenCalledWith('guild1', 'user1', 'role1'); + expect(interaction.editReply).toHaveBeenCalledWith(expect.stringContaining('✅')); + }); + + it('returns error when no active assignment found', async () => { + revokeTempRole.mockResolvedValueOnce(null); + const interaction = createInteraction('revoke'); + await execute(interaction); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('No active temporary role assignment'), + ); + }); + }); + + // ── list ──────────────────────────────────────────────────────────────── + + describe('list subcommand', () => { + it('shows empty message when no assignments', async () => { + const interaction = createInteraction('list'); + interaction.options.getUser = vi.fn().mockReturnValue(null); + await execute(interaction); + + expect(listTempRoles).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('No active temporary role'), + ); + }); + + it('shows embed when assignments exist', async () => { + listTempRoles.mockResolvedValueOnce({ + rows: [ + { + id: 1, + user_id: 'u1', + role_id: 'r1', + expires_at: new Date(Date.now() + 86400000).toISOString(), + reason: null, + }, + ], + total: 1, + }); + + const interaction = createInteraction('list'); + interaction.options.getUser = vi.fn().mockReturnValue(null); + await execute(interaction); + + // editReply called with an embed object (not a string) + const call = interaction.editReply.mock.calls[0][0]; + expect(call).toHaveProperty('embeds'); + }); + }); +}); diff --git a/tests/modules/tempRoleHandler.test.js b/tests/modules/tempRoleHandler.test.js new file mode 100644 index 000000000..1ef4c7318 --- /dev/null +++ b/tests/modules/tempRoleHandler.test.js @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), +})); + +import { getPool } from '../../src/db.js'; +import { info, error as logError } from '../../src/logger.js'; +import { + assignTempRole, + listTempRoles, + revokeTempRole, + revokeTempRoleById, + startTempRoleScheduler, + stopTempRoleScheduler, +} from '../../src/modules/tempRoleHandler.js'; + +describe('tempRoleHandler', () => { + let mockPool; + + beforeEach(() => { + vi.useFakeTimers(); + + mockPool = { query: vi.fn() }; + getPool.mockReturnValue(mockPool); + }); + + afterEach(() => { + stopTempRoleScheduler(); + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + // ── assignTempRole ──────────────────────────────────────────────────────── + + describe('assignTempRole', () => { + it('inserts a record and returns it', async () => { + const fakeRow = { + id: 1, + guild_id: 'g1', + user_id: 'u1', + user_tag: 'User#0001', + role_id: 'r1', + role_name: 'VIP', + duration: '1 day', + expires_at: new Date(Date.now() + 86400000), + }; + mockPool.query.mockResolvedValueOnce({ rows: [fakeRow] }); + + const result = await assignTempRole({ + guildId: 'g1', + userId: 'u1', + userTag: 'User#0001', + roleId: 'r1', + roleName: 'VIP', + moderatorId: 'mod1', + moderatorTag: 'Mod#0001', + duration: '1 day', + expiresAt: fakeRow.expires_at, + reason: 'test reason', + }); + + expect(result).toEqual(fakeRow); + expect(mockPool.query).toHaveBeenCalledOnce(); + expect(mockPool.query.mock.calls[0][0]).toContain('INSERT INTO temp_roles'); + expect(info).toHaveBeenCalledWith( + 'Temp role assigned', + expect.objectContaining({ roleId: 'r1' }), + ); + }); + + it('throws on database error', async () => { + mockPool.query.mockRejectedValueOnce(new Error('DB connection failed')); + + await expect( + assignTempRole({ + guildId: 'g1', + userId: 'u1', + userTag: 'User#0001', + roleId: 'r1', + roleName: 'VIP', + moderatorId: 'mod1', + moderatorTag: 'Mod#0001', + duration: '1 day', + expiresAt: new Date(), + }), + ).rejects.toThrow('Failed to assign temp role'); + + expect(logError).toHaveBeenCalledWith( + 'Failed to assign temp role', + expect.objectContaining({ error: 'DB connection failed' }), + ); + }); + }); + + // ── revokeTempRole ──────────────────────────────────────────────────────── + + describe('revokeTempRole', () => { + it('marks the record removed and returns it', async () => { + const fakeRow = { id: 1, guild_id: 'g1', user_id: 'u1', role_id: 'r1', removed: true }; + mockPool.query.mockResolvedValueOnce({ rows: [fakeRow] }); + + const result = await revokeTempRole('g1', 'u1', 'r1'); + + expect(result).toEqual(fakeRow); + expect(mockPool.query.mock.calls[0][0]).toContain('UPDATE temp_roles'); + expect(info).toHaveBeenCalledWith('Temp role revoked early', expect.any(Object)); + }); + + it('returns null when no active assignment found', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const result = await revokeTempRole('g1', 'u1', 'nonexistent'); + expect(result).toBeNull(); + }); + + it('throws on database error', async () => { + mockPool.query.mockRejectedValueOnce(new Error('DB error')); + + await expect(revokeTempRole('g1', 'u1', 'r1')).rejects.toThrow('Failed to revoke temp role'); + + expect(logError).toHaveBeenCalledWith( + 'Failed to revoke temp role', + expect.objectContaining({ error: 'DB error' }), + ); + }); + }); + + // ── revokeTempRoleById ──────────────────────────────────────────────────── + + describe('revokeTempRoleById', () => { + it('revokes by record id and returns the row', async () => { + const fakeRow = { id: 42, guild_id: 'g1', user_id: 'u1', role_id: 'r1', removed: true }; + mockPool.query.mockResolvedValueOnce({ rows: [fakeRow] }); + + const result = await revokeTempRoleById(42, 'g1'); + + expect(result).toEqual(fakeRow); + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('WHERE id = $1 AND guild_id = $2'), + [42, 'g1'], + ); + expect(info).toHaveBeenCalledWith('Temp role revoked by ID', expect.any(Object)); + }); + + it('returns null when record not found', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const result = await revokeTempRoleById(999, 'g1'); + expect(result).toBeNull(); + }); + + it('throws on database error', async () => { + mockPool.query.mockRejectedValueOnce(new Error('DB error')); + + await expect(revokeTempRoleById(42, 'g1')).rejects.toThrow('Failed to revoke temp role'); + + expect(logError).toHaveBeenCalledWith( + 'Failed to revoke temp role by ID', + expect.objectContaining({ error: 'DB error' }), + ); + }); + }); + + // ── listTempRoles ───────────────────────────────────────────────────────── + + describe('listTempRoles', () => { + it('returns rows and total count', async () => { + const fakeRows = [{ id: 1 }, { id: 2 }]; + mockPool.query + .mockResolvedValueOnce({ rows: fakeRows }) + .mockResolvedValueOnce({ rows: [{ total: 2 }] }); + + const result = await listTempRoles('g1'); + + expect(result.rows).toEqual(fakeRows); + expect(result.total).toBe(2); + }); + + it('adds userId filter when provided', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ total: 0 }] }); + + await listTempRoles('g1', { userId: 'u1' }); + + const selectCall = mockPool.query.mock.calls[0][0]; + expect(selectCall).toContain('user_id'); + }); + + it('returns empty when no results', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ total: 0 }] }); + + const result = await listTempRoles('g1'); + expect(result.rows).toHaveLength(0); + expect(result.total).toBe(0); + }); + + it('throws on database error', async () => { + mockPool.query.mockRejectedValueOnce(new Error('DB error')); + + await expect(listTempRoles('g1')).rejects.toThrow('Failed to list temp roles'); + + expect(logError).toHaveBeenCalledWith( + 'Failed to list temp roles', + expect.objectContaining({ error: 'DB error' }), + ); + }); + }); + + // ── scheduler ───────────────────────────────────────────────────────────── + + describe('startTempRoleScheduler / stopTempRoleScheduler', () => { + it('starts and stops without error', () => { + // Pool query for initial poll (no expired rows) + mockPool.query.mockResolvedValue({ rows: [] }); + + const mockClient = { guilds: { fetch: vi.fn() } }; + startTempRoleScheduler(mockClient); + stopTempRoleScheduler(); + + expect(info).toHaveBeenCalledWith('Temp role scheduler started'); + expect(info).toHaveBeenCalledWith('Temp role scheduler stopped'); + }); + + it('does not start a second interval if already running', () => { + mockPool.query.mockResolvedValue({ rows: [] }); + + const mockClient = { guilds: { fetch: vi.fn() } }; + startTempRoleScheduler(mockClient); + startTempRoleScheduler(mockClient); // second call should be a no-op + + // info called once (not twice for "started") + const startedCalls = info.mock.calls.filter(([msg]) => msg === 'Temp role scheduler started'); + expect(startedCalls).toHaveLength(1); + }); + }); +}); diff --git a/web/src/app/api/temp-roles/[id]/route.ts b/web/src/app/api/temp-roles/[id]/route.ts new file mode 100644 index 000000000..b258a6ab7 --- /dev/null +++ b/web/src/app/api/temp-roles/[id]/route.ts @@ -0,0 +1,54 @@ +/** + * Next.js API proxy — DELETE /api/temp-roles/:id + * Revokes a specific temp role assignment. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/128 + */ + +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + authorizeGuildAdmin, + buildUpstreamUrl, + getBotApiConfig, + proxyToBotApi, +} from '@/lib/bot-api-proxy'; + +export const dynamic = 'force-dynamic'; + +const LOG_PREFIX = '[api/temp-roles/:id]'; + +/** + * DELETE /api/temp-roles/:id?guildId=... + * Revokes a temp role by record ID. + */ +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +): Promise { + const { id } = await params; + const guildId = request.nextUrl.searchParams.get('guildId'); + + if (!guildId) { + return NextResponse.json({ error: 'guildId is required' }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstream = buildUpstreamUrl( + config.baseUrl, + `/temp-roles/${encodeURIComponent(id)}`, + LOG_PREFIX, + ); + if (upstream instanceof NextResponse) return upstream; + + upstream.searchParams.set('guildId', guildId); + + return proxyToBotApi(upstream, config.secret, LOG_PREFIX, 'Failed to revoke temp role', { + method: 'DELETE', + }); +} diff --git a/web/src/app/api/temp-roles/route.ts b/web/src/app/api/temp-roles/route.ts new file mode 100644 index 000000000..c625751e9 --- /dev/null +++ b/web/src/app/api/temp-roles/route.ts @@ -0,0 +1,80 @@ +/** + * Next.js API proxy for temp role endpoints. + * Proxies GET (list) and POST (assign) to the bot API. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/128 + */ + +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + authorizeGuildAdmin, + buildUpstreamUrl, + getBotApiConfig, + proxyToBotApi, +} from '@/lib/bot-api-proxy'; + +export const dynamic = 'force-dynamic'; + +const LOG_PREFIX = '[api/temp-roles]'; +const ALLOWED_GET_PARAMS = ['guildId', 'userId', 'page', 'limit']; + +/** + * GET /api/temp-roles?guildId=... + * Lists active temp role assignments for a guild. + */ +export async function GET(request: NextRequest): Promise { + const guildId = request.nextUrl.searchParams.get('guildId'); + if (!guildId) { + return NextResponse.json({ error: 'guildId is required' }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstream = buildUpstreamUrl(config.baseUrl, '/temp-roles', LOG_PREFIX); + if (upstream instanceof NextResponse) return upstream; + + for (const key of ALLOWED_GET_PARAMS) { + const value = request.nextUrl.searchParams.get(key); + if (value !== null) upstream.searchParams.set(key, value); + } + + return proxyToBotApi(upstream, config.secret, LOG_PREFIX, 'Failed to fetch temp roles'); +} + +/** + * POST /api/temp-roles + * Assigns a temporary role via the dashboard. + */ +export async function POST(request: NextRequest): Promise { + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const guildId = body.guildId; + if (!guildId) { + return NextResponse.json({ error: 'guildId is required' }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstream = buildUpstreamUrl(config.baseUrl, '/temp-roles', LOG_PREFIX); + if (upstream instanceof NextResponse) return upstream; + + return proxyToBotApi(upstream, config.secret, LOG_PREFIX, 'Failed to assign temp role', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} diff --git a/web/src/app/dashboard/temp-roles/page.tsx b/web/src/app/dashboard/temp-roles/page.tsx new file mode 100644 index 000000000..edd373127 --- /dev/null +++ b/web/src/app/dashboard/temp-roles/page.tsx @@ -0,0 +1,295 @@ +'use client'; + +/** + * Temp Roles Dashboard Page + * View and manage active temporary role assignments. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/128 + */ + +import { Clock, RefreshCw, Shield, Trash2 } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { useGuildSelection } from '@/hooks/use-guild-selection'; + +interface TempRole { + id: number; + guild_id: string; + user_id: string; + user_tag: string; + role_id: string; + role_name: string; + moderator_id: string; + moderator_tag: string; + reason: string | null; + duration: string; + expires_at: string; + created_at: string; +} + +interface TempRolesResponse { + data: TempRole[]; + pagination: { + page: number; + limit: number; + total: number; + pages: number; + }; +} + +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = Date.now(); + const diff = date.getTime() - now; + + if (diff <= 0) return 'Expired'; + + const s = Math.floor(diff / 1000); + if (s < 60) return `in ${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `in ${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `in ${h}h`; + const d = Math.floor(h / 24); + return `in ${d}d`; +} + +export default function TempRolesPage() { + const router = useRouter(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [revoking, setRevoking] = useState(null); + const abortRef = useRef(null); + + const onGuildChange = useCallback(() => setPage(1), []); + const guildId = useGuildSelection({ onGuildChange }); + + const onUnauthorized = useCallback(() => router.replace('/login'), [router]); + + const fetchTempRoles = useCallback( + async (id: string, currentPage: number) => { + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams({ + guildId: id, + page: String(currentPage), + limit: '25', + }); + + const res = await fetch(`/api/temp-roles?${params.toString()}`, { + cache: 'no-store', + signal: controller.signal, + }); + + if (res.status === 401) { + onUnauthorized(); + return; + } + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError(body.error || 'Failed to load temp roles'); + return; + } + + const json: TempRolesResponse = await res.json(); + setData(json); + } catch (err) { + if ((err as Error).name !== 'AbortError') { + setError('Failed to load temp roles'); + } + } finally { + setLoading(false); + } + }, + [onUnauthorized], + ); + + useEffect(() => { + if (!guildId) return; + void fetchTempRoles(guildId, page); + }, [guildId, page, fetchTempRoles]); + + const handleRevoke = useCallback( + async (record: TempRole) => { + if (!guildId) return; + if (!confirm(`Revoke ${record.role_name} from ${record.user_tag}?`)) return; + + setRevoking(record.id); + try { + const res = await fetch( + `/api/temp-roles/${record.id}?guildId=${encodeURIComponent(guildId)}`, + { + method: 'DELETE', + }, + ); + + if (res.status === 401) { + onUnauthorized(); + return; + } + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + alert(body.error || 'Failed to revoke temp role'); + return; + } + + // Refresh list + void fetchTempRoles(guildId, page); + } catch { + alert('Failed to revoke temp role'); + } finally { + setRevoking(null); + } + }, + [guildId, page, fetchTempRoles, onUnauthorized], + ); + + const handleRefresh = useCallback(() => { + if (guildId) void fetchTempRoles(guildId, page); + }, [guildId, page, fetchTempRoles]); + + const rows = data?.data ?? []; + const pagination = data?.pagination; + + return ( +
+ {/* Header */} +
+
+

+ + Temporary Roles +

+

+ Active role assignments that expire automatically. +

+
+ +
+ + {/* No guild selected */} + {!guildId && ( +
+ Select a server from the top bar to view temp roles. +
+ )} + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Table */} + {guildId && !error && ( +
+ {loading && rows.length === 0 ? ( +
Loading…
+ ) : rows.length === 0 ? ( +
+ No active temporary roles. +
+ ) : ( + + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
UserRoleDurationExpiresModeratorReasonActions
+ {row.user_tag} + ({row.user_id}) + + + + {row.role_name} + + {row.duration} + + {formatRelativeTime(row.expires_at)} + + {row.moderator_tag} + {row.reason ?? '—'} + + +
+ )} +
+ )} + + {/* Pagination */} + {pagination && pagination.pages > 1 && ( +
+

+ Page {pagination.page} of {pagination.pages} — {pagination.total} total +

+
+ + +
+
+ )} +
+ ); +} diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index c6015577d..82e644f2d 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -4,6 +4,7 @@ import { Activity, Bot, ClipboardList, + Clock, LayoutDashboard, MessageSquare, MessagesSquare, @@ -29,6 +30,11 @@ const navigation = [ href: '/dashboard/moderation', icon: Shield, }, + { + name: 'Temp Roles', + href: '/dashboard/temp-roles', + icon: Clock, + }, { name: 'AI Chat', href: '/dashboard/ai',