diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index e9fc061..472204e 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -81,6 +81,23 @@ class AdminController extends OCSController { $topContributorsAllTime = $this->forumUserMapper->getTopContributors(5); $topContributorsRecent = $this->forumUserMapper->getTopContributorsSince($weekAgo, 5); + // Enrich contributors with display names + $allContributorIds = array_unique(array_merge( + array_map(fn ($c) => $c['userId'], $topContributorsAllTime), + array_map(fn ($c) => $c['userId'], $topContributorsRecent), + )); + $enrichedUsers = $this->userService->enrichMultipleUsers($allContributorIds); + + $enrichContributors = function (array $contributors) use ($enrichedUsers): array { + return array_map(function ($c) use ($enrichedUsers) { + $userData = $enrichedUsers[$c['userId']] ?? null; + $c['displayName'] = $userData['displayName'] ?? $c['userId']; + $c['isGuest'] = $userData['isGuest'] ?? false; + $c['roles'] = $userData['roles'] ?? []; + return $c; + }, $contributors); + }; + return new DataResponse([ 'totals' => [ 'users' => $totalUsers, @@ -93,8 +110,8 @@ class AdminController extends OCSController { 'threads' => $recentThreads, 'posts' => $recentPosts, ], - 'topContributorsAllTime' => $topContributorsAllTime, - 'topContributorsRecent' => $topContributorsRecent, + 'topContributorsAllTime' => $enrichContributors($topContributorsAllTime), + 'topContributorsRecent' => $enrichContributors($topContributorsRecent), ]); } catch (\Exception $e) { $this->logger->error('Error fetching dashboard stats: ' . $e->getMessage()); diff --git a/lib/Controller/GuestController.php b/lib/Controller/GuestController.php new file mode 100644 index 0000000..03dc045 --- /dev/null +++ b/lib/Controller/GuestController.php @@ -0,0 +1,59 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Forum\Controller; + +use OCA\Forum\Service\GuestService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\PublicPage; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use Psr\Log\LoggerInterface; + +class GuestController extends OCSController { + public function __construct( + string $appName, + IRequest $request, + private GuestService $guestService, + private LoggerInterface $logger, + ) { + parent::__construct($appName, $request); + } + + /** + * Get or create a guest identity + * + * @param string $guestToken 32-character hex token + * @return DataResponse + * + * 200: Guest identity returned + */ + #[NoAdminRequired] + #[PublicPage] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/guest/me')] + public function me(string $guestToken): DataResponse { + try { + $session = $this->guestService->getOrCreateSession($guestToken); + + return new DataResponse([ + 'displayName' => $session->getDisplayName(), + 'guestToken' => $session->getSessionToken(), + 'isGuest' => true, + ]); + } catch (\InvalidArgumentException $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } catch (\Exception $e) { + $this->logger->error('Error resolving guest identity: ' . $e->getMessage()); + return new DataResponse(['error' => 'Failed to resolve guest identity'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/lib/Controller/PostController.php b/lib/Controller/PostController.php index c996113..98fe835 100644 --- a/lib/Controller/PostController.php +++ b/lib/Controller/PostController.php @@ -17,6 +17,7 @@ use OCA\Forum\Db\ReadMarkerMapper; use OCA\Forum\Db\ThreadMapper; use OCA\Forum\Db\ThreadSubscriptionMapper; use OCA\Forum\Service\BBCodeService; +use OCA\Forum\Service\GuestService; use OCA\Forum\Service\NotificationService; use OCA\Forum\Service\PermissionService; use OCA\Forum\Service\PostEnrichmentService; @@ -27,6 +28,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; @@ -53,6 +55,7 @@ class PostController extends OCSController { private UserService $userService, private UserPreferencesService $userPreferencesService, private ThreadSubscriptionMapper $threadSubscriptionMapper, + private GuestService $guestService, private IUserSession $userSession, private LoggerInterface $logger, ) { @@ -319,23 +322,36 @@ class PostController extends OCSController { * * @param int $threadId Thread ID * @param string $content Post content + * @param string $guestToken Guest session token (32-char hex, for unauthenticated users) * @return DataResponse, array{}> * * 201: Post created */ #[NoAdminRequired] + #[PublicPage] + #[NoCSRFRequired] #[RequirePermission('canReply', resourceType: 'category', resourceIdFromThreadId: 'threadId')] #[ApiRoute(verb: 'POST', url: '/api/posts')] - public function create(int $threadId, string $content): DataResponse { + public function create(int $threadId, string $content, string $guestToken = ''): DataResponse { try { $user = $this->userSession->getUser(); - if (!$user) { + + // Resolve author identity + if ($user) { + $authorId = $user->getUID(); + } elseif ($guestToken !== '') { + try { + $authorId = $this->guestService->resolveGuestIdentity($guestToken); + } catch (\InvalidArgumentException $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } else { return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED); } $post = new \OCA\Forum\Db\Post(); $post->setThreadId($threadId); - $post->setAuthorId($user->getUID()); + $post->setAuthorId($authorId); $post->setContent($content); $post->setIsEdited(false); $post->setIsFirstPost(false); @@ -345,16 +361,39 @@ class PostController extends OCSController { /** @var \OCA\Forum\Db\Post */ $createdPost = $this->postMapper->insert($post); - // Mark thread as read up to and including the new post - try { - $this->readMarkerMapper->createOrUpdate( - $user->getUID(), - $threadId, - $createdPost->getId() - ); - } catch (\Exception $e) { - $this->logger->warning('Failed to update read marker after creating post: ' . $e->getMessage()); - // Don't fail the request if read marker update fails + // User-only operations (read markers, forum user stats, auto-subscribe) + if ($user) { + // Mark thread as read up to and including the new post + try { + $this->readMarkerMapper->createOrUpdate( + $user->getUID(), + $threadId, + $createdPost->getId() + ); + } catch (\Exception $e) { + $this->logger->warning('Failed to update read marker after creating post: ' . $e->getMessage()); + } + + // Update forum user post count (auto-creates forum user if needed) + try { + $this->forumUserMapper->incrementPostCount($user->getUID()); + } catch (\Exception $e) { + $this->logger->warning('Failed to update forum user post count: ' . $e->getMessage()); + } + + // Auto-subscribe the user to the thread if preference is enabled and not already subscribed + try { + $autoSubscribe = $this->userPreferencesService->getPreference( + $user->getUID(), + UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS + ); + + if ($autoSubscribe && !$this->threadSubscriptionMapper->isUserSubscribed($user->getUID(), $threadId)) { + $this->threadSubscriptionMapper->subscribe($user->getUID(), $threadId); + } + } catch (\Exception $e) { + $this->logger->warning('Failed to auto-subscribe user to thread: ' . $e->getMessage()); + } } // Update the thread's post count and timestamps @@ -366,15 +405,6 @@ class PostController extends OCSController { $this->threadMapper->update($thread); } catch (\Exception $e) { $this->logger->warning('Failed to update thread post count: ' . $e->getMessage()); - // Don't fail the request if thread update fails - } - - // Update forum user post count (auto-creates forum user if needed) - try { - $this->forumUserMapper->incrementPostCount($user->getUID()); - } catch (\Exception $e) { - $this->logger->warning('Failed to update forum user post count: ' . $e->getMessage()); - // Don't fail the request if forum user update fails } // Update the category's post count @@ -384,39 +414,21 @@ class PostController extends OCSController { $this->categoryMapper->update($category); } catch (\Exception $e) { $this->logger->warning('Failed to update category post count: ' . $e->getMessage()); - // Don't fail the request if category update fails } // Notify registered users about the new post try { - $this->notificationService->notifyThreadSubscribers($threadId, $createdPost->getId(), $user->getUID()); + $this->notificationService->notifyThreadSubscribers($threadId, $createdPost->getId(), $authorId); } catch (\Exception $e) { $this->logger->warning('Failed to send notifications for new post: ' . $e->getMessage()); - // Don't fail the request if notification sending fails } // Notify mentioned users try { $mentionedUsers = $this->notificationService->extractMentions($content); - $this->notificationService->notifyMentionedUsers($createdPost->getId(), $threadId, $user->getUID(), $mentionedUsers); + $this->notificationService->notifyMentionedUsers($createdPost->getId(), $threadId, $authorId, $mentionedUsers); } catch (\Exception $e) { $this->logger->warning('Failed to send mention notifications: ' . $e->getMessage()); - // Don't fail the request if mention notification sending fails - } - - // Auto-subscribe the user to the thread if preference is enabled and not already subscribed - try { - $autoSubscribe = $this->userPreferencesService->getPreference( - $user->getUID(), - UserPreferencesService::PREF_AUTO_SUBSCRIBE_REPLIED_THREADS - ); - - if ($autoSubscribe && !$this->threadSubscriptionMapper->isUserSubscribed($user->getUID(), $threadId)) { - $this->threadSubscriptionMapper->subscribe($user->getUID(), $threadId); - } - } catch (\Exception $e) { - $this->logger->warning('Failed to auto-subscribe user to thread: ' . $e->getMessage()); - // Don't fail the request if auto-subscribe fails } return new DataResponse($this->postEnrichmentService->enrichPost($createdPost), Http::STATUS_CREATED); diff --git a/lib/Controller/ThreadController.php b/lib/Controller/ThreadController.php index 1d12f86..4779fbd 100644 --- a/lib/Controller/ThreadController.php +++ b/lib/Controller/ThreadController.php @@ -17,6 +17,7 @@ use OCA\Forum\Db\ReadMarkerMapper; use OCA\Forum\Db\Thread; use OCA\Forum\Db\ThreadMapper; use OCA\Forum\Db\ThreadSubscriptionMapper; +use OCA\Forum\Service\GuestService; use OCA\Forum\Service\NotificationService; use OCA\Forum\Service\PermissionService; use OCA\Forum\Service\ThreadEnrichmentService; @@ -26,6 +27,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; @@ -49,6 +51,7 @@ class ThreadController extends OCSController { private UserService $userService, private PermissionService $permissionService, private NotificationService $notificationService, + private GuestService $guestService, private IUserSession $userSession, private LoggerInterface $logger, ) { @@ -275,35 +278,50 @@ class ThreadController extends OCSController { * @param int $categoryId Category ID * @param string $title Thread title * @param string $content Initial post content + * @param string $guestToken Guest session token (32-char hex, for unauthenticated users) * @return DataResponse, array{}> * * 201: Thread created */ #[NoAdminRequired] + #[PublicPage] + #[NoCSRFRequired] #[RequirePermission('canPost', resourceType: 'category', resourceIdBody: 'categoryId')] #[ApiRoute(verb: 'POST', url: '/api/threads')] - public function create(int $categoryId, string $title, string $content): DataResponse { + public function create(int $categoryId, string $title, string $content, string $guestToken = ''): DataResponse { try { $user = $this->userSession->getUser(); - if (!$user) { + + // Resolve author identity + if ($user) { + $authorId = $user->getUID(); + } elseif ($guestToken !== '') { + try { + $authorId = $this->guestService->resolveGuestIdentity($guestToken); + } catch (\InvalidArgumentException $e) { + return new DataResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } else { return new DataResponse(['error' => 'User not authenticated'], Http::STATUS_UNAUTHORIZED); } // Check if the category was already read before creating the thread // (used later to decide whether to update the category read marker) $wasCategoryRead = false; - try { - $lastActivity = $this->threadMapper->getLastActivityForCategory($categoryId); - if ($lastActivity === null) { - $wasCategoryRead = true; - } else { - $marker = $this->readMarkerMapper->findByUserAndCategory($user->getUID(), $categoryId); - $wasCategoryRead = $marker->getReadAt() >= $lastActivity; + if ($user) { + try { + $lastActivity = $this->threadMapper->getLastActivityForCategory($categoryId); + if ($lastActivity === null) { + $wasCategoryRead = true; + } else { + $marker = $this->readMarkerMapper->findByUserAndCategory($user->getUID(), $categoryId); + $wasCategoryRead = $marker->getReadAt() >= $lastActivity; + } + } catch (DoesNotExistException $e) { + // No read marker means the category is unread + } catch (\Exception $e) { + $this->logger->warning('Failed to check category read state: ' . $e->getMessage()); } - } catch (DoesNotExistException $e) { - // No read marker means the category is unread - } catch (\Exception $e) { - $this->logger->warning('Failed to check category read state: ' . $e->getMessage()); } // Generate slug from title @@ -314,7 +332,7 @@ class ThreadController extends OCSController { $thread = new \OCA\Forum\Db\Thread(); $thread->setCategoryId($categoryId); - $thread->setAuthorId($user->getUID()); + $thread->setAuthorId($authorId); $thread->setTitle($title); $thread->setSlug($slug); $thread->setViewCount(0); @@ -331,7 +349,7 @@ class ThreadController extends OCSController { // Create the initial post $post = new \OCA\Forum\Db\Post(); $post->setThreadId($createdThread->getId()); - $post->setAuthorId($user->getUID()); + $post->setAuthorId($authorId); $post->setContent($content); $post->setIsEdited(false); $post->setIsFirstPost(true); @@ -356,57 +374,60 @@ class ThreadController extends OCSController { $this->logger->warning('Failed to update category counts: ' . $e->getMessage()); } - // Update forum user (thread count only, first post doesn't count) - try { - $this->forumUserMapper->incrementThreadCount($user->getUID()); - } catch (\Exception $e) { - $this->logger->warning('Failed to update forum user: ' . $e->getMessage()); - } - - // Auto-subscribe the thread creator to receive notifications (if preference is enabled) - try { - $autoSubscribe = $this->userPreferencesService->getPreference( - $user->getUID(), - UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS - ); - - if ($autoSubscribe) { - $this->threadSubscriptionMapper->subscribe($user->getUID(), $createdThread->getId()); + // User-only operations (forum user stats, subscriptions, read markers, drafts) + if ($user) { + // Update forum user (thread count only, first post doesn't count) + try { + $this->forumUserMapper->incrementThreadCount($user->getUID()); + } catch (\Exception $e) { + $this->logger->warning('Failed to update forum user: ' . $e->getMessage()); + } + + // Auto-subscribe the thread creator to receive notifications (if preference is enabled) + try { + $autoSubscribe = $this->userPreferencesService->getPreference( + $user->getUID(), + UserPreferencesService::PREF_AUTO_SUBSCRIBE_CREATED_THREADS + ); + + if ($autoSubscribe) { + $this->threadSubscriptionMapper->subscribe($user->getUID(), $createdThread->getId()); + } + } catch (\Exception $e) { + $this->logger->warning('Failed to subscribe thread creator: ' . $e->getMessage()); + } + + // Update category read marker so the category stays read, + // but only if it was not already unread before this thread was created + if ($wasCategoryRead) { + try { + $this->readMarkerMapper->createOrUpdateCategoryMarker($user->getUID(), $categoryId); + } catch (\Exception $e) { + $this->logger->warning('Failed to update category read marker: ' . $e->getMessage()); + } + } + + // Delete any draft for this category now that the thread is created + try { + $this->draftMapper->deleteThreadDraft($user->getUID(), $categoryId); + } catch (\Exception $e) { + $this->logger->warning('Failed to delete thread draft: ' . $e->getMessage()); } - } catch (\Exception $e) { - $this->logger->warning('Failed to subscribe thread creator: ' . $e->getMessage()); } - // Notify mentioned users in the initial post + // Notify mentioned users in the initial post (works for both guest and authenticated posts) try { $mentionedUsers = $this->notificationService->extractMentions($content); $this->notificationService->notifyMentionedUsers( $createdPost->getId(), $createdThread->getId(), - $user->getUID(), + $authorId, $mentionedUsers ); } catch (\Exception $e) { $this->logger->warning('Failed to send mention notifications: ' . $e->getMessage()); } - // Update category read marker so the category stays read, - // but only if it was not already unread before this thread was created - if ($wasCategoryRead) { - try { - $this->readMarkerMapper->createOrUpdateCategoryMarker($user->getUID(), $categoryId); - } catch (\Exception $e) { - $this->logger->warning('Failed to update category read marker: ' . $e->getMessage()); - } - } - - // Delete any draft for this category now that the thread is created - try { - $this->draftMapper->deleteThreadDraft($user->getUID(), $categoryId); - } catch (\Exception $e) { - $this->logger->warning('Failed to delete thread draft: ' . $e->getMessage()); - } - return new DataResponse($createdThread->jsonSerialize(), Http::STATUS_CREATED); } catch (\Exception $e) { $this->logger->error('Error creating thread: ' . $e->getMessage()); diff --git a/lib/Dashboard/RecentActivityWidget.php b/lib/Dashboard/RecentActivityWidget.php index fc7f246..ca076bd 100644 --- a/lib/Dashboard/RecentActivityWidget.php +++ b/lib/Dashboard/RecentActivityWidget.php @@ -8,6 +8,7 @@ declare(strict_types=1); namespace OCA\Forum\Dashboard; use OCA\Forum\AppInfo\Application; +use OCA\Forum\Service\UserService; use OCP\Dashboard\IAPIWidgetV2; use OCP\Dashboard\IButtonWidget; use OCP\Dashboard\IIconWidget; @@ -22,6 +23,7 @@ class RecentActivityWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget { private IL10N $l, private IURLGenerator $urlGenerator, private WidgetService $widgetService, + private UserService $userService, ) { } @@ -66,6 +68,18 @@ class RecentActivityWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget { public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems { $activity = $this->widgetService->getRecentActivity($userId, $limit); + // Collect all author IDs and resolve display names in batch + $authorIds = []; + foreach ($activity as $entry) { + if ($entry['type'] === 'thread' && $entry['thread'] !== null) { + $authorIds[] = $entry['thread']->getAuthorId(); + } elseif ($entry['type'] === 'reply') { + $authorIds[] = $entry['item']->getAuthorId(); + } + } + $authorIds = array_unique($authorIds); + $enrichedAuthors = !empty($authorIds) ? $this->userService->enrichMultipleUsers($authorIds) : []; + $items = []; foreach ($activity as $entry) { $thread = $entry['thread']; @@ -78,10 +92,22 @@ class RecentActivityWidget implements IAPIWidgetV2, IIconWidget, IButtonWidget { $sinceId = (string)$entry['createdAt']; if ($entry['type'] === 'thread') { - $subtitle = $this->l->t('New thread by %1$s', [$thread->getAuthorId()]); + $authorId = $thread->getAuthorId(); + $authorData = $enrichedAuthors[$authorId] ?? null; + $displayName = $authorData['displayName'] ?? $authorId; + if (!empty($authorData['isGuest'])) { + $displayName = $this->l->t('%1$s (Guest)', [$displayName]); + } + $subtitle = $this->l->t('New thread by %1$s', [$displayName]); } else { $post = $entry['item']; - $subtitle = $this->l->t('Reply by %1$s', [$post->getAuthorId()]); + $authorId = $post->getAuthorId(); + $authorData = $enrichedAuthors[$authorId] ?? null; + $displayName = $authorData['displayName'] ?? $authorId; + if (!empty($authorData['isGuest'])) { + $displayName = $this->l->t('%1$s (Guest)', [$displayName]); + } + $subtitle = $this->l->t('Reply by %1$s', [$displayName]); } $items[] = new WidgetItem( diff --git a/lib/Db/GuestSession.php b/lib/Db/GuestSession.php new file mode 100644 index 0000000..31d8bf3 --- /dev/null +++ b/lib/Db/GuestSession.php @@ -0,0 +1,43 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Forum\Db; + +use JsonSerializable; +use OCP\AppFramework\Db\Entity; + +/** + * @method int getId() + * @method void setId(int $value) + * @method string getSessionToken() + * @method void setSessionToken(string $value) + * @method string getDisplayName() + * @method void setDisplayName(string $value) + * @method int getCreatedAt() + * @method void setCreatedAt(int $value) + */ +class GuestSession extends Entity implements JsonSerializable { + protected $sessionToken; + protected $displayName; + protected $createdAt; + + public function __construct() { + $this->addType('id', 'integer'); + $this->addType('sessionToken', 'string'); + $this->addType('displayName', 'string'); + $this->addType('createdAt', 'integer'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'sessionToken' => $this->getSessionToken(), + 'displayName' => $this->getDisplayName(), + 'createdAt' => $this->getCreatedAt(), + ]; + } +} diff --git a/lib/Db/GuestSessionMapper.php b/lib/Db/GuestSessionMapper.php new file mode 100644 index 0000000..ee20063 --- /dev/null +++ b/lib/Db/GuestSessionMapper.php @@ -0,0 +1,55 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Forum\Db; + +use OCA\Forum\AppInfo\Application; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper + */ +class GuestSessionMapper extends QBMapper { + public function __construct( + IDBConnection $db, + ) { + parent::__construct($db, Application::tableName('forum_guest_sessions'), GuestSession::class); + } + + /** + * @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException + * @throws DoesNotExistException + */ + public function findByToken(string $token): GuestSession { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('session_token', $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR)) + ); + return $this->findEntity($qb); + } + + /** + * Check if a display name already exists + */ + public function displayNameExists(string $displayName): bool { + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*', 'cnt')) + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('display_name', $qb->createNamedParameter($displayName, IQueryBuilder::PARAM_STR)) + ); + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); + return $count > 0; + } +} diff --git a/lib/Middleware/PermissionMiddleware.php b/lib/Middleware/PermissionMiddleware.php index 446f021..96b1836 100644 --- a/lib/Middleware/PermissionMiddleware.php +++ b/lib/Middleware/PermissionMiddleware.php @@ -56,7 +56,7 @@ class PermissionMiddleware extends Middleware { // If user is not authenticated if (!$user) { // Allow unauthenticated access for public pages or when guest access is enabled for read-only - $allowUnauthenticated = $isPublicPage || ($guestAccessEnabled && $isReadOnlyMethod); + $allowUnauthenticated = $isPublicPage || $guestAccessEnabled; if ($allowUnauthenticated) { // Check if there are permission requirements - if so, check guest permissions diff --git a/lib/Migration/Version24Date20260317000000.php b/lib/Migration/Version24Date20260317000000.php new file mode 100644 index 0000000..574f4f4 --- /dev/null +++ b/lib/Migration/Version24Date20260317000000.php @@ -0,0 +1,54 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Forum\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version24Date20260317000000 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure(): ISchemaWrapper $schemaClosure + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('forum_guest_sessions')) { + $table = $schema->createTable('forum_guest_sessions'); + + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('session_token', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('display_name', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('created_at', Types::BIGINT, [ + 'notnull' => true, + 'unsigned' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['session_token'], 'forum_guest_sess_token_idx'); + } + + return $schema; + } +} diff --git a/lib/Service/GuestService.php b/lib/Service/GuestService.php new file mode 100644 index 0000000..a7fc5e0 --- /dev/null +++ b/lib/Service/GuestService.php @@ -0,0 +1,164 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Forum\Service; + +use OCA\Forum\Db\GuestSession; +use OCA\Forum\Db\GuestSessionMapper; +use OCP\AppFramework\Db\DoesNotExistException; + +class GuestService { + private const ADJECTIVES = [ + 'Bright', 'Swift', 'Calm', 'Bold', 'Keen', + 'Wise', 'Fair', 'Warm', 'Cool', 'Pure', + 'Sharp', 'Brave', 'Clear', 'Quick', 'Glad', + 'Kind', 'Free', 'Noble', 'Proud', 'True', + 'Lucky', 'Merry', 'Jolly', 'Lively', 'Gentle', + 'Vivid', 'Quiet', 'Eager', 'Happy', 'Witty', + 'Daring', 'Clever', 'Humble', 'Cosmic', 'Sunny', + 'Golden', 'Silver', 'Crystal', 'Mystic', 'Velvet', + 'Amber', 'Azure', 'Coral', 'Ivory', 'Jade', + 'Ruby', 'Sage', 'Teal', 'Rustic', 'Nimble', + 'Polar', 'Lunar', 'Solar', 'Stellar', 'Radiant', + 'Serene', 'Dapper', 'Frosty', 'Mossy', 'Dusty', + 'Misty', 'Breezy', 'Stormy', 'Snowy', 'Rainy', + 'Zesty', 'Peppy', 'Perky', 'Chipper', 'Plucky', + 'Hardy', 'Sturdy', 'Steady', 'Loyal', 'Fierce', + 'Savvy', 'Crafty', 'Nifty', 'Handy', 'Zippy', + 'Glossy', 'Sleek', 'Crisp', 'Fresh', 'Rosy', + 'Dusky', 'Ashen', 'Oaken', 'Marble', 'Pewter', + 'Copper', 'Bronze', 'Cobalt', 'Scarlet', 'Indigo', + ]; + + private const NOUNS = [ + 'Mountain', 'River', 'Forest', 'Meadow', 'Ocean', + 'Valley', 'Desert', 'Island', 'Canyon', 'Prairie', + 'Falcon', 'Eagle', 'Raven', 'Phoenix', 'Tiger', + 'Dolphin', 'Panther', 'Wolf', 'Fox', 'Hawk', + 'Storm', 'Thunder', 'Breeze', 'Frost', 'Aurora', + 'Comet', 'Star', 'Nebula', 'Galaxy', 'Cosmos', + 'Oak', 'Cedar', 'Willow', 'Maple', 'Birch', + 'Spark', 'Flame', 'Wave', 'Stone', 'Crystal', + 'Arrow', 'Shield', 'Crown', 'Compass', 'Lantern', + 'Harbor', 'Summit', 'Ridge', 'Cliff', 'Shore', + 'Heron', 'Otter', 'Panda', 'Lynx', 'Crane', + 'Badger', 'Osprey', 'Wren', 'Finch', 'Condor', + 'Glacier', 'Lagoon', 'Tundra', 'Steppe', 'Reef', + 'Plateau', 'Ravine', 'Basin', 'Delta', 'Fjord', + 'Pebble', 'Ember', 'Geyser', 'Prism', 'Quartz', + 'Beacon', 'Anchor', 'Vessel', 'Scepter', 'Banner', + 'Thistle', 'Clover', 'Orchid', 'Lotus', 'Fern', + 'Ivy', 'Aspen', 'Cypress', 'Spruce', 'Sequoia', + 'Dusk', 'Dawn', 'Zenith', 'Horizon', 'Eclipse', + 'Cascade', 'Torrent', 'Zephyr', 'Tempest', 'Monsoon', + ]; + + public function __construct( + private GuestSessionMapper $guestSessionMapper, + ) { + } + + /** + * Resolve a guest token to a guest author ID. + * Creates a new guest session if the token does not exist yet. + * + * @param string $guestToken 32-character hex token + * @return string Author ID in the format "guest:" + * @throws \InvalidArgumentException If token is invalid + */ + public function resolveGuestIdentity(string $guestToken): string { + // Validate token format: 32 hex characters + if (!preg_match('/^[0-9a-f]{32}$/', $guestToken)) { + throw new \InvalidArgumentException('Invalid guest token format'); + } + + try { + $this->guestSessionMapper->findByToken($guestToken); + } catch (DoesNotExistException $e) { + // Create new guest session + $session = new GuestSession(); + $session->setSessionToken($guestToken); + $session->setDisplayName($this->generateGuestName()); + $session->setCreatedAt(time()); + $this->guestSessionMapper->insert($session); + } + + return 'guest:' . $guestToken; + } + + /** + * Get the display name for a guest author ID + * + * @param string $authorId Author ID in the format "guest:" + * @return string|null Display name, or null if not found + */ + public function getGuestDisplayName(string $authorId): ?string { + if (!self::isGuestAuthor($authorId)) { + return null; + } + + $token = substr($authorId, 6); // Remove "guest:" prefix + try { + $session = $this->guestSessionMapper->findByToken($token); + return $session->getDisplayName(); + } catch (DoesNotExistException $e) { + return null; + } + } + + /** + * Get guest session by token, creating one if it does not exist + * + * @param string $guestToken 32-character hex token + * @return GuestSession + * @throws \InvalidArgumentException If token is invalid + */ + public function getOrCreateSession(string $guestToken): GuestSession { + if (!preg_match('/^[0-9a-f]{32}$/', $guestToken)) { + throw new \InvalidArgumentException('Invalid guest token format'); + } + + try { + return $this->guestSessionMapper->findByToken($guestToken); + } catch (DoesNotExistException $e) { + $session = new GuestSession(); + $session->setSessionToken($guestToken); + $session->setDisplayName($this->generateGuestName()); + $session->setCreatedAt(time()); + /** @var GuestSession */ + return $this->guestSessionMapper->insert($session); + } + } + + /** + * Check if an author ID represents a guest user + */ + public static function isGuestAuthor(string $authorId): bool { + return str_starts_with($authorId, 'guest:'); + } + + /** + * Generate a unique guest display name + * Format: AdjectiveNounNN (e.g., "BrightMountain42") + */ + private function generateGuestName(): string { + $maxAttempts = 20; + for ($i = 0; $i < $maxAttempts; $i++) { + $adjective = self::ADJECTIVES[array_rand(self::ADJECTIVES)]; + $noun = self::NOUNS[array_rand(self::NOUNS)]; + $number = str_pad((string)random_int(0, 99), 2, '0', STR_PAD_LEFT); + $name = $adjective . $noun . $number; + + if (!$this->guestSessionMapper->displayNameExists($name)) { + return $name; + } + } + + // Fallback: use timestamp-based name + return 'Guest' . dechex(time()) . str_pad((string)random_int(0, 99), 2, '0', STR_PAD_LEFT); + } +} diff --git a/lib/Service/UserService.php b/lib/Service/UserService.php index 8a8dfab..09e67db 100644 --- a/lib/Service/UserService.php +++ b/lib/Service/UserService.php @@ -9,6 +9,7 @@ namespace OCA\Forum\Service; use OCA\Forum\Db\BBCodeMapper; use OCA\Forum\Db\ForumUserMapper; +use OCA\Forum\Db\Role; use OCA\Forum\Db\RoleMapper; use OCA\Forum\Db\UserRoleMapper; use OCP\AppFramework\Db\DoesNotExistException; @@ -27,6 +28,7 @@ class UserService { private UserRoleMapper $userRoleMapper, private BBCodeMapper $bbCodeMapper, private BBCodeService $bbCodeService, + private GuestService $guestService, private IL10N $l10n, ) { } @@ -75,6 +77,26 @@ class UserService { * @return array{userId: string, displayName: string, isDeleted: bool, roles: array, signature: ?string, signatureRaw: ?string} */ public function enrichUserData(string $userId, ?array $roles = null, ?array $bbcodes = null): array { + // Handle guest authors + if (GuestService::isGuestAuthor($userId)) { + $guestDisplayName = $this->guestService->getGuestDisplayName($userId) ?? $this->l10n->t('Guest'); + try { + $guestRole = $this->roleMapper->findByRoleType(Role::ROLE_TYPE_GUEST); + $guestRoles = [$guestRole->jsonSerialize()]; + } catch (\Exception $e) { + $guestRoles = []; + } + return [ + 'userId' => $userId, + 'displayName' => $guestDisplayName, + 'isDeleted' => false, + 'isGuest' => true, + 'roles' => $guestRoles, + 'signature' => null, + 'signatureRaw' => null, + ]; + } + $isDeleted = $this->isUserDeleted($userId); $displayName = $this->getUserDisplayName($userId); @@ -130,38 +152,77 @@ class UserService { public function enrichMultipleUsers(array $userIds, ?array $rolesMap = null, ?array $bbcodes = null): array { $result = []; - // If roles not provided, fetch them all at once - if ($rolesMap === null) { - $rolesMap = $this->fetchRolesForUsers($userIds); - } - - // Fetch all forum users at once for signatures - $signaturesMap = $this->fetchSignaturesForUsers($userIds); - - // Fetch BBCodes once for parsing all signatures (if not provided) - if ($bbcodes === null) { - $bbcodes = $this->bbCodeMapper->findAllEnabled(); - } - + // Separate guest and real user IDs + $guestIds = []; + $realUserIds = []; foreach ($userIds as $userId) { - $isDeleted = $this->isUserDeleted($userId); - $displayName = $this->getUserDisplayName($userId); + if (GuestService::isGuestAuthor($userId)) { + $guestIds[] = $userId; + } else { + $realUserIds[] = $userId; + } + } - $signatureRaw = $signaturesMap[$userId] ?? null; - $signature = null; - if ($signatureRaw !== null && $signatureRaw !== '') { - $signature = $this->bbCodeService->parse($signatureRaw, $bbcodes); + // Handle guest users + if (!empty($guestIds)) { + $guestRoles = []; + try { + $guestRole = $this->roleMapper->findByRoleType(Role::ROLE_TYPE_GUEST); + $guestRoles = [$guestRole->jsonSerialize()]; + } catch (\Exception $e) { + // Guest role not found } - $result[$userId] = [ - 'userId' => $userId, - 'displayName' => $displayName, - 'isDeleted' => $isDeleted, - 'roles' => $rolesMap[$userId] ?? [], - 'signature' => $signature, - 'signatureRaw' => $signatureRaw, - ]; + foreach ($guestIds as $guestId) { + $guestDisplayName = $this->guestService->getGuestDisplayName($guestId) ?? $this->l10n->t('Guest'); + $result[$guestId] = [ + 'userId' => $guestId, + 'displayName' => $guestDisplayName, + 'isDeleted' => false, + 'isGuest' => true, + 'roles' => $guestRoles, + 'signature' => null, + 'signatureRaw' => null, + ]; + } } + + // Handle real users + if (!empty($realUserIds)) { + // If roles not provided, fetch them all at once + if ($rolesMap === null) { + $rolesMap = $this->fetchRolesForUsers($realUserIds); + } + + // Fetch all forum users at once for signatures + $signaturesMap = $this->fetchSignaturesForUsers($realUserIds); + + // Fetch BBCodes once for parsing all signatures (if not provided) + if ($bbcodes === null) { + $bbcodes = $this->bbCodeMapper->findAllEnabled(); + } + + foreach ($realUserIds as $userId) { + $isDeleted = $this->isUserDeleted($userId); + $displayName = $this->getUserDisplayName($userId); + + $signatureRaw = $signaturesMap[$userId] ?? null; + $signature = null; + if ($signatureRaw !== null && $signatureRaw !== '') { + $signature = $this->bbCodeService->parse($signatureRaw, $bbcodes); + } + + $result[$userId] = [ + 'userId' => $userId, + 'displayName' => $displayName, + 'isDeleted' => $isDeleted, + 'roles' => $rolesMap[$userId] ?? [], + 'signature' => $signature, + 'signatureRaw' => $signatureRaw, + ]; + } + } + return $result; } diff --git a/openapi-full.json b/openapi-full.json index 5fbb4b8..f408a4e 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -4840,6 +4840,96 @@ } } }, + "/ocs/v2.php/apps/forum/api/guest/me": { + "get": { + "operationId": "guest-me", + "summary": "Get or create a guest identity", + "tags": [ + "guest" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "guestToken", + "in": "query", + "description": "32-character hex token", + "required": true, + "schema": { + "type": "string" + } + }, + { + "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": "Guest identity returned", + "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": [ + "displayName", + "guestToken", + "isGuest" + ], + "properties": { + "displayName": { + "type": "string" + }, + "guestToken": { + "type": "string" + }, + "isGuest": { + "type": "boolean", + "enum": [ + true + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/forum/api/threads/{threadId}/posts": { "get": { "operationId": "post-by-thread", @@ -5561,6 +5651,7 @@ "post" ], "security": [ + {}, { "bearer_auth": [] }, @@ -5587,6 +5678,11 @@ "content": { "type": "string", "description": "Post content" + }, + "guestToken": { + "type": "string", + "default": "", + "description": "Guest session token (32-char hex, for unauthenticated users)" } } } @@ -5638,34 +5734,6 @@ } } } - }, - "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": {} - } - } - } - } - } - } } } } @@ -8651,6 +8719,7 @@ "thread" ], "security": [ + {}, { "bearer_auth": [] }, @@ -8682,6 +8751,11 @@ "content": { "type": "string", "description": "Initial post content" + }, + "guestToken": { + "type": "string", + "default": "", + "description": "Guest session token (32-char hex, for unauthenticated users)" } } } @@ -8733,34 +8807,6 @@ } } } - }, - "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": {} - } - } - } - } - } - } } } } diff --git a/openapi.json b/openapi.json index 40d7ee9..26360cd 100644 --- a/openapi.json +++ b/openapi.json @@ -4840,6 +4840,96 @@ } } }, + "/ocs/v2.php/apps/forum/api/guest/me": { + "get": { + "operationId": "guest-me", + "summary": "Get or create a guest identity", + "tags": [ + "guest" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "guestToken", + "in": "query", + "description": "32-character hex token", + "required": true, + "schema": { + "type": "string" + } + }, + { + "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": "Guest identity returned", + "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": [ + "displayName", + "guestToken", + "isGuest" + ], + "properties": { + "displayName": { + "type": "string" + }, + "guestToken": { + "type": "string" + }, + "isGuest": { + "type": "boolean", + "enum": [ + true + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/forum/api/threads/{threadId}/posts": { "get": { "operationId": "post-by-thread", @@ -5561,6 +5651,7 @@ "post" ], "security": [ + {}, { "bearer_auth": [] }, @@ -5587,6 +5678,11 @@ "content": { "type": "string", "description": "Post content" + }, + "guestToken": { + "type": "string", + "default": "", + "description": "Guest session token (32-char hex, for unauthenticated users)" } } } @@ -5638,34 +5734,6 @@ } } } - }, - "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": {} - } - } - } - } - } - } } } } @@ -8651,6 +8719,7 @@ "thread" ], "security": [ + {}, { "bearer_auth": [] }, @@ -8682,6 +8751,11 @@ "content": { "type": "string", "description": "Initial post content" + }, + "guestToken": { + "type": "string", + "default": "", + "description": "Guest session token (32-char hex, for unauthenticated users)" } } } @@ -8733,34 +8807,6 @@ } } } - }, - "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": {} - } - } - } - } - } - } } } } diff --git a/src/axios.ts b/src/axios.ts index ab372e6..0c55d30 100644 --- a/src/axios.ts +++ b/src/axios.ts @@ -1,10 +1,27 @@ import { generateOcsUrl } from '@nextcloud/router' import _axios from '@nextcloud/axios' +import { getCurrentUser } from '@nextcloud/auth' const baseURL = generateOcsUrl('/apps/forum/api') export const http = _axios.create({ baseURL }) export const ocs = _axios.create({ baseURL }) export const webDav = _axios.create({ baseURL: '' }) + +// Inject guestToken for unauthenticated users +ocs.interceptors.request.use((config) => { + if (getCurrentUser() === null) { + const guestToken = localStorage.getItem('forum_guest_token') + if (guestToken) { + if (config.method === 'get' || config.method === 'GET') { + config.params = { ...config.params, guestToken } + } else { + config.data = { ...config.data, guestToken } + } + } + } + return config +}) + ocs.interceptors.response.use( (response) => { const ocsData = response?.data?.ocs?.data diff --git a/src/components/AppNavigation/AppNavigation.vue b/src/components/AppNavigation/AppNavigation.vue index e341934..eea3204 100644 --- a/src/components/AppNavigation/AppNavigation.vue +++ b/src/components/AppNavigation/AppNavigation.vue @@ -188,6 +188,20 @@ + @@ -219,6 +233,7 @@ import { useCategories } from '@/composables/useCategories' import { useCurrentUser } from '@/composables/useCurrentUser' import { useUserRole } from '@/composables/useUserRole' import { useCurrentThread } from '@/composables/useCurrentThread' +import { useGuestSession } from '@/composables/useGuestSession' import type { Category } from '@/types' export default defineComponent({ @@ -250,6 +265,7 @@ export default defineComponent({ const { userId, displayName, fetchCurrentUser } = useCurrentUser() const { canAccessAdmin, canEditRoles, canEditCategories, fetchUserRoles } = useUserRole() const { categoryId: currentThreadCategoryId, fetchThread, clearThread } = useCurrentThread() + const { isGuest, guestDisplayName, fetchGuestIdentity } = useGuestSession() return { categoryHeaders, @@ -264,6 +280,9 @@ export default defineComponent({ currentThreadCategoryId, fetchThread, clearThread, + isGuest, + guestDisplayName, + fetchGuestIdentity, } }, data() { @@ -289,6 +308,7 @@ export default defineComponent({ navAdminBBCodes: t('forum', 'BBCodes'), expand: t('forum', 'Expand'), collapse: t('forum', 'Collapse'), + guestLabel: t('forum', '(Guest)'), }, } }, @@ -310,6 +330,11 @@ export default defineComponent({ await this.fetchUserRoles(userResult.value.userId).catch((e) => { console.error('Failed to load user roles:', e) }) + } else if (this.isGuest) { + // Fetch guest identity for sidebar display + await this.fetchGuestIdentity().catch((e) => { + console.error('Failed to load guest identity:', e) + }) } // Log any errors from categories fetch diff --git a/src/components/PostCard/PostCard.vue b/src/components/PostCard/PostCard.vue index 4cb4eb9..ba7e264 100644 --- a/src/components/PostCard/PostCard.vue +++ b/src/components/PostCard/PostCard.vue @@ -7,6 +7,7 @@ :user-id="post.author?.userId || post.authorId" :display-name="post.author?.displayName || post.authorId" :is-deleted="post.author?.isDeleted || false" + :is-guest="post.author?.isGuest || false" :avatar-size="32" :roles="post.author?.roles || []" > diff --git a/src/components/PostReplyForm/PostReplyForm.test.ts b/src/components/PostReplyForm/PostReplyForm.test.ts index 546f7e7..aa0474c 100644 --- a/src/components/PostReplyForm/PostReplyForm.test.ts +++ b/src/components/PostReplyForm/PostReplyForm.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' +import { ref, computed } from 'vue' import { createIconMock, createComponentMock } from '@/test-utils' import PostReplyForm from './PostReplyForm.vue' @@ -24,7 +25,7 @@ vi.mock('@/components/BBCodeEditor', () => vi.mock('@/components/UserInfo', () => createComponentMock('UserInfo', { template: '', - props: ['userId', 'displayName', 'avatarSize', 'clickable'], + props: ['userId', 'displayName', 'avatarSize', 'clickable', 'isGuest'], }), ) @@ -47,6 +48,14 @@ vi.mock('@/composables/useCurrentUser', () => ({ }), })) +// Mock useGuestSession composable +vi.mock('@/composables/useGuestSession', () => ({ + useGuestSession: () => ({ + isGuest: computed(() => false), + guestDisplayName: ref(null), + }), +})) + describe('PostReplyForm', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/components/PostReplyForm/PostReplyForm.vue b/src/components/PostReplyForm/PostReplyForm.vue index 400c95f..36a836b 100644 --- a/src/components/PostReplyForm/PostReplyForm.vue +++ b/src/components/PostReplyForm/PostReplyForm.vue @@ -1,11 +1,12 @@