mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-06 17:53:19 +08:00
Add admin notes and deletion tools for users
This commit is contained in:
@@ -264,37 +264,79 @@
|
||||
:key="user.id"
|
||||
class="border border-white/10 bg-black/40"
|
||||
>
|
||||
<v-card-text class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm text-white/50">{{ formatDateTime(user.createdAt) }}</p>
|
||||
<h3 class="text-lg font-semibold">{{ user.email }}</h3>
|
||||
<p v-if="user.name" class="text-sm text-white/60">{{ user.name }}</p>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-white/50">
|
||||
<span>Invitations created: {{ user.invitationCodesIssued }}</span>
|
||||
<span v-if="user.lastLoginAt">Last login: {{ formatRelative(user.lastLoginAt) }}</span>
|
||||
<v-card-text class="space-y-4">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm text-white/50">{{ formatDateTime(user.createdAt) }}</p>
|
||||
<h3 class="text-lg font-semibold">{{ user.email }}</h3>
|
||||
<p v-if="user.name" class="text-sm text-white/60">{{ user.name }}</p>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-white/50">
|
||||
<span>Invitations created: {{ user.invitationCodesIssued }}</span>
|
||||
<span v-if="user.lastLoginAt">Last login: {{ formatRelative(user.lastLoginAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2 md:w-48 md:items-end">
|
||||
<v-select
|
||||
v-model="userRoleDraft[user.id]"
|
||||
:items="userRoleEditOptions"
|
||||
label="Role"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
color="cyan"
|
||||
hide-details
|
||||
class="w-44 md:w-full"
|
||||
/>
|
||||
<v-btn
|
||||
color="cyan"
|
||||
variant="flat"
|
||||
size="small"
|
||||
:loading="userRoleUpdating === user.id"
|
||||
:disabled="userRoleDraft[user.id] === user.role"
|
||||
@click="applyRoleChange(user.id)"
|
||||
>
|
||||
Update role
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-2 md:items-end">
|
||||
<v-select
|
||||
v-model="userRoleDraft[user.id]"
|
||||
:items="userRoleEditOptions"
|
||||
label="Role"
|
||||
density="comfortable"
|
||||
<v-divider class="border-white/10" />
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<v-textarea
|
||||
v-model="userNotesDraft[user.id]"
|
||||
label="Internal notes"
|
||||
auto-grow
|
||||
rows="2"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
color="cyan"
|
||||
hide-details
|
||||
class="w-44"
|
||||
:counter="2000"
|
||||
maxlength="2000"
|
||||
hide-details="auto"
|
||||
class="md:flex-1"
|
||||
/>
|
||||
<v-btn
|
||||
color="cyan"
|
||||
variant="flat"
|
||||
size="small"
|
||||
:loading="userRoleUpdating === user.id"
|
||||
:disabled="userRoleDraft[user.id] === user.role"
|
||||
@click="applyRoleChange(user.id)"
|
||||
>
|
||||
Update role
|
||||
</v-btn>
|
||||
<div class="flex flex-col gap-2 md:w-48">
|
||||
<v-btn
|
||||
color="cyan"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-content-save-outline"
|
||||
:loading="userNotesSaving === user.id"
|
||||
:disabled="(userNotesDraft[user.id] || '') === (user.adminNotes || '')"
|
||||
@click="saveUserNotes(user.id)"
|
||||
>
|
||||
Save notes
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="user.role === 'user'"
|
||||
color="red"
|
||||
variant="text"
|
||||
size="small"
|
||||
prepend-icon="mdi-delete-outline"
|
||||
:loading="userDeleteLoading === user.id"
|
||||
@click="deleteUserAccount(user)"
|
||||
>
|
||||
Delete user
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -1224,6 +1266,7 @@ interface OverviewData {
|
||||
|
||||
interface AdminUser extends OverviewUser {
|
||||
invitationCodesIssued: number
|
||||
adminNotes?: string
|
||||
}
|
||||
|
||||
interface UsersResponse {
|
||||
@@ -1232,6 +1275,11 @@ interface UsersResponse {
|
||||
roles: { user: number; admin: number; dev: number }
|
||||
}
|
||||
|
||||
interface UpdateNotesResponse {
|
||||
success: boolean
|
||||
notes: string
|
||||
}
|
||||
|
||||
interface InvitationItem extends OverviewInvitation {
|
||||
createdBy?: { id: string; email: string; name?: string; role: string }
|
||||
usedBy?: { id: string; email: string; name?: string; role: string }
|
||||
@@ -1424,6 +1472,9 @@ const userRoleEditOptions = [
|
||||
]
|
||||
const userRoleUpdating = ref<string | null>(null)
|
||||
const userRoleDraft = reactive<Record<string, 'user' | 'admin' | 'dev'>>({})
|
||||
const userNotesDraft = reactive<Record<string, string>>({})
|
||||
const userNotesSaving = ref<string | null>(null)
|
||||
const userDeleteLoading = ref<string | null>(null)
|
||||
|
||||
const invitations = ref<InvitationItem[]>([])
|
||||
const invitationPagination = reactive({ total: 0, page: 1, pages: 1, pageSize: 12 })
|
||||
@@ -1699,8 +1750,15 @@ async function fetchUsers(resetPage = false) {
|
||||
if (userRoleFilter.value !== 'all') query.role = userRoleFilter.value
|
||||
const response = await api.get<UsersResponse>('/api/admin/users', { query })
|
||||
users.value = response.items
|
||||
const itemIds = new Set(response.items.map((user) => user.id))
|
||||
Object.keys(userNotesDraft).forEach((key) => {
|
||||
if (!itemIds.has(key)) {
|
||||
delete userNotesDraft[key]
|
||||
}
|
||||
})
|
||||
response.items.forEach((user) => {
|
||||
userRoleDraft[user.id] = user.role as 'user' | 'admin' | 'dev'
|
||||
userNotesDraft[user.id] = user.adminNotes ?? ''
|
||||
})
|
||||
Object.assign(userPagination, response.pagination)
|
||||
Object.assign(userRoleStats, response.roles)
|
||||
@@ -1736,6 +1794,47 @@ async function applyRoleChange(userId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUserNotes(userId: string) {
|
||||
userNotesSaving.value = userId
|
||||
try {
|
||||
const notes = userNotesDraft[userId] ?? ''
|
||||
const response = await api.request<UpdateNotesResponse>(`/api/admin/users/${userId}/notes`, {
|
||||
method: 'PATCH',
|
||||
body: { notes },
|
||||
})
|
||||
const target = users.value.find((u) => u.id === userId)
|
||||
if (target) {
|
||||
target.adminNotes = response.notes
|
||||
}
|
||||
userNotesDraft[userId] = response.notes
|
||||
} catch (error) {
|
||||
userError.value = extractErrorMessage(error, 'Could not save notes.')
|
||||
} finally {
|
||||
userNotesSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUserAccount(user: AdminUser) {
|
||||
if (typeof window !== 'undefined') {
|
||||
const confirmed = window.confirm(`Delete user ${user.email}? This action cannot be undone.`)
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
userDeleteLoading.value = user.id
|
||||
try {
|
||||
await api.request(`/api/admin/users/${user.id}`, { method: 'DELETE' })
|
||||
const shouldMoveBack = users.value.length === 1 && userPagination.page > 1
|
||||
if (shouldMoveBack) {
|
||||
userPagination.page -= 1
|
||||
}
|
||||
await fetchUsers()
|
||||
} catch (error) {
|
||||
userError.value = extractErrorMessage(error, 'Could not delete user.')
|
||||
} finally {
|
||||
userDeleteLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInvitations(resetPage = false) {
|
||||
if (resetPage) {
|
||||
invitationPagination.page = 1
|
||||
|
||||
@@ -11,6 +11,7 @@ type UserListItem = {
|
||||
createdAt: string
|
||||
lastLoginAt?: string
|
||||
invitationCodesIssued: number
|
||||
adminNotes?: string
|
||||
}
|
||||
|
||||
function mapUser(doc: any): UserListItem {
|
||||
@@ -22,6 +23,7 @@ function mapUser(doc: any): UserListItem {
|
||||
createdAt: doc.createdAt ? new Date(doc.createdAt).toISOString() : new Date().toISOString(),
|
||||
lastLoginAt: doc.lastLoginAt ? new Date(doc.lastLoginAt).toISOString() : undefined,
|
||||
invitationCodesIssued: doc.invitationCodesIssued || 0,
|
||||
adminNotes: doc.adminNotes || '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
40
server/api/admin/users/[id]/index.delete.ts
Normal file
40
server/api/admin/users/[id]/index.delete.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createError, defineEventHandler } from 'h3'
|
||||
import { requireAdmin } from '../../../../utils/auth'
|
||||
import { User } from '../../../../models/User'
|
||||
import { LearnProfile } from '../../../../models/LearnProfile'
|
||||
import { BridgeToken } from '../../../../models/BridgeToken'
|
||||
import { PasswordResetToken } from '../../../../models/PasswordResetToken'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const admin = await requireAdmin(event)
|
||||
|
||||
const params = event.context.params as { id?: string }
|
||||
const userId = params?.id
|
||||
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing user ID' })
|
||||
}
|
||||
|
||||
if (String(admin._id) === userId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'You cannot delete your own account.' })
|
||||
}
|
||||
|
||||
const user = await User.findById(userId)
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'User not found' })
|
||||
}
|
||||
|
||||
if (user.role !== 'user') {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Only regular user accounts can be deleted.' })
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
LearnProfile.deleteOne({ user: user._id }).catch(() => null),
|
||||
BridgeToken.deleteMany({ user: user._id }).catch(() => null),
|
||||
PasswordResetToken.deleteMany({ user: user._id }).catch(() => null),
|
||||
])
|
||||
|
||||
await user.deleteOne()
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
39
server/api/admin/users/[id]/notes.patch.ts
Normal file
39
server/api/admin/users/[id]/notes.patch.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createError, defineEventHandler, readBody } from 'h3'
|
||||
import { requireAdmin } from '../../../../utils/auth'
|
||||
import { User } from '../../../../models/User'
|
||||
|
||||
interface UpdateNotesBody {
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdmin(event)
|
||||
|
||||
const params = event.context.params as { id?: string }
|
||||
const userId = params?.id
|
||||
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing user ID' })
|
||||
}
|
||||
|
||||
const body = await readBody<UpdateNotesBody>(event).catch(() => ({}))
|
||||
const rawNotes = typeof body.notes === 'string' ? body.notes.replace(/\r\n/g, '\n') : ''
|
||||
const notes = rawNotes.trim()
|
||||
|
||||
if (notes.length > 2000) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Notes are too long (max 2000 characters).' })
|
||||
}
|
||||
|
||||
const user = await User.findById(userId)
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'User not found' })
|
||||
}
|
||||
|
||||
user.adminNotes = notes
|
||||
await user.save()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
notes: user.adminNotes || '',
|
||||
}
|
||||
})
|
||||
@@ -13,6 +13,7 @@ export interface UserDocument extends mongoose.Document {
|
||||
invitationCodesIssued: number
|
||||
acceptedTermsAt: Date
|
||||
acceptedPrivacyAt: Date
|
||||
adminNotes?: string
|
||||
}
|
||||
|
||||
const userSchema = new mongoose.Schema<UserDocument>({
|
||||
@@ -26,6 +27,7 @@ const userSchema = new mongoose.Schema<UserDocument>({
|
||||
invitationCodesIssued: { type: Number, default: 0 },
|
||||
acceptedTermsAt: { type: Date, required: true },
|
||||
acceptedPrivacyAt: { type: Date, required: true },
|
||||
adminNotes: { type: String, trim: true, default: '' },
|
||||
})
|
||||
|
||||
export const User =
|
||||
|
||||
Reference in New Issue
Block a user