diff --git a/app/pages/admin/index.vue b/app/pages/admin/index.vue index 2bc01de..0ebad13 100644 --- a/app/pages/admin/index.vue +++ b/app/pages/admin/index.vue @@ -264,37 +264,79 @@ :key="user.id" class="border border-white/10 bg-black/40" > - -
-

{{ formatDateTime(user.createdAt) }}

-

{{ user.email }}

-

{{ user.name }}

-
- Invitations created: {{ user.invitationCodesIssued }} - Last login: {{ formatRelative(user.lastLoginAt) }} + +
+
+

{{ formatDateTime(user.createdAt) }}

+

{{ user.email }}

+

{{ user.name }}

+
+ Invitations created: {{ user.invitationCodesIssued }} + Last login: {{ formatRelative(user.lastLoginAt) }} +
+
+
+ + + Update role +
-
- +
+ - - Update role - +
+ + Save notes + + + Delete user + +
@@ -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(null) const userRoleDraft = reactive>({}) +const userNotesDraft = reactive>({}) +const userNotesSaving = ref(null) +const userDeleteLoading = ref(null) const invitations = ref([]) 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('/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(`/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 diff --git a/server/api/admin/users.get.ts b/server/api/admin/users.get.ts index eb47fa3..8ad8243 100644 --- a/server/api/admin/users.get.ts +++ b/server/api/admin/users.get.ts @@ -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 || '', } } diff --git a/server/api/admin/users/[id]/index.delete.ts b/server/api/admin/users/[id]/index.delete.ts new file mode 100644 index 0000000..4c3fce9 --- /dev/null +++ b/server/api/admin/users/[id]/index.delete.ts @@ -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 } +}) diff --git a/server/api/admin/users/[id]/notes.patch.ts b/server/api/admin/users/[id]/notes.patch.ts new file mode 100644 index 0000000..27279f4 --- /dev/null +++ b/server/api/admin/users/[id]/notes.patch.ts @@ -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(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 || '', + } +}) diff --git a/server/models/User.ts b/server/models/User.ts index 65c71c0..2651e20 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -13,6 +13,7 @@ export interface UserDocument extends mongoose.Document { invitationCodesIssued: number acceptedTermsAt: Date acceptedPrivacyAt: Date + adminNotes?: string } const userSchema = new mongoose.Schema({ @@ -26,6 +27,7 @@ const userSchema = new mongoose.Schema({ invitationCodesIssued: { type: Number, default: 0 }, acceptedTermsAt: { type: Date, required: true }, acceptedPrivacyAt: { type: Date, required: true }, + adminNotes: { type: String, trim: true, default: '' }, }) export const User =