mirror of
https://github.com/vrcx-team/VRCX.git
synced 2026-04-06 00:32:02 +02:00
fead: group member moderation ban export/import dialog (#1675)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Alert variant="warning" class="mb-4">
|
||||
<MessageSquareWarning class="text-lg" />
|
||||
<MessageSquareWarning />
|
||||
<AlertTitle>{{ t('common.feature_relocated.title') }}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<i18n-t keypath="common.feature_relocated.description" tag="span">
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="isGroupBansExportDialogVisible"
|
||||
@update:open="
|
||||
(open) => {
|
||||
if (!open) setIsGroupBansExportDialogVisible();
|
||||
}
|
||||
">
|
||||
<DialogContent class="x-dialog sm:max-w-162.5">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('dialog.group_member_moderation.export_bans') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div style="margin-bottom: 10px" class="flex flex-col gap-2">
|
||||
<label v-for="option in exportBansOptions" :key="option.label" class="inline-flex items-center gap-2">
|
||||
<Checkbox
|
||||
:model-value="checkedExportBansOptions.includes(option.label)"
|
||||
@update:modelValue="(val) => toggleExportOption(option.label, val)" />
|
||||
<span>{{ t(option.text) }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<br />
|
||||
<InputGroupTextareaField
|
||||
v-model="exportContent"
|
||||
:rows="15"
|
||||
readonly
|
||||
style="margin-top: 15px"
|
||||
input-class="resize-none"
|
||||
@click="handleCopyExportContent" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { ref, watch } from 'vue';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { InputGroupTextareaField } from '@/components/ui/input-group';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { copyToClipboard } from '../../../shared/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
isGroupBansExportDialogVisible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
groupBansModerationTable: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:isGroupBansExportDialogVisible']);
|
||||
|
||||
watch(
|
||||
() => props.isGroupBansExportDialogVisible,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
updateExportContent();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const exportContent = ref('');
|
||||
|
||||
const exportBansOptions = [
|
||||
{ label: 'userId', text: 'dialog.group_member_moderation.user_id' },
|
||||
{ label: 'displayName', text: 'dialog.group_member_moderation.display_name' },
|
||||
{ label: 'roles', text: 'dialog.group_member_moderation.roles' },
|
||||
{ label: 'managerNotes', text: 'dialog.group_member_moderation.notes' },
|
||||
{ label: 'joinedAt', text: 'dialog.group_member_moderation.joined_at' },
|
||||
{ label: 'bannedAt', text: 'dialog.group_member_moderation.banned_at' }
|
||||
];
|
||||
|
||||
const checkedExportBansOptions = ref(['userId', 'displayName', 'roles', 'managerNotes', 'joinedAt', 'bannedAt']);
|
||||
|
||||
function toggleExportOption(label, checked) {
|
||||
const selection = checkedExportBansOptions.value;
|
||||
const index = selection.indexOf(label);
|
||||
if (checked && index === -1) {
|
||||
selection.push(label);
|
||||
} else if (!checked && index !== -1) {
|
||||
selection.splice(index, 1);
|
||||
}
|
||||
updateExportContent();
|
||||
}
|
||||
|
||||
function getRowValue(item, key) {
|
||||
switch (key) {
|
||||
case 'displayName':
|
||||
return item?.user?.displayName ?? item?.$displayName ?? '';
|
||||
case 'roles': {
|
||||
const ids = Array.isArray(item?.roleIds) ? item.roleIds : [];
|
||||
return ids.join(';');
|
||||
}
|
||||
default:
|
||||
return item?.[key] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function updateExportContent() {
|
||||
const formatter = (str) => (/[\x00-\x1f,"]/.test(str) ? `"${str.replace(/"/g, '""')}"` : str);
|
||||
|
||||
const sortedCheckedOptions = exportBansOptions
|
||||
.filter((option) => checkedExportBansOptions.value.includes(option.label))
|
||||
.map((option) => option.label);
|
||||
|
||||
const header = `${sortedCheckedOptions.join(',')}\n`;
|
||||
|
||||
const content = props.groupBansModerationTable.data
|
||||
.map((item) => sortedCheckedOptions.map((key) => formatter(String(getRowValue(item, key)))).join(','))
|
||||
.join('\n');
|
||||
|
||||
exportContent.value = header + content;
|
||||
}
|
||||
|
||||
function handleCopyExportContent() {
|
||||
copyToClipboard(exportContent.value);
|
||||
}
|
||||
|
||||
function setIsGroupBansExportDialogVisible() {
|
||||
emit('update:isGroupBansExportDialogVisible', false);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:open="isGroupBansImportDialogVisible"
|
||||
@update:open="
|
||||
(open) => {
|
||||
if (!open) closeDialog();
|
||||
}
|
||||
">
|
||||
<DialogContent class="x-dialog sm:max-w-162.5">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('dialog.group_member_moderation.import_bans') }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="text-xs text-muted-foreground mb-2">
|
||||
<p>{{ t('dialog.group_member_moderation.import_bans_description') }}</p>
|
||||
</div>
|
||||
|
||||
<Alert variant="warning" class="mb-2">
|
||||
<TriangleAlert />
|
||||
<AlertDescription>
|
||||
{{ t('dialog.group_member_moderation.import_bans_warning') }}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<InputGroupTextareaField
|
||||
v-model="csvInput"
|
||||
:rows="10"
|
||||
:disabled="importing"
|
||||
class="mb-2"
|
||||
input-class="resize-none"
|
||||
:placeholder="t('dialog.group_member_moderation.import_bans_placeholder')" />
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="sm" :disabled="!csvInput.trim() || importing" @click="parseAndImport">
|
||||
{{ t('dialog.group_member_moderation.import_bans_start') }}
|
||||
</Button>
|
||||
<Button v-if="importing" size="sm" variant="secondary" @click="cancelImport">
|
||||
{{ t('dialog.group_member_moderation.cancel') }}
|
||||
</Button>
|
||||
<span v-if="importing" class="text-sm">
|
||||
<Spinner class="inline-block ml-2 mr-2" />
|
||||
{{ t('dialog.group_member_moderation.progress') }}
|
||||
{{ progressCurrent }}/{{ progressTotal }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="errors">
|
||||
<br />
|
||||
<Button size="sm" variant="secondary" @click="errors = ''">
|
||||
{{ t('dialog.group_member_moderation.import_bans_clear_errors') }}
|
||||
</Button>
|
||||
<pre style="white-space: pre-wrap; font-size: 12px; margin-top: 5px" v-text="errors"></pre>
|
||||
</template>
|
||||
|
||||
<template v-if="resultMessage">
|
||||
<br />
|
||||
<span class="text-sm">{{ resultMessage }}</span>
|
||||
</template>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { InputGroupTextareaField } from '@/components/ui/input-group';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { TriangleAlert } from 'lucide-vue-next';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { groupRequest } from '../../../api';
|
||||
|
||||
import * as workerTimers from 'worker-timers';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
isGroupBansImportDialogVisible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
groupId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:isGroupBansImportDialogVisible', 'imported']);
|
||||
|
||||
const csvInput = ref('');
|
||||
const importing = ref(false);
|
||||
const cancelled = ref(false);
|
||||
const progressCurrent = ref(0);
|
||||
const progressTotal = ref(0);
|
||||
const errors = ref('');
|
||||
const resultMessage = ref('');
|
||||
|
||||
/**
|
||||
* Parse CSV input and extract user IDs.
|
||||
* Supports:
|
||||
* - Raw list of user IDs (one per line)
|
||||
* - CSV with header row containing a "userId" column
|
||||
* - Any column containing usr_ prefixed IDs
|
||||
* @param input
|
||||
*/
|
||||
function extractUserIds(input) {
|
||||
const lines = input
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const userIdRegex = /usr_[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}/g;
|
||||
|
||||
// Try CSV with header: check if first line contains "userId"
|
||||
const firstLine = lines[0];
|
||||
const headers = firstLine.split(',').map((h) => h.trim());
|
||||
const userIdColIndex = headers.findIndex((h) => h.toLowerCase() === 'userid');
|
||||
|
||||
if (userIdColIndex !== -1 && lines.length > 1) {
|
||||
// CSV mode: extract from specific column
|
||||
const ids = new Set();
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cols = lines[i].split(',');
|
||||
if (cols.length > userIdColIndex) {
|
||||
const val = cols[userIdColIndex].trim().replace(/^"|"$/g, '');
|
||||
if (val.startsWith('usr_')) {
|
||||
ids.add(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
// Fallback: extract all usr_ IDs from entire input
|
||||
const ids = new Set();
|
||||
let match;
|
||||
while ((match = userIdRegex.exec(input)) !== null) {
|
||||
ids.add(match[0]);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function parseAndImport() {
|
||||
const userIds = extractUserIds(csvInput.value);
|
||||
if (userIds.length === 0) {
|
||||
errors.value = 'No valid user IDs found (usr_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)';
|
||||
return;
|
||||
}
|
||||
|
||||
importing.value = true;
|
||||
cancelled.value = false;
|
||||
progressCurrent.value = 0;
|
||||
progressTotal.value = userIds.length;
|
||||
errors.value = '';
|
||||
resultMessage.value = '';
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < userIds.length; i++) {
|
||||
if (cancelled.value) break;
|
||||
|
||||
const userId = userIds[i];
|
||||
progressCurrent.value = i + 1;
|
||||
|
||||
try {
|
||||
await groupRequest.banGroupMember({
|
||||
groupId: props.groupId,
|
||||
userId
|
||||
});
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
errors.value += `${userId}: ${err}\n`;
|
||||
}
|
||||
|
||||
// Rate limit delay between requests
|
||||
if (i < userIds.length - 1 && !cancelled.value) {
|
||||
await new Promise((resolve) => {
|
||||
workerTimers.setTimeout(resolve, 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resultMessage.value = cancelled.value
|
||||
? `Cancelled. Banned ${successCount}/${progressCurrent.value} users.`
|
||||
: `Done. Banned ${successCount}/${userIds.length} users.`;
|
||||
|
||||
importing.value = false;
|
||||
progressCurrent.value = 0;
|
||||
progressTotal.value = 0;
|
||||
|
||||
if (successCount > 0) {
|
||||
emit('imported');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function cancelImport() {
|
||||
cancelled.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function closeDialog() {
|
||||
if (importing.value) {
|
||||
cancelImport();
|
||||
}
|
||||
csvInput.value = '';
|
||||
errors.value = '';
|
||||
resultMessage.value = '';
|
||||
emit('update:isGroupBansImportDialogVisible', false);
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getAuditLogTypeName, resolveRoleNames } from '../groupModerationUtils';
|
||||
|
||||
describe('getAuditLogTypeName', () => {
|
||||
it('converts dotted audit log type to title case', () => {
|
||||
expect(getAuditLogTypeName('group.member.ban')).toBe('Member Ban');
|
||||
});
|
||||
|
||||
it('handles single segment after group prefix', () => {
|
||||
expect(getAuditLogTypeName('group.update')).toBe('Update');
|
||||
});
|
||||
|
||||
it('handles multiple segments', () => {
|
||||
expect(getAuditLogTypeName('group.role.member.add')).toBe(
|
||||
'Role Member Add'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty string for falsy input', () => {
|
||||
expect(getAuditLogTypeName('')).toBe('');
|
||||
expect(getAuditLogTypeName(null)).toBe('');
|
||||
expect(getAuditLogTypeName(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRoleNames', () => {
|
||||
const roles = [
|
||||
{ id: 'role_1', name: 'Admin' },
|
||||
{ id: 'role_2', name: 'Moderator' },
|
||||
{ id: 'role_3', name: 'Member' }
|
||||
];
|
||||
|
||||
it('resolves role IDs to comma-separated names', () => {
|
||||
expect(resolveRoleNames(['role_1', 'role_3'], roles)).toBe(
|
||||
'Admin, Member'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty string for empty roleIds', () => {
|
||||
expect(resolveRoleNames([], roles)).toBe('');
|
||||
});
|
||||
|
||||
it('skips unknown role IDs', () => {
|
||||
expect(resolveRoleNames(['role_1', 'role_unknown'], roles)).toBe(
|
||||
'Admin'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles non-array roleIds gracefully', () => {
|
||||
expect(resolveRoleNames(null, roles)).toBe('');
|
||||
expect(resolveRoleNames(undefined, roles)).toBe('');
|
||||
});
|
||||
|
||||
it('handles non-array roles gracefully', () => {
|
||||
expect(resolveRoleNames(['role_1'], null)).toBe('');
|
||||
});
|
||||
|
||||
it('returns single name without comma', () => {
|
||||
expect(resolveRoleNames(['role_2'], roles)).toBe('Moderator');
|
||||
});
|
||||
});
|
||||
32
src/components/dialogs/GroupDialog/groupModerationUtils.js
Normal file
32
src/components/dialogs/GroupDialog/groupModerationUtils.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Convert an audit log type string to a human-readable name.
|
||||
* e.g. 'group.member.ban' → 'Member Ban'
|
||||
* @param {string} auditLogType
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getAuditLogTypeName(auditLogType) {
|
||||
if (!auditLogType) return '';
|
||||
return auditLogType
|
||||
.replace('group.', '')
|
||||
.replace(/\./g, ' ')
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an array of role IDs to a comma-separated string of role names.
|
||||
* @param {Array<string>} roleIds
|
||||
* @param {Array<{id: string, name: string}>} roles - available roles
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolveRoleNames(roleIds, roles) {
|
||||
const ids = Array.isArray(roleIds) ? roleIds : [];
|
||||
const roleList = Array.isArray(roles) ? roles : [];
|
||||
const names = [];
|
||||
for (const id of ids) {
|
||||
const role = roleList.find((r) => r?.id === id);
|
||||
if (role?.name) {
|
||||
names.push(role.name);
|
||||
}
|
||||
}
|
||||
return names.join(', ');
|
||||
}
|
||||
321
src/components/dialogs/GroupDialog/useGroupBatchOperations.js
Normal file
321
src/components/dialogs/GroupDialog/useGroupBatchOperations.js
Normal file
@@ -0,0 +1,321 @@
|
||||
import { ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
/**
|
||||
* Composable for batch moderation operations with progress tracking.
|
||||
* @param {object} deps
|
||||
* @param {import('vue').Ref} deps.selectedUsersArray
|
||||
* @param {import('vue').Ref} deps.currentUser
|
||||
* @param {import('vue').Ref} deps.groupMemberModeration
|
||||
* @param {Function} deps.deselectedUsers
|
||||
* @param {object} deps.groupRequest
|
||||
* @param {Function} deps.handleGroupMemberRoleChange
|
||||
* @param {Function} deps.handleGroupMemberProps
|
||||
*/
|
||||
export function useGroupBatchOperations(deps) {
|
||||
const progressCurrent = ref(0);
|
||||
const progressTotal = ref(0);
|
||||
|
||||
/**
|
||||
* Generic batch operation runner.
|
||||
* @param {object} options
|
||||
* @param {Function} options.action - async (user, groupId) => void
|
||||
* @param {string} options.logPrefix - e.g. 'Banning'
|
||||
* @param {string} options.successMessage - e.g. 'Banned {count} group members'
|
||||
* @param {string} options.errorMessage - e.g. 'Failed to ban group member'
|
||||
* @param {boolean} [options.skipSelf]
|
||||
* @param {Function} [options.onComplete] - called after the loop finishes
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function runBatchOperation({
|
||||
action,
|
||||
logPrefix,
|
||||
successMessage,
|
||||
errorMessage,
|
||||
skipSelf = true,
|
||||
onComplete
|
||||
}) {
|
||||
const users = [...deps.selectedUsersArray.value];
|
||||
const memberCount = users.length;
|
||||
const groupId = deps.groupMemberModeration.value.id;
|
||||
progressTotal.value = memberCount;
|
||||
let allSuccess = true;
|
||||
|
||||
for (let i = 0; i < memberCount; i++) {
|
||||
if (!progressTotal.value) break;
|
||||
const user = users[i];
|
||||
progressCurrent.value = i + 1;
|
||||
|
||||
if (skipSelf && user.userId === deps.currentUser.value.id) continue;
|
||||
|
||||
console.log(`${logPrefix} ${user.userId} ${i + 1}/${memberCount}`);
|
||||
try {
|
||||
await action(user, groupId);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(`${errorMessage}: ${err}`);
|
||||
allSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allSuccess) {
|
||||
toast.success(successMessage.replace('{count}', memberCount));
|
||||
}
|
||||
progressCurrent.value = 0;
|
||||
progressTotal.value = 0;
|
||||
deps.deselectedUsers(null, true);
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param callbacks
|
||||
*/
|
||||
async function groupMembersBan(callbacks) {
|
||||
await runBatchOperation({
|
||||
action: (user, groupId) =>
|
||||
deps.groupRequest.banGroupMember({
|
||||
groupId,
|
||||
userId: user.userId
|
||||
}),
|
||||
logPrefix: 'Banning',
|
||||
successMessage: 'Banned {count} group members',
|
||||
errorMessage: 'Failed to ban group member',
|
||||
onComplete: callbacks?.onComplete
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param callbacks
|
||||
*/
|
||||
async function groupMembersUnban(callbacks) {
|
||||
await runBatchOperation({
|
||||
action: (user, groupId) =>
|
||||
deps.groupRequest.unbanGroupMember({
|
||||
groupId,
|
||||
userId: user.userId
|
||||
}),
|
||||
logPrefix: 'Unbanning',
|
||||
successMessage: 'Unbanned {count} group members',
|
||||
errorMessage: 'Failed to unban group member',
|
||||
onComplete: callbacks?.onComplete
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param callbacks
|
||||
*/
|
||||
async function groupMembersKick(callbacks) {
|
||||
await runBatchOperation({
|
||||
action: (user, groupId) =>
|
||||
deps.groupRequest.kickGroupMember({
|
||||
groupId,
|
||||
userId: user.userId
|
||||
}),
|
||||
logPrefix: 'Kicking',
|
||||
successMessage: 'Kicked {count} group members',
|
||||
errorMessage: 'Failed to kick group member',
|
||||
onComplete: callbacks?.onComplete
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param noteValue
|
||||
* @param callbacks
|
||||
*/
|
||||
async function groupMembersSaveNote(noteValue, callbacks) {
|
||||
await runBatchOperation({
|
||||
action: async (user, groupId) => {
|
||||
if (user.managerNotes === noteValue) return;
|
||||
const args = await deps.groupRequest.setGroupMemberProps(
|
||||
user.userId,
|
||||
groupId,
|
||||
{
|
||||
managerNotes: noteValue
|
||||
}
|
||||
);
|
||||
deps.handleGroupMemberProps(args);
|
||||
},
|
||||
logPrefix: 'Setting note for',
|
||||
successMessage: 'Saved notes for {count} group members',
|
||||
errorMessage: 'Failed to set group member note for',
|
||||
skipSelf: false,
|
||||
onComplete: callbacks?.onComplete
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param roleIds
|
||||
* @param callbacks
|
||||
*/
|
||||
async function groupMembersRemoveRoles(roleIds, callbacks) {
|
||||
const rolesToRemoveSet = new Set(roleIds);
|
||||
await runBatchOperation({
|
||||
action: async (user, groupId) => {
|
||||
const currentRoles = new Set(user.roleIds || []);
|
||||
const rolesToRemoveForUser = [];
|
||||
rolesToRemoveSet.forEach((roleId) => {
|
||||
if (currentRoles.has(roleId)) {
|
||||
rolesToRemoveForUser.push(roleId);
|
||||
}
|
||||
});
|
||||
if (!rolesToRemoveForUser.length) return;
|
||||
for (const roleId of rolesToRemoveForUser) {
|
||||
const args = await deps.groupRequest.removeGroupMemberRole({
|
||||
groupId,
|
||||
userId: user.userId,
|
||||
roleId
|
||||
});
|
||||
deps.handleGroupMemberRoleChange(args);
|
||||
}
|
||||
},
|
||||
logPrefix: 'Removing roles from',
|
||||
successMessage: 'Roles removed',
|
||||
errorMessage: 'Failed to remove group member roles',
|
||||
onComplete: callbacks?.onComplete
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param roleIds
|
||||
* @param callbacks
|
||||
*/
|
||||
async function groupMembersAddRoles(roleIds, callbacks) {
|
||||
const rolesToAddSet = new Set(roleIds);
|
||||
await runBatchOperation({
|
||||
action: async (user, groupId) => {
|
||||
const currentRoles = new Set(user.roleIds || []);
|
||||
const rolesToAddForUser = [];
|
||||
rolesToAddSet.forEach((roleId) => {
|
||||
if (!currentRoles.has(roleId)) {
|
||||
rolesToAddForUser.push(roleId);
|
||||
}
|
||||
});
|
||||
if (!rolesToAddForUser.length) return;
|
||||
for (const roleId of rolesToAddForUser) {
|
||||
const args = await deps.groupRequest.addGroupMemberRole({
|
||||
groupId,
|
||||
userId: user.userId,
|
||||
roleId
|
||||
});
|
||||
deps.handleGroupMemberRoleChange(args);
|
||||
}
|
||||
},
|
||||
logPrefix: 'Adding roles to',
|
||||
successMessage: 'Added group member roles',
|
||||
errorMessage: 'Failed to add group member roles',
|
||||
onComplete: callbacks?.onComplete
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param callbacks
|
||||
*/
|
||||
async function groupMembersDeleteSentInvite(callbacks) {
|
||||
await runBatchOperation({
|
||||
action: (user, groupId) =>
|
||||
deps.groupRequest.deleteSentGroupInvite({
|
||||
groupId,
|
||||
userId: user.userId
|
||||
}),
|
||||
logPrefix: 'Deleting group invite',
|
||||
successMessage: 'Deleted {count} group invites',
|
||||
errorMessage: 'Failed to delete group invites',
|
||||
onComplete: callbacks?.onComplete
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param callbacks
|
||||
*/
|
||||
async function groupMembersAcceptInviteRequest(callbacks) {
|
||||
await runBatchOperation({
|
||||
action: (user, groupId) =>
|
||||
deps.groupRequest.acceptGroupInviteRequest({
|
||||
groupId,
|
||||
userId: user.userId
|
||||
}),
|
||||
logPrefix: 'Accepting group join request from',
|
||||
successMessage: 'Accepted {count} group join requests',
|
||||
errorMessage: 'Failed to accept group join requests',
|
||||
onComplete: callbacks?.onComplete
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param callbacks
|
||||
*/
|
||||
async function groupMembersRejectInviteRequest(callbacks) {
|
||||
await runBatchOperation({
|
||||
action: (user, groupId) =>
|
||||
deps.groupRequest.rejectGroupInviteRequest({
|
||||
groupId,
|
||||
userId: user.userId
|
||||
}),
|
||||
logPrefix: 'Rejecting group join request from',
|
||||
successMessage: 'Rejected {count} group join requests',
|
||||
errorMessage: 'Failed to reject group join requests',
|
||||
onComplete: callbacks?.onComplete
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param callbacks
|
||||
*/
|
||||
async function groupMembersBlockJoinRequest(callbacks) {
|
||||
await runBatchOperation({
|
||||
action: (user, groupId) =>
|
||||
deps.groupRequest.blockGroupInviteRequest({
|
||||
groupId,
|
||||
userId: user.userId
|
||||
}),
|
||||
logPrefix: 'Blocking group join request from',
|
||||
successMessage: 'Blocked {count} group join requests',
|
||||
errorMessage: 'Failed to block group join requests',
|
||||
onComplete: callbacks?.onComplete
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param callbacks
|
||||
*/
|
||||
async function groupMembersDeleteBlockedRequest(callbacks) {
|
||||
await runBatchOperation({
|
||||
action: (user, groupId) =>
|
||||
deps.groupRequest.deleteBlockedGroupRequest({
|
||||
groupId,
|
||||
userId: user.userId
|
||||
}),
|
||||
logPrefix: 'Deleting blocked group request for',
|
||||
successMessage: 'Deleted {count} blocked group requests',
|
||||
errorMessage: 'Failed to delete blocked group requests',
|
||||
onComplete: callbacks?.onComplete
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
progressCurrent,
|
||||
progressTotal,
|
||||
groupMembersBan,
|
||||
groupMembersUnban,
|
||||
groupMembersKick,
|
||||
groupMembersSaveNote,
|
||||
groupMembersRemoveRoles,
|
||||
groupMembersAddRoles,
|
||||
groupMembersDeleteSentInvite,
|
||||
groupMembersAcceptInviteRequest,
|
||||
groupMembersRejectInviteRequest,
|
||||
groupMembersBlockJoinRequest,
|
||||
groupMembersDeleteBlockedRequest
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
/**
|
||||
* Composable for managing selected users across all moderation tables.
|
||||
* @param {object} tables - reactive table objects with `.data` arrays
|
||||
* @param {object} tables.members
|
||||
* @param {object} tables.bans
|
||||
* @param {object} tables.invites
|
||||
* @param {object} tables.joinRequests
|
||||
* @param {object} tables.blocked
|
||||
*/
|
||||
export function useGroupModerationSelection(tables) {
|
||||
const selectedUsers = reactive({});
|
||||
const selectedUsersArray = ref([]);
|
||||
|
||||
/**
|
||||
* @param {string} userId
|
||||
* @param {object} user
|
||||
*/
|
||||
function setSelectedUsers(userId, user) {
|
||||
if (!user) return;
|
||||
selectedUsers[userId] = user;
|
||||
selectedUsersArray.value = Object.values(selectedUsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|null} userId
|
||||
* @param {boolean} isAll
|
||||
*/
|
||||
function deselectedUsers(userId, isAll = false) {
|
||||
if (isAll) {
|
||||
for (const id in selectedUsers) {
|
||||
if (Object.prototype.hasOwnProperty.call(selectedUsers, id)) {
|
||||
delete selectedUsers[id];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Object.prototype.hasOwnProperty.call(selectedUsers, userId)) {
|
||||
delete selectedUsers[userId];
|
||||
}
|
||||
}
|
||||
selectedUsersArray.value = Object.values(selectedUsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} row
|
||||
*/
|
||||
function onSelectionChange(row) {
|
||||
if (row.$selected && !selectedUsers[row.userId]) {
|
||||
setSelectedUsers(row.userId, row);
|
||||
} else if (!row.$selected && selectedUsers[row.userId]) {
|
||||
deselectedUsers(row.userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect a user across all tables.
|
||||
* @param {string} [userId] - if omitted, deselects all rows in all tables
|
||||
*/
|
||||
function deselectInTables(userId) {
|
||||
const allTables = [
|
||||
tables.members,
|
||||
tables.bans,
|
||||
tables.invites,
|
||||
tables.joinRequests,
|
||||
tables.blocked
|
||||
];
|
||||
for (const table of allTables) {
|
||||
if (!table?.data) continue;
|
||||
if (userId) {
|
||||
const row = table.data.find((item) => item.userId === userId);
|
||||
if (row) {
|
||||
row.$selected = false;
|
||||
}
|
||||
} else {
|
||||
table.data.forEach((row) => {
|
||||
if (row.$selected) {
|
||||
row.$selected = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} user
|
||||
*/
|
||||
function deleteSelectedUser(user) {
|
||||
deselectedUsers(user.userId);
|
||||
deselectInTables(user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function clearAllSelected() {
|
||||
deselectedUsers(null, true);
|
||||
deselectInTables();
|
||||
}
|
||||
|
||||
/**
|
||||
* Select all rows in a given table data array.
|
||||
* @param {Array} tableData
|
||||
*/
|
||||
function selectAll(tableData) {
|
||||
tableData.forEach((row) => {
|
||||
row.$selected = true;
|
||||
setSelectedUsers(row.userId, row);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
selectedUsers,
|
||||
selectedUsersArray,
|
||||
setSelectedUsers,
|
||||
deselectedUsers,
|
||||
onSelectionChange,
|
||||
deselectInTables,
|
||||
deleteSelectedUser,
|
||||
clearAllSelected,
|
||||
selectAll
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export { default as AlertDescription } from './AlertDescription.vue';
|
||||
export { default as AlertTitle } from './AlertTitle.vue';
|
||||
|
||||
export const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-1 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -1915,7 +1915,14 @@
|
||||
"selected_roles": "Selected Roles",
|
||||
"remove_roles": "Remove Roles",
|
||||
"add_roles": "Add Roles",
|
||||
"export_logs": "Export Logs"
|
||||
"export_logs": "Export Logs",
|
||||
"export_bans": "Export Bans",
|
||||
"import_bans": "Import Bans",
|
||||
"import_bans_description": "Paste CSV or user IDs below. The userId column will be auto-detected from CSV headers, or all usr_ IDs will be extracted.",
|
||||
"import_bans_warning": "Do not import too many users at once. The API is rate-limited and may fail. You are responsible for any consequences.",
|
||||
"import_bans_placeholder": "Paste CSV or user IDs here (one per line)\nusr_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"import_bans_start": "Import & Ban",
|
||||
"import_bans_clear_errors": "Clear Errors"
|
||||
},
|
||||
"group_post_edit": {
|
||||
"header": "Create/Edit Post",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
generateEmojiStyle,
|
||||
getEmojiFileName,
|
||||
getPrintFileName,
|
||||
getPrintLocalDate
|
||||
@@ -311,4 +312,47 @@ describe('Gallery Utils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateEmojiStyle', () => {
|
||||
test('returns CSS with background url and animation', () => {
|
||||
const style = generateEmojiStyle(
|
||||
'https://example.com/emoji.png',
|
||||
10,
|
||||
4,
|
||||
'linear',
|
||||
100
|
||||
);
|
||||
expect(style).toContain("url('https://example.com/emoji.png')");
|
||||
expect(style).toContain('animation:');
|
||||
expect(style).toContain('steps(1)');
|
||||
});
|
||||
|
||||
test('uses 2 framesPerLine for frameCount <= 4', () => {
|
||||
const style = generateEmojiStyle('u', 10, 4, 'linear', 100);
|
||||
// frameSize = 1024/2 = 512
|
||||
expect(style).toContain('512px');
|
||||
});
|
||||
|
||||
test('uses 4 framesPerLine for frameCount 5-16', () => {
|
||||
const style = generateEmojiStyle('u', 10, 8, 'linear', 100);
|
||||
// frameSize = 1024/4 = 256
|
||||
expect(style).toContain('256px');
|
||||
});
|
||||
|
||||
test('uses 8 framesPerLine for frameCount > 16', () => {
|
||||
const style = generateEmojiStyle('u', 10, 20, 'linear', 100);
|
||||
// frameSize = 1024/8 = 128
|
||||
expect(style).toContain('128px');
|
||||
});
|
||||
|
||||
test('uses alternate for pingpong loopStyle', () => {
|
||||
const style = generateEmojiStyle('u', 10, 4, 'pingpong', 100);
|
||||
expect(style).toContain('alternate');
|
||||
});
|
||||
|
||||
test('uses none for non-pingpong loopStyle', () => {
|
||||
const style = generateEmojiStyle('u', 10, 4, 'linear', 100);
|
||||
expect(style).toMatch(/\bnone\b/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { removeFromArray } from '../array';
|
||||
import {
|
||||
removeFromArray,
|
||||
arraysMatch,
|
||||
moveArrayItem,
|
||||
replaceReactiveObject
|
||||
} from '../array';
|
||||
|
||||
describe('Array Utils', () => {
|
||||
describe('removeFromArray', () => {
|
||||
@@ -33,4 +38,91 @@ describe('Array Utils', () => {
|
||||
expect(arr).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('arraysMatch', () => {
|
||||
test('returns true for identical arrays', () => {
|
||||
expect(arraysMatch([1, 2, 3], [1, 2, 3])).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for different lengths', () => {
|
||||
expect(arraysMatch([1, 2], [1, 2, 3])).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for different content', () => {
|
||||
expect(arraysMatch([1, 2], [1, 3])).toBe(false);
|
||||
});
|
||||
|
||||
test('returns true for empty arrays', () => {
|
||||
expect(arraysMatch([], [])).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for non-array first arg', () => {
|
||||
expect(arraysMatch(null, [])).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for non-array second arg', () => {
|
||||
expect(arraysMatch([], null)).toBe(false);
|
||||
});
|
||||
|
||||
test('deep-compares objects via JSON', () => {
|
||||
expect(arraysMatch([{ a: 1 }], [{ a: 1 }])).toBe(true);
|
||||
expect(arraysMatch([{ a: 1 }], [{ a: 2 }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveArrayItem', () => {
|
||||
test('moves item forward', () => {
|
||||
const arr = ['a', 'b', 'c', 'd'];
|
||||
moveArrayItem(arr, 0, 2);
|
||||
expect(arr).toEqual(['b', 'c', 'a', 'd']);
|
||||
});
|
||||
|
||||
test('moves item backward', () => {
|
||||
const arr = ['a', 'b', 'c', 'd'];
|
||||
moveArrayItem(arr, 3, 1);
|
||||
expect(arr).toEqual(['a', 'd', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('no-ops when fromIndex equals toIndex', () => {
|
||||
const arr = [1, 2, 3];
|
||||
moveArrayItem(arr, 1, 1);
|
||||
expect(arr).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('no-ops for negative fromIndex', () => {
|
||||
const arr = [1, 2, 3];
|
||||
moveArrayItem(arr, -1, 0);
|
||||
expect(arr).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('no-ops for out-of-bounds toIndex', () => {
|
||||
const arr = [1, 2, 3];
|
||||
moveArrayItem(arr, 0, 5);
|
||||
expect(arr).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('no-ops for non-array input', () => {
|
||||
expect(() => moveArrayItem(null, 0, 1)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceReactiveObject', () => {
|
||||
test('replaces all keys from source', () => {
|
||||
const target = { a: 1, b: 2 };
|
||||
replaceReactiveObject(target, { c: 3 });
|
||||
expect(target).toEqual({ c: 3 });
|
||||
});
|
||||
|
||||
test('clears target when source is empty', () => {
|
||||
const target = { a: 1, b: 2 };
|
||||
replaceReactiveObject(target, {});
|
||||
expect(target).toEqual({});
|
||||
});
|
||||
|
||||
test('overwrites existing keys', () => {
|
||||
const target = { a: 1 };
|
||||
replaceReactiveObject(target, { a: 99, b: 2 });
|
||||
expect(target).toEqual({ a: 99, b: 2 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user