feat: allow reassigning guests to actual users

This commit is contained in:
2026-04-01 09:03:06 +03:00
parent a1d2791d1c
commit ca109dc7fc
13 changed files with 1458 additions and 5 deletions

View File

@@ -14,6 +14,7 @@ use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Service\AdminSettingsService;
use OCA\Forum\Service\GuestService;
use OCA\Forum\Service\UserRoleService;
use OCA\Forum\Service\UserService;
use OCP\AppFramework\Http;
@@ -41,6 +42,7 @@ class AdminController extends OCSController {
private IUserManager $userManager,
private IUserSession $userSession,
private AdminSettingsService $settingsService,
private GuestService $guestService,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
@@ -380,4 +382,58 @@ class AdminController extends OCSController {
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Reassign all posts and threads from a guest to a registered user
*
* @param string $guestAuthorId The guest author ID (format: "guest:<token>")
* @param string $targetUserId The target Nextcloud user ID to assign posts to
* @return DataResponse<Http::STATUS_OK, array{success: bool, postsReassigned: int, threadsReassigned: int}, array{}>
*
* 200: Posts reassigned successfully
*/
#[NoAdminRequired]
#[RequirePermission('canManageUsers')]
#[ApiRoute(verb: 'POST', url: '/api/admin/guests/reassign')]
public function reassignGuestPosts(string $guestAuthorId, string $targetUserId): DataResponse {
try {
// Validate guest author ID format
if (!GuestService::isGuestAuthor($guestAuthorId)) {
return new DataResponse(['error' => 'Invalid guest author ID format'], Http::STATUS_BAD_REQUEST);
}
// Validate target user exists
$targetUser = $this->userManager->get($targetUserId);
if ($targetUser === null) {
return new DataResponse(['error' => 'Target user does not exist'], Http::STATUS_NOT_FOUND);
}
// Count posts before reassignment for forum user stats
$postCounts = $this->postMapper->countByAuthorId($guestAuthorId);
// Reassign posts, threads, and last_reply_author_id references
$postsReassigned = $this->postMapper->reassignAuthor($guestAuthorId, $targetUserId);
$threadsReassigned = $this->threadMapper->reassignAuthor($guestAuthorId, $targetUserId);
$this->threadMapper->reassignLastReplyAuthor($guestAuthorId, $targetUserId);
// Update the target user's forum user stats
if ($postCounts['replies'] > 0) {
$this->forumUserMapper->incrementPostCount($targetUserId, $postCounts['replies']);
}
if ($postCounts['threads'] > 0) {
$this->forumUserMapper->incrementThreadCount($targetUserId, $postCounts['threads']);
}
$this->logger->info("Reassigned {$postsReassigned} posts and {$threadsReassigned} threads from guest '{$guestAuthorId}' to user '{$targetUserId}'");
return new DataResponse([
'success' => true,
'postsReassigned' => $postsReassigned,
'threadsReassigned' => $threadsReassigned,
]);
} catch (\Exception $e) {
$this->logger->error('Error reassigning guest posts: ' . $e->getMessage());
return new DataResponse(['error' => 'Failed to reassign guest posts'], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -482,6 +482,64 @@ class PostMapper extends QBMapper {
return $count;
}
/**
* Reassign all posts from one author to another
*
* @param string $fromAuthorId Current author ID (e.g., "guest:abc123")
* @param string $toAuthorId New author ID (e.g., "john")
* @return int Number of posts updated
*/
public function reassignAuthor(string $fromAuthorId, string $toAuthorId): int {
$qb = $this->db->getQueryBuilder();
$qb->update($this->getTableName())
->set('author_id', $qb->createNamedParameter($toAuthorId, IQueryBuilder::PARAM_STR))
->set('updated_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq('author_id', $qb->createNamedParameter($fromAuthorId, IQueryBuilder::PARAM_STR)));
return $qb->executeStatement();
}
/**
* Count posts by author (including deleted)
*/
public function countByAuthorId(string $authorId): array {
$qb = $this->db->getQueryBuilder();
$qb->select(
$qb->func()->count('*', 'total'),
)
->from($this->getTableName())
->where($qb->expr()->eq('author_id', $qb->createNamedParameter($authorId, IQueryBuilder::PARAM_STR)));
$result = $qb->executeQuery();
$row = $result->fetch();
$result->closeCursor();
// Count first posts (threads) vs replies separately
$qb2 = $this->db->getQueryBuilder();
$qb2->select($qb2->func()->count('*', 'count'))
->from($this->getTableName())
->where($qb2->expr()->eq('author_id', $qb2->createNamedParameter($authorId, IQueryBuilder::PARAM_STR)))
->andWhere($qb2->expr()->eq('is_first_post', $qb2->createNamedParameter(true, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb2->expr()->isNull('deleted_at'));
$result2 = $qb2->executeQuery();
$row2 = $result2->fetch();
$result2->closeCursor();
$qb3 = $this->db->getQueryBuilder();
$qb3->select($qb3->func()->count('*', 'count'))
->from($this->getTableName())
->where($qb3->expr()->eq('author_id', $qb3->createNamedParameter($authorId, IQueryBuilder::PARAM_STR)))
->andWhere($qb3->expr()->eq('is_first_post', $qb3->createNamedParameter(false, IQueryBuilder::PARAM_BOOL)))
->andWhere($qb3->expr()->isNull('deleted_at'));
$result3 = $qb3->executeQuery();
$row3 = $result3->fetch();
$result3->closeCursor();
return [
'total' => (int)($row['total'] ?? 0),
'threads' => (int)($row2['count'] ?? 0),
'replies' => (int)($row3['count'] ?? 0),
];
}
/**
* Find all posts for a thread, including deleted posts
*

View File

@@ -359,6 +359,37 @@ class ThreadMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Reassign all threads from one author to another
*
* @param string $fromAuthorId Current author ID (e.g., "guest:abc123")
* @param string $toAuthorId New author ID (e.g., "john")
* @return int Number of threads updated
*/
public function reassignAuthor(string $fromAuthorId, string $toAuthorId): int {
$qb = $this->db->getQueryBuilder();
$qb->update($this->getTableName())
->set('author_id', $qb->createNamedParameter($toAuthorId, IQueryBuilder::PARAM_STR))
->set('updated_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq('author_id', $qb->createNamedParameter($fromAuthorId, IQueryBuilder::PARAM_STR)));
return $qb->executeStatement();
}
/**
* Reassign last_reply_author_id from one author to another
*
* @param string $fromAuthorId Current author ID
* @param string $toAuthorId New author ID
* @return int Number of threads updated
*/
public function reassignLastReplyAuthor(string $fromAuthorId, string $toAuthorId): int {
$qb = $this->db->getQueryBuilder();
$qb->update($this->getTableName())
->set('last_reply_author_id', $qb->createNamedParameter($toAuthorId, IQueryBuilder::PARAM_STR))
->where($qb->expr()->eq('last_reply_author_id', $qb->createNamedParameter($fromAuthorId, IQueryBuilder::PARAM_STR)));
return $qb->executeStatement();
}
/**
* Find a thread by ID including soft-deleted threads
*

View File

@@ -825,6 +825,137 @@
}
}
},
"/ocs/v2.php/apps/forum/api/admin/guests/reassign": {
"post": {
"operationId": "admin-reassign-guest-posts",
"summary": "Reassign all posts and threads from a guest to a registered user",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"guestAuthorId",
"targetUserId"
],
"properties": {
"guestAuthorId": {
"type": "string",
"description": "The guest author ID (format: \"guest:<token>\")"
},
"targetUserId": {
"type": "string",
"description": "The target Nextcloud user ID to assign posts to"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Posts reassigned successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"success",
"postsReassigned",
"threadsReassigned"
],
"properties": {
"success": {
"type": "boolean"
},
"postsReassigned": {
"type": "integer",
"format": "int64"
},
"threadsReassigned": {
"type": "integer",
"format": "int64"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/bbcodes": {
"get": {
"operationId": "bb_code-index",

View File

@@ -825,6 +825,137 @@
}
}
},
"/ocs/v2.php/apps/forum/api/admin/guests/reassign": {
"post": {
"operationId": "admin-reassign-guest-posts",
"summary": "Reassign all posts and threads from a guest to a registered user",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"guestAuthorId",
"targetUserId"
],
"properties": {
"guestAuthorId": {
"type": "string",
"description": "The guest author ID (format: \"guest:<token>\")"
},
"targetUserId": {
"type": "string",
"description": "The target Nextcloud user ID to assign posts to"
}
}
}
}
}
},
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Posts reassigned successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"success",
"postsReassigned",
"threadsReassigned"
],
"properties": {
"success": {
"type": "boolean"
},
"postsReassigned": {
"type": "integer",
"format": "int64"
},
"threadsReassigned": {
"type": "integer",
"format": "int64"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/bbcodes": {
"get": {
"operationId": "bb_code-index",

View File

@@ -0,0 +1,253 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createComponentMock } from '@/test-utils'
// Mock axios
vi.mock('@/axios', () => ({
ocs: {
get: vi.fn(),
post: vi.fn(),
},
}))
vi.mock('@nextcloud/dialogs', () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
}))
// Mock NcAvatar
vi.mock('@nextcloud/vue/components/NcAvatar', () =>
createComponentMock('NcAvatar', {
template: '<span class="nc-avatar-mock" :data-user="user" />',
props: ['user', 'size', 'showUserStatus'],
}),
)
// Import after mocks
import { ocs } from '@/axios'
import { showSuccess } from '@nextcloud/dialogs'
import GuestReassignDialog from './GuestReassignDialog.vue'
const mockGet = vi.mocked(ocs.get)
const mockPost = vi.mocked(ocs.post)
describe('GuestReassignDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGet.mockResolvedValue({ data: [] } as never)
mockPost.mockResolvedValue({
data: { success: true, postsReassigned: 3, threadsReassigned: 1 },
} as never)
})
const createWrapper = (props = {}) => {
return mount(GuestReassignDialog, {
props: {
open: true,
guestAuthorId: 'guest:abcdef1234567890abcdef1234567890',
guestDisplayName: 'BrightMountain42',
...props,
},
})
}
describe('rendering', () => {
it('renders dialog when open', () => {
const wrapper = createWrapper()
expect(wrapper.find('.nc-dialog').exists()).toBe(true)
})
it('does not render dialog when closed', () => {
const wrapper = createWrapper({ open: false })
expect(wrapper.find('.nc-dialog').exists()).toBe(false)
})
it('shows description text', () => {
const wrapper = createWrapper()
expect(wrapper.find('.description').exists()).toBe(true)
expect(wrapper.text()).toContain('All posts and threads by this guest will be reassigned')
})
it('shows user search input', () => {
const wrapper = createWrapper()
expect(wrapper.find('.user-search').exists()).toBe(true)
})
})
describe('user search', () => {
it('calls autocomplete API when searching', async () => {
vi.useFakeTimers()
const users = [
{ id: 'alice', label: 'Alice Smith' },
{ id: 'bob', label: 'Bob Jones' },
]
mockGet.mockResolvedValue({ data: users } as never)
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.handleSearch('ali')
vi.advanceTimersByTime(300)
await flushPromises()
expect(mockGet).toHaveBeenCalledWith('/users/autocomplete', {
params: { search: 'ali', limit: 10 },
})
vi.useRealTimers()
})
it('does not call API for empty search', async () => {
vi.useFakeTimers()
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.handleSearch('')
vi.advanceTimersByTime(300)
await flushPromises()
expect(mockGet).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('debounces search calls', async () => {
vi.useFakeTimers()
mockGet.mockResolvedValue({ data: [] } as never)
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.handleSearch('a')
vm.handleSearch('al')
vm.handleSearch('ali')
vi.advanceTimersByTime(300)
await flushPromises()
expect(mockGet).toHaveBeenCalledTimes(1)
expect(mockGet).toHaveBeenCalledWith('/users/autocomplete', {
params: { search: 'ali', limit: 10 },
})
vi.useRealTimers()
})
})
describe('confirm action', () => {
it('calls reassign API with correct parameters', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
// Simulate selecting a user
vm.selectedUser = { id: 'alice', label: 'Alice Smith' }
await wrapper.vm.$nextTick()
await vm.handleConfirm()
await flushPromises()
expect(mockPost).toHaveBeenCalledWith('/admin/guests/reassign', {
guestAuthorId: 'guest:abcdef1234567890abcdef1234567890',
targetUserId: 'alice',
})
})
it('shows success message after reassignment', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.selectedUser = { id: 'alice', label: 'Alice Smith' }
await vm.handleConfirm()
await flushPromises()
expect(showSuccess).toHaveBeenCalledWith('Guest posts reassigned successfully')
})
it('emits reassigned event on success', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.selectedUser = { id: 'alice', label: 'Alice Smith' }
await vm.handleConfirm()
await flushPromises()
expect(wrapper.emitted('reassigned')).toBeTruthy()
expect(wrapper.emitted('reassigned')![0]).toEqual([
{
guestAuthorId: 'guest:abcdef1234567890abcdef1234567890',
targetUserId: 'alice',
targetDisplayName: 'Alice Smith',
},
])
})
it('emits update:open false on success', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.selectedUser = { id: 'alice', label: 'Alice Smith' }
await vm.handleConfirm()
await flushPromises()
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
it('does not call API when no user is selected', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.selectedUser = null
await vm.handleConfirm()
await flushPromises()
expect(mockPost).not.toHaveBeenCalled()
})
it('shows error message on API failure', async () => {
mockPost.mockRejectedValue({
response: { data: { error: 'Target user does not exist' } },
})
vi.spyOn(console, 'error').mockImplementation(() => {})
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.selectedUser = { id: 'nonexistent', label: 'Nobody' }
await vm.handleConfirm()
await flushPromises()
expect(wrapper.find('.error-message').exists()).toBe(true)
expect(wrapper.find('.error-message').text()).toBe('Target user does not exist')
})
})
describe('reset on open', () => {
it('resets state when dialog reopens', async () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
// Set some state
vm.selectedUser = { id: 'alice', label: 'Alice' }
vm.error = 'Some error'
// Close and reopen
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
expect(vm.selectedUser).toBeNull()
expect(vm.error).toBeNull()
})
})
describe('close event', () => {
it('emits update:open false when handleClose is called', () => {
const wrapper = createWrapper()
const vm = wrapper.vm as InstanceType<typeof GuestReassignDialog>
vm.handleClose()
expect(wrapper.emitted('update:open')).toBeTruthy()
expect(wrapper.emitted('update:open')![0]).toEqual([false])
})
})
})

View File

@@ -0,0 +1,234 @@
<template>
<NcDialog :name="strings.title" :open="open" size="normal" @update:open="handleClose">
<div class="guest-reassign-dialog">
<p class="description">
{{ strings.description }}
</p>
<div class="user-search">
<NcSelect
v-model="selectedUser"
:options="userOptions"
:placeholder="strings.searchPlaceholder"
:input-label="strings.searchLabel"
:loading="searching"
:filterable="false"
label="label"
@search="handleSearch"
>
<template #option="option">
<div class="user-option">
<NcAvatar :user="option.id" :size="24" :show-user-status="false" />
<span class="user-option-label">{{ option.label }}</span>
<span class="user-option-id muted">@{{ option.id }}</span>
</div>
</template>
<template #selected-option="option">
<div class="user-option">
<NcAvatar :user="option.id" :size="20" :show-user-status="false" />
<span class="user-option-label">{{ option.label }}</span>
</div>
</template>
<template #no-options>
{{ searchQuery ? strings.noResults : strings.typeToSearch }}
</template>
</NcSelect>
</div>
<!-- Error message -->
<p v-if="error" class="error-message">{{ error }}</p>
</div>
<template #actions>
<NcButton @click="handleClose">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="!selectedUser || submitting" @click="handleConfirm">
{{ submitting ? strings.reassigning : strings.confirm }}
</NcButton>
</template>
</NcDialog>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import { showSuccess, showError } from '@nextcloud/dialogs'
interface UserOption {
id: string
label: string
}
export default defineComponent({
name: 'GuestReassignDialog',
components: {
NcDialog,
NcButton,
NcSelect,
NcAvatar,
},
props: {
open: {
type: Boolean,
default: false,
},
guestAuthorId: {
type: String,
default: '',
},
guestDisplayName: {
type: String,
default: '',
},
},
emits: ['update:open', 'reassigned'],
data() {
return {
selectedUser: null as UserOption | null,
userOptions: [] as UserOption[],
searching: false,
submitting: false,
error: null as string | null,
searchQuery: '',
searchTimeout: null as ReturnType<typeof setTimeout> | null,
strings: {
title: t('forum', 'Assign guest posts to account'),
description: t(
'forum',
'All posts and threads by this guest will be reassigned to the selected account.',
),
searchPlaceholder: t('forum', 'Search for an account …'),
searchLabel: t('forum', 'Account'),
noResults: t('forum', 'No accounts found'),
typeToSearch: t('forum', 'Type to search for an account'),
cancel: t('forum', 'Cancel'),
confirm: t('forum', 'Reassign'),
reassigning: t('forum', 'Reassigning …'),
successMessage: t('forum', 'Guest posts reassigned successfully'),
errorMessage: t('forum', 'Failed to reassign guest posts'),
},
}
},
watch: {
open(newVal: boolean) {
if (newVal) {
this.reset()
}
},
},
methods: {
reset() {
this.selectedUser = null
this.userOptions = []
this.error = null
this.searchQuery = ''
},
handleClose() {
this.$emit('update:open', false)
},
handleSearch(query: string) {
this.searchQuery = query
if (this.searchTimeout) {
clearTimeout(this.searchTimeout)
}
if (!query || query.length < 1) {
this.userOptions = []
return
}
this.searchTimeout = setTimeout(() => {
this.fetchUsers(query)
}, 300)
},
async fetchUsers(query: string) {
try {
this.searching = true
const response = await ocs.get<Array<{ id: string; label: string }>>(
'/users/autocomplete',
{
params: { search: query, limit: 10 },
},
)
this.userOptions = (response.data || []).map((u) => ({
id: u.id,
label: u.label,
}))
} catch (e) {
console.error('Error searching users:', e)
this.userOptions = []
} finally {
this.searching = false
}
},
async handleConfirm() {
if (!this.selectedUser || !this.guestAuthorId) {
return
}
try {
this.submitting = true
this.error = null
await ocs.post('/admin/guests/reassign', {
guestAuthorId: this.guestAuthorId,
targetUserId: this.selectedUser.id,
})
showSuccess(this.strings.successMessage)
this.$emit('reassigned', {
guestAuthorId: this.guestAuthorId,
targetUserId: this.selectedUser.id,
targetDisplayName: this.selectedUser.label,
})
this.handleClose()
} catch (e) {
console.error('Error reassigning guest posts:', e)
const errorData = (e as any)?.response?.data
this.error = errorData?.error || this.strings.errorMessage
} finally {
this.submitting = false
}
},
},
})
</script>
<style scoped lang="scss">
.guest-reassign-dialog {
padding: 8px 0;
.description {
margin-bottom: 16px;
color: var(--color-text-maxcontrast);
}
.user-search {
width: 100%;
}
.user-option {
display: flex;
align-items: center;
gap: 8px;
}
.user-option-id {
font-size: 0.85rem;
}
.error-message {
margin-top: 12px;
color: var(--color-error);
font-size: 0.9rem;
}
}
</style>

View File

@@ -0,0 +1,2 @@
import GuestReassignDialog from './GuestReassignDialog.vue'
export default GuestReassignDialog

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockPost, createMockUser } from '@/test-mocks'
import { createMockPost, createMockRole, createMockUser } from '@/test-mocks'
import { useUserRole } from '@/composables/useUserRole'
import PostCard from './PostCard.vue'
// Mock icons
@@ -10,6 +11,7 @@ vi.mock('@icons/Pencil.vue', () => createIconMock('PencilIcon'))
vi.mock('@icons/Delete.vue', () => createIconMock('DeleteIcon'))
vi.mock('@icons/History.vue', () => createIconMock('HistoryIcon'))
vi.mock('@icons/LinkVariant.vue', () => createIconMock('LinkVariantIcon'))
vi.mock('@icons/AccountConvert.vue', () => createIconMock('AccountConvertIcon'))
// Mock components
vi.mock('@/components/UserInfo', () =>
@@ -40,6 +42,14 @@ vi.mock('@/components/PostHistoryDialog', () =>
}),
)
vi.mock('@/components/GuestReassignDialog', () =>
createComponentMock('GuestReassignDialog', {
template: '<div class="guest-reassign-dialog-mock" v-if="open" />',
props: ['open', 'guestAuthorId', 'guestDisplayName'],
emits: ['update:open', 'reassigned'],
}),
)
vi.mock('@nextcloud/dialogs', () => ({
showSuccess: vi.fn(),
showError: vi.fn(),
@@ -72,6 +82,8 @@ describe('PostCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCurrentUser.mockReturnValue({ uid: 'testuser', displayName: 'Test User' })
const { clear } = useUserRole()
clear()
})
describe('rendering', () => {
@@ -502,4 +514,99 @@ describe('PostCard', () => {
expect(buttons.some((b) => b.text().includes('Delete'))).toBe(false)
})
})
describe('guest reassignment', () => {
it('should show assign to account button for guest posts when user has canManageUsers', () => {
const { setRoles } = useUserRole()
setRoles('testuser', [createMockRole({ canManageUsers: true })])
const guestAuthor = createMockUser({
userId: 'guest:abc123',
displayName: 'BrightMountain42',
isGuest: true,
})
const post = createMockPost({ authorId: 'guest:abc123', author: guestAuthor })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Assign to account'))).toBe(true)
})
it('should not show assign to account button for non-guest posts', () => {
const { setRoles } = useUserRole()
setRoles('testuser', [createMockRole({ canManageUsers: true })])
const author = createMockUser({ userId: 'alice', isGuest: false })
const post = createMockPost({ authorId: 'alice', author })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Assign to account'))).toBe(false)
})
it('should not show assign to account button when user lacks canManageUsers', () => {
const { setRoles } = useUserRole()
setRoles('testuser', [createMockRole({ canManageUsers: false })])
const guestAuthor = createMockUser({ userId: 'guest:abc123', isGuest: true })
const post = createMockPost({ authorId: 'guest:abc123', author: guestAuthor })
const wrapper = mount(PostCard, {
props: { post },
})
const buttons = wrapper.findAll('.nc-action-button')
expect(buttons.some((b) => b.text().includes('Assign to account'))).toBe(false)
})
it('should open reassign dialog when button is clicked', async () => {
const { setRoles } = useUserRole()
setRoles('testuser', [createMockRole({ canManageUsers: true })])
const guestAuthor = createMockUser({ userId: 'guest:abc123', isGuest: true })
const post = createMockPost({ authorId: 'guest:abc123', author: guestAuthor })
const wrapper = mount(PostCard, {
props: { post },
})
expect(wrapper.find('.guest-reassign-dialog-mock').exists()).toBe(false)
const button = wrapper
.findAll('.nc-action-button')
.find((b) => b.text().includes('Assign to account'))
await button?.trigger('click')
expect(wrapper.find('.guest-reassign-dialog-mock').exists()).toBe(true)
})
it('should emit reassigned event from dialog', async () => {
const { setRoles } = useUserRole()
setRoles('testuser', [createMockRole({ canManageUsers: true })])
const guestAuthor = createMockUser({ userId: 'guest:abc123', isGuest: true })
const post = createMockPost({ authorId: 'guest:abc123', author: guestAuthor })
const wrapper = mount(PostCard, {
props: { post },
})
const vm = wrapper.vm as InstanceType<typeof PostCard>
vm.handleReassigned({
guestAuthorId: 'guest:abc123',
targetUserId: 'alice',
targetDisplayName: 'Alice',
})
expect(wrapper.emitted('reassigned')).toBeTruthy()
expect(wrapper.emitted('reassigned')![0]).toEqual([
{
guestAuthorId: 'guest:abc123',
targetUserId: 'alice',
targetDisplayName: 'Alice',
},
])
})
})
})

View File

@@ -54,6 +54,12 @@
</template>
{{ strings.directLink }}
</NcActionButton>
<NcActionButton v-if="canReassignGuest" @click="handleReassignGuest">
<template #icon>
<AccountConvertIcon :size="20" />
</template>
{{ strings.assignToAccount }}
</NcActionButton>
</NcActions>
</div>
</div>
@@ -91,6 +97,15 @@
:post-id="post.id"
@update:open="showHistoryDialog = $event"
/>
<!-- Guest Reassign Dialog -->
<GuestReassignDialog
:open="showReassignDialog"
:guest-author-id="post.authorId"
:guest-display-name="post.author?.displayName || ''"
@update:open="showReassignDialog = $event"
@reassigned="handleReassigned"
/>
</div>
</template>
@@ -104,14 +119,17 @@ import PencilIcon from '@icons/Pencil.vue'
import DeleteIcon from '@icons/Delete.vue'
import HistoryIcon from '@icons/History.vue'
import LinkVariantIcon from '@icons/LinkVariant.vue'
import AccountConvertIcon from '@icons/AccountConvert.vue'
import UserInfo from '@/components/UserInfo'
import PostReactions from '@/components/PostReactions'
import PostEditForm from '@/components/PostEditForm'
import PostHistoryDialog from '@/components/PostHistoryDialog'
import GuestReassignDialog from '@/components/GuestReassignDialog'
import { t } from '@nextcloud/l10n'
import { getCurrentUser } from '@nextcloud/auth'
import { generateUrl } from '@nextcloud/router'
import { showSuccess } from '@nextcloud/dialogs'
import { useUserRole } from '@/composables/useUserRole'
import type { Post } from '@/types'
import type { ReactionGroup } from '@/composables/useReactions'
@@ -126,10 +144,12 @@ export default defineComponent({
DeleteIcon,
HistoryIcon,
LinkVariantIcon,
AccountConvertIcon,
UserInfo,
PostReactions,
PostEditForm,
PostHistoryDialog,
GuestReassignDialog,
},
props: {
post: {
@@ -157,14 +177,16 @@ export default defineComponent({
default: 1,
},
},
emits: ['reply', 'edit', 'delete', 'update'],
emits: ['reply', 'edit', 'delete', 'update', 'reassigned'],
setup() {
return {}
const { canManageUsers } = useUserRole()
return { canManageUsers }
},
data() {
return {
isEditing: false,
showHistoryDialog: false,
showReassignDialog: false,
strings: {
edited: t('forum', 'Edited'),
reply: t('forum', 'Quote reply'),
@@ -178,6 +200,7 @@ export default defineComponent({
unread: t('forum', 'Unread'),
directLink: t('forum', 'Direct link'),
directLinkCopied: t('forum', 'Direct link copied to clipboard'),
assignToAccount: t('forum', 'Assign to account'),
},
}
},
@@ -209,6 +232,9 @@ export default defineComponent({
hasSignature(): boolean {
return !!this.post.author?.signature
},
canReassignGuest(): boolean {
return this.canManageUsers && !!this.post.author?.isGuest
},
},
methods: {
closeActionsMenu() {
@@ -245,6 +271,19 @@ export default defineComponent({
this.showHistoryDialog = true
},
handleReassignGuest() {
this.closeActionsMenu()
this.showReassignDialog = true
},
handleReassigned(data: {
guestAuthorId: string
targetUserId: string
targetDisplayName: string
}) {
this.$emit('reassigned', data)
},
async handleDirectLink() {
this.closeActionsMenu()

View File

@@ -209,6 +209,7 @@
@reply="handleReply"
@update="handleUpdate"
@delete="handleDelete"
@reassigned="handleReassigned"
/>
</section>
@@ -246,6 +247,7 @@
@reply="handleReply"
@update="handleUpdate"
@delete="handleDelete"
@reassigned="handleReassigned"
/>
</div>
@@ -874,6 +876,60 @@ export default defineComponent({
}
},
async handleReassigned(data: {
guestAuthorId: string
targetUserId: string
targetDisplayName: string
}): Promise<void> {
try {
// Fetch the target user's roles from the forum user endpoint
let roles: any[] = []
try {
const response = await ocs.get(`/users/${data.targetUserId}`)
roles = response.data?.roles || []
} catch {
// User may not have a forum profile yet - that is fine
}
const newAuthor = {
userId: data.targetUserId,
displayName: data.targetDisplayName,
isDeleted: false,
isGuest: false,
roles,
signature: null,
signatureRaw: null,
}
// Update first post if it belonged to this guest
if (this.firstPost && this.firstPost.authorId === data.guestAuthorId) {
this.firstPost = { ...this.firstPost, authorId: data.targetUserId, author: newAuthor }
}
// Update all replies that belonged to this guest
this.replies = this.replies.map((reply) => {
if (reply.authorId === data.guestAuthorId) {
return { ...reply, authorId: data.targetUserId, author: newAuthor }
}
return reply
})
// Update thread header if the thread author was this guest
if (this.thread && this.thread.authorId === data.guestAuthorId) {
this.thread.authorId = data.targetUserId
this.thread.author = newAuthor
}
// Update lastReplyAuthorId if it was this guest
if (this.thread && this.thread.lastReplyAuthorId === data.guestAuthorId) {
this.thread.lastReplyAuthorId = data.targetUserId
}
} catch (e) {
console.error('Failed to update posts after reassignment', e)
// Posts were reassigned on the backend; a refresh will show the correct state
}
},
replyToThread(): void {
// Redirect guests to login (only if they cannot reply)
if (this.userId === null && !this.canReply) {

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { createIconMock, createComponentMock } from '@/test-utils'
import { createMockThread, createMockPost } from '@/test-mocks'
import { createMockThread, createMockPost, createMockUser } from '@/test-mocks'
// Mock axios
vi.mock('@/axios', () => ({
@@ -82,7 +82,7 @@ vi.mock('@/components/PostCard', () =>
template:
'<div class="post-card-mock" :data-can-reply="canReply" :data-can-moderate="canModerateCategory" />',
props: ['post', 'isFirstPost', 'isUnread', 'canModerateCategory', 'canReply'],
emits: ['reply', 'update', 'delete'],
emits: ['reply', 'update', 'delete', 'reassigned'],
}),
)
@@ -342,4 +342,243 @@ describe('ThreadView', () => {
expect(wrapper.text()).toContain('You do not have permission to reply in this category.')
})
})
describe('guest reassignment', () => {
const guestAuthorId = 'guest:abcdef1234567890abcdef1234567890'
const guestAuthor = createMockUser({
userId: guestAuthorId,
displayName: 'BrightMountain42',
isGuest: true,
})
it('updates first post author in-place after reassignment', async () => {
const guestFirstPost = createMockPost({
id: 1,
authorId: guestAuthorId,
author: guestAuthor,
content: '<p>Guest post</p>',
})
mockOcsGet.mockImplementation((url: string) => {
if (url.includes('/posts/paginated')) {
return mockGetResponse({
data: {
firstPost: guestFirstPost,
replies: [],
pagination: {
page: 1,
perPage: 20,
total: 0,
totalPages: 1,
startPage: 1,
lastReadPostId: null,
},
},
})
}
if (url === '/users/alice') {
return mockGetResponse({ data: { userId: 'alice', roles: [{ id: 1, name: 'Default' }] } })
}
return mockGetResponse({ data: null })
})
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as InstanceType<typeof ThreadView>
await vm.handleReassigned({
guestAuthorId,
targetUserId: 'alice',
targetDisplayName: 'Alice Smith',
})
await flushPromises()
expect(vm.firstPost!.authorId).toBe('alice')
expect(vm.firstPost!.author!.displayName).toBe('Alice Smith')
expect(vm.firstPost!.author!.isGuest).toBe(false)
})
it('updates replies author in-place after reassignment', async () => {
const guestReply = createMockPost({ id: 10, authorId: guestAuthorId, author: guestAuthor })
const otherReply = createMockPost({
id: 11,
authorId: 'bob',
author: createMockUser({ userId: 'bob' }),
})
mockOcsGet.mockImplementation((url: string) => {
if (url.includes('/posts/paginated')) {
return mockGetResponse({
data: {
firstPost: mockFirstPost,
replies: [guestReply, otherReply],
pagination: {
page: 1,
perPage: 20,
total: 2,
totalPages: 1,
startPage: 1,
lastReadPostId: null,
},
},
})
}
if (url === '/users/alice') {
return mockGetResponse({ data: { userId: 'alice', roles: [] } })
}
return mockGetResponse({ data: null })
})
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as InstanceType<typeof ThreadView>
await vm.handleReassigned({
guestAuthorId,
targetUserId: 'alice',
targetDisplayName: 'Alice Smith',
})
await flushPromises()
// Guest reply should be updated
expect(vm.replies[0]!.authorId).toBe('alice')
expect(vm.replies[0]!.author!.displayName).toBe('Alice Smith')
expect(vm.replies[0]!.author!.isGuest).toBe(false)
// Other reply should be unchanged
expect(vm.replies[1]!.authorId).toBe('bob')
})
it('updates thread header author after reassignment', async () => {
mockThread.value = createMockThread({
id: 1,
categoryId: 5,
authorId: guestAuthorId,
author: guestAuthor,
})
mockFetchThread.mockResolvedValue(mockThread.value)
mockOcsGet.mockImplementation((url: string) => {
if (url.includes('/posts/paginated')) {
return mockGetResponse({
data: {
firstPost: mockFirstPost,
replies: [],
pagination: {
page: 1,
perPage: 20,
total: 0,
totalPages: 1,
startPage: 1,
lastReadPostId: null,
},
},
})
}
if (url === '/users/alice') {
return mockGetResponse({ data: { userId: 'alice', roles: [] } })
}
return mockGetResponse({ data: null })
})
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as InstanceType<typeof ThreadView>
await vm.handleReassigned({
guestAuthorId,
targetUserId: 'alice',
targetDisplayName: 'Alice Smith',
})
await flushPromises()
expect(vm.thread!.authorId).toBe('alice')
expect(vm.thread!.author!.displayName).toBe('Alice Smith')
})
it('updates thread lastReplyAuthorId after reassignment', async () => {
mockThread.value = createMockThread({
id: 1,
categoryId: 5,
lastReplyAuthorId: guestAuthorId,
})
mockFetchThread.mockResolvedValue(mockThread.value)
mockOcsGet.mockImplementation((url: string) => {
if (url.includes('/posts/paginated')) {
return mockGetResponse({
data: {
firstPost: mockFirstPost,
replies: [],
pagination: {
page: 1,
perPage: 20,
total: 0,
totalPages: 1,
startPage: 1,
lastReadPostId: null,
},
},
})
}
return mockGetResponse({ data: null })
})
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as InstanceType<typeof ThreadView>
await vm.handleReassigned({
guestAuthorId,
targetUserId: 'alice',
targetDisplayName: 'Alice Smith',
})
await flushPromises()
expect(vm.thread!.lastReplyAuthorId).toBe('alice')
})
it('handles user fetch failure gracefully', async () => {
const guestReply = createMockPost({ id: 10, authorId: guestAuthorId, author: guestAuthor })
mockOcsGet.mockImplementation((url: string) => {
if (url.includes('/posts/paginated')) {
return mockGetResponse({
data: {
firstPost: mockFirstPost,
replies: [guestReply],
pagination: {
page: 1,
perPage: 20,
total: 1,
totalPages: 1,
startPage: 1,
lastReadPostId: null,
},
},
})
}
if (url === '/users/alice') {
return Promise.reject(new Error('Not found'))
}
return mockGetResponse({ data: null })
})
const wrapper = createWrapper()
await flushPromises()
const vm = wrapper.vm as InstanceType<typeof ThreadView>
await vm.handleReassigned({
guestAuthorId,
targetUserId: 'alice',
targetDisplayName: 'Alice Smith',
})
await flushPromises()
// Should still update with empty roles
expect(vm.replies[0]!.authorId).toBe('alice')
expect(vm.replies[0]!.author!.displayName).toBe('Alice Smith')
expect(vm.replies[0]!.author!.roles).toEqual([])
})
})
})

View File

@@ -12,8 +12,10 @@ use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Service\AdminSettingsService;
use OCA\Forum\Service\GuestService;
use OCA\Forum\Service\UserRoleService;
use OCA\Forum\Service\UserService;
use OCP\AppFramework\Http;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserManager;
@@ -45,6 +47,8 @@ class AdminControllerTest extends TestCase {
private IUserSession $userSession;
/** @var AdminSettingsService&MockObject */
private AdminSettingsService $settingsService;
/** @var GuestService&MockObject */
private GuestService $guestService;
/** @var LoggerInterface&MockObject */
private LoggerInterface $logger;
/** @var IRequest&MockObject */
@@ -62,6 +66,7 @@ class AdminControllerTest extends TestCase {
$this->userManager = $this->createMock(IUserManager::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->settingsService = $this->createMock(AdminSettingsService::class);
$this->guestService = $this->createMock(GuestService::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->controller = new AdminController(
@@ -77,6 +82,7 @@ class AdminControllerTest extends TestCase {
$this->userManager,
$this->userSession,
$this->settingsService,
$this->guestService,
$this->logger
);
}
@@ -260,4 +266,114 @@ class AdminControllerTest extends TestCase {
$this->assertEquals(3, $contributor['postCount']);
$this->assertEquals(1, $contributor['threadCount']);
}
// ── Guest reassignment tests ─────────────────────────────────────
public function testReassignGuestPostsSuccess(): void {
$guestAuthorId = 'guest:abcdef1234567890abcdef1234567890';
$targetUserId = 'alice';
$targetUser = $this->createMock(IUser::class);
$this->userManager->expects($this->once())
->method('get')
->with($targetUserId)
->willReturn($targetUser);
$this->postMapper->expects($this->once())
->method('countByAuthorId')
->with($guestAuthorId)
->willReturn(['total' => 5, 'threads' => 1, 'replies' => 4]);
$this->postMapper->expects($this->once())
->method('reassignAuthor')
->with($guestAuthorId, $targetUserId)
->willReturn(5);
$this->threadMapper->expects($this->once())
->method('reassignAuthor')
->with($guestAuthorId, $targetUserId)
->willReturn(1);
$this->threadMapper->expects($this->once())
->method('reassignLastReplyAuthor')
->with($guestAuthorId, $targetUserId);
$this->forumUserMapper->expects($this->once())
->method('incrementPostCount')
->with($targetUserId, 4);
$this->forumUserMapper->expects($this->once())
->method('incrementThreadCount')
->with($targetUserId, 1);
$response = $this->controller->reassignGuestPosts($guestAuthorId, $targetUserId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$data = $response->getData();
$this->assertTrue($data['success']);
$this->assertEquals(5, $data['postsReassigned']);
$this->assertEquals(1, $data['threadsReassigned']);
}
public function testReassignGuestPostsRejectsInvalidGuestId(): void {
$response = $this->controller->reassignGuestPosts('not-a-guest-id', 'alice');
$this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus());
$this->assertEquals('Invalid guest author ID format', $response->getData()['error']);
}
public function testReassignGuestPostsRejectsNonexistentTargetUser(): void {
$this->userManager->expects($this->once())
->method('get')
->with('nonexistent')
->willReturn(null);
$response = $this->controller->reassignGuestPosts(
'guest:abcdef1234567890abcdef1234567890',
'nonexistent'
);
$this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus());
$this->assertEquals('Target user does not exist', $response->getData()['error']);
}
public function testReassignGuestPostsDoesNotIncrementZeroCounts(): void {
$guestAuthorId = 'guest:abcdef1234567890abcdef1234567890';
$targetUserId = 'alice';
$targetUser = $this->createMock(IUser::class);
$this->userManager->method('get')->willReturn($targetUser);
$this->postMapper->method('countByAuthorId')
->willReturn(['total' => 0, 'threads' => 0, 'replies' => 0]);
$this->postMapper->method('reassignAuthor')->willReturn(0);
$this->threadMapper->method('reassignAuthor')->willReturn(0);
$this->threadMapper->method('reassignLastReplyAuthor');
$this->forumUserMapper->expects($this->never())->method('incrementPostCount');
$this->forumUserMapper->expects($this->never())->method('incrementThreadCount');
$response = $this->controller->reassignGuestPosts($guestAuthorId, $targetUserId);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
$this->assertTrue($response->getData()['success']);
}
public function testReassignGuestPostsHandlesException(): void {
$guestAuthorId = 'guest:abcdef1234567890abcdef1234567890';
$targetUser = $this->createMock(IUser::class);
$this->userManager->method('get')->willReturn($targetUser);
$this->postMapper->method('countByAuthorId')
->willThrowException(new \Exception('DB error'));
$this->logger->expects($this->once())
->method('error')
->with($this->stringContains('Error reassigning guest posts'));
$response = $this->controller->reassignGuestPosts($guestAuthorId, 'alice');
$this->assertEquals(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus());
}
}