Skip to content

Google Calendar/Meet — Pro slots + OAuth scope (free side)#625

Closed
anik-fahmid wants to merge 60 commits into
developfrom
feature/google-workspace-calendar
Closed

Google Calendar/Meet — Pro slots + OAuth scope (free side)#625
anik-fahmid wants to merge 60 commits into
developfrom
feature/google-workspace-calendar

Conversation

@anik-fahmid

@anik-fahmid anik-fahmid commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Builds on the Drive PR. Adds free-side scaffolding for Pro Calendar/Meet: extension slots + upgrade covers on the G Workspace page and settings, incremental Calendar OAuth scope (calendar.events), per-user service prefs, disconnect cleanup hook + confirm modal. (Includes the Drive PR commits; merge after/with Drive.)

Summary by CodeRabbit

  • New Features
    • Added a “G Workspace” admin section for OAuth/API setup, with Drive and Drive-in-comments toggles.
    • Enabled per-user Google connection management (connect/disconnect) and surfaced OAuth/feature readiness.
    • Added Google Drive attachments with Drive Picker for projects/tasks/discussions/comments, including per-project access controls and attachment add/remove.
    • Enhanced comment/discussion composers with Drive link insertion and improved Google Drive/Docs + Google Meet rendering and activity icons.
  • Bug Fixes
    • Prevented the task sheet from closing during Google Picker interactions.
    • Improved activity logging so Drive/Meet metadata is only added when appropriate.

Free feature, always on. Lets each user connect their own Google account
and attach Drive files to tasks by reference (drive.file scope).

- OAuth2 connect/disconnect via WP HTTP API (no SDK); per-user tokens
  encrypted at rest (AES-256-CBC, key from WP salts), auto-refresh.
- Admin sets site-level OAuth credentials + Picker keys (Client ID/Secret,
  API key, App ID). Settings_Page_Access gated.
- Google Picker (client-side) for browsing under least-privilege drive.file;
  setOrigin + z-index/pointer-events handling for in-admin embedding.
- Task detail "Google Drive" section: attach/list/detach file references.
- DB tables wp_pm_google_tokens, wp_pm_google_drive_files (idempotent install
  + last_used_at self-heal).
- 60-day stale-token purge via daily cron.
- uninstall.php removes Google Workspace tables/options/cron only.
- Graceful reconnect when site salts rotate (undecryptable tokens purged).
- Browser guidance for third-party-cookie blocking (Chrome/Brave/Safari).

Files: src/Google_Workspace/* (Loader, Google_Client, Google_Service,
Models, Controllers), routes/google-workspace.php, React store slice +
components/google-workspace/*, registrations in index.jsx, sidebar nav,
TaskDetailSheet picker-aware outside-close guard, Create_Table + start.php
bootstrap, uninstall.php.
- Move site-level OAuth credentials into Settings → Google Workspace tab
  (new GoogleWorkspaceSettingsTab; admin-only via the Settings page gate).
- Google Workspace page is now per-user account connection only
  (connect/disconnect/status) + a features overview (Drive available;
  Calendar/Meet coming soon) so future features slot in cleanly.
- One account connection powers all Google Workspace features.
- Sidebar nav uses the Google Drive logo instead of the disk icon.

No backend changes; reuses existing settings/status/auth endpoints.
…Google Drive (free) / Google Workspace (pro)
…ly when allowed) + enforce detach permission
…cussion/project) + shared GoogleDriveAttach component
…edit/delete (order: drive, edit, delete); chips render below via showAdd=false
…n; discussion/file headers show icon + count only
…Calendar & Meet

- G Workspace page: Calendar/Meet rows are now Slots (google.workspace.feature.{calendar,meet}); free shows a clickable Pro upgrade teaser, Pro fills them.
- Settings tab: Calendar sync + Meet groups shown as locked Pro teasers (google.workspace.settings.{calendar,meet} slots) behind the upgrade modal.
- Drive remains the free feature; Calendar/Meet reserved for Pro.
- Google_Client: add CALENDAR_SCOPE; get_auth_url() accepts a scope override.
- OAuth_Controller: auth-url honors with_calendar (requests calendar.events alongside drive.file via include_granted_scopes); status returns calendar_connected.
- Google_Service: user_has_scope()/user_has_calendar(); Loader localizes calendar_connected.
- Slice getAuthUrl({withCalendar}); expose GW thunks (fetchStatus/getAuthUrl/disconnect) on window.PM for Pro.
…ce page

- Calendar is now a card section on the G Workspace page (slot google.workspace.feature.calendar), Pro fills it with sync settings + connect; free shows a Pro cover card.
- Drop the Calendar/Meet teasers from the admin settings tab (creds-only again).
…; sidebar label 'Google Workspace' (free+pro)

- Sidebar nav label unified to 'Google Workspace' for free and pro.
- Workspace page now shows Connected services cards: Google Drive (free, status) + Calendar/Meet slots (Pro connect cards, free covers).
- Calendar config (enable + sync options) moved back to Settings -> Google Workspace (admin) via the settings.calendar slot.
- Drive card gets a per-user on/off (user meta pm_gws_drive_on); user_can_use_drive respects it; status/localize expose drive_user_on; new POST google-workspace/my-prefs; saveDrivePref thunk.
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fd90a1e2-5133-40a2-b22a-1010fbe295f4

📥 Commits

Reviewing files that changed from the base of the PR and between 0d23756 and 6d7fc47.

📒 Files selected for processing (10)
  • core/WP/Menu.php
  • routes/google-workspace.php
  • src/Google_Workspace/Controllers/Drive_Controller.php
  • src/Google_Workspace/Controllers/OAuth_Controller.php
  • src/Google_Workspace/Controllers/Settings_Controller.php
  • src/Google_Workspace/Google_Service.php
  • src/Google_Workspace/Loader.php
  • views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx
  • views/assets/src/components/layout/AppSidebar.jsx
  • views/assets/src/store/googleWorkspaceSlice.js
💤 Files with no reviewable changes (1)
  • src/Google_Workspace/Controllers/Drive_Controller.php
🚧 Files skipped from review as they are similar to previous changes (7)
  • routes/google-workspace.php
  • views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx
  • src/Google_Workspace/Controllers/Settings_Controller.php
  • views/assets/src/store/googleWorkspaceSlice.js
  • src/Google_Workspace/Controllers/OAuth_Controller.php
  • src/Google_Workspace/Loader.php
  • src/Google_Workspace/Google_Service.php

Walkthrough

Adds Google Workspace OAuth, Drive attachment, settings, and UI wiring across PHP services, REST endpoints, Redux state, and React screens. It also introduces Google link decoration, activity metadata, and attachment support in task, discussion, and comment flows.

Changes

Google Workspace Integration

Layer / File(s) Summary
Models and schema
src/Google_Workspace/Models/Google_Token.php, src/Google_Workspace/Models/Google_Drive_File.php, src/Google_Workspace/Loader.php, db/Create_Table.php, bootstrap/start.php, uninstall.php
Defines the Google token and Drive file models and adds table creation, migration, versioning, cleanup, and uninstall deletion for the Google Workspace tables.
OAuth client and service
src/Google_Workspace/Google_Client.php, src/Google_Workspace/Google_Service.php
Implements the Google OAuth client, token exchange and refresh flows, encrypted token storage, per-project access checks, connection state, and access-token refresh logic.
REST routes and controllers
routes/google-workspace.php, src/Google_Workspace/Controllers/Settings_Controller.php, src/Google_Workspace/Controllers/OAuth_Controller.php, src/Google_Workspace/Controllers/Drive_Controller.php
Registers Google Workspace REST routes and implements site settings, per-user OAuth state, picker config, project access, and Drive attachment endpoints.
Redux slice and app wiring
views/assets/src/store/googleWorkspaceSlice.js, views/assets/src/store/index.js, views/assets/src/index.jsx
Adds the Google Workspace Redux slice, registers it in the root reducer, and exposes the slice, components, and utilities through the app entry point.
Admin settings and Workspace page
views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx, views/assets/src/components/admin-settings/SettingsPage.jsx, views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx, views/assets/src/components/layout/AppSidebar.jsx, core/WP/Menu.php
Adds the Google Workspace admin settings tab, the user-facing Workspace page, and the sidebar and admin menu navigation entries.
Picker and attachment components
views/assets/src/components/google-workspace/DrivePickerModal.jsx, views/assets/src/components/google-workspace/GoogleDriveAttach.jsx, views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx, views/assets/src/components/google-workspace/GoogleDriveStage.jsx, views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx, views/assets/src/components/google-workspace/GoogleIcons.jsx
Adds the Google Picker modal, attachment renderers, comment attach triggers, staging UI, task wrapper, and Google icon components.
Comment and activity rendering
views/assets/src/lib/google-links.js, src/Activity/Transformers/Activity_Transformer.php, src/Comment/Observers/Comment_Observer.php, views/assets/src/components/projects/ActivitiesPage/constants.js, views/assets/src/components/projects/ActivitiesPage/parts/ActivityItem.jsx, views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx, views/assets/src/components/projects/DiscussionsPage/index.jsx, views/assets/src/components/tasks/SingleTaskListPage.jsx, views/assets/src/components/tasks/TaskDetailSheet/index.jsx
Decorates Google links in rendered HTML, adds comment link actions, stages discussion attachments on create, and shows Drive/Meet activity metadata across task and discussion views.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

Needs Dev Review, Needs Testing

Suggested reviewers

  • iftakharul-islam

Poem

🐇 A Drive tab hopped into the nest,
With tokens tucked away safe and blessed.
Pick a file, share a link,
Google links now sparkle and wink ✨
Hop-hop — the workspace feels brand new.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.45% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches a real subset of the PR: Google Workspace Pro slots and OAuth scope work for Calendar/Meet.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/google-workspace-calendar

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (6)
src/Google_Workspace/Google_Service.php (1)

265-285: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider chunked iteration for stale token purge.

Google_Token::all() loads every token row into memory. On sites with many users, this could cause memory pressure during the daily cron run. A chunked approach avoids loading the entire table at once.

♻️ Proposed chunked iteration
     public static function purge_stale( $days = 60 ) {
         $cutoff = Carbon::now()->subDays( $days );
         $purged = 0;
 
-        $tokens = Google_Token::all();
-        foreach ( $tokens as $token ) {
+        Google_Token::chunk( 100, function ( $tokens ) use ( $cutoff, &$purged ) {
+            foreach ( $tokens as $token ) {
             $marker = $token->last_used_at ?: $token->updated_at ?: $token->created_at;
             if ( ! $marker || ! Carbon::parse( $marker )->lessThan( $cutoff ) ) {
                 continue;
             }
 
             $refresh = self::decrypt( $token->refresh_token );
             if ( $refresh ) {
                 self::client()->revoke( $refresh );
             }
             $token->delete();
             $purged++;
-        }
+            }
+        } );
 
         return $purged;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Google_Workspace/Google_Service.php` around lines 265 - 285, The
purge_stale method loads all Google_Token records into memory at once using
Google_Token::all(), which can cause memory pressure on sites with many users.
Replace the all() call with a chunked iteration approach that processes tokens
in smaller batches instead of loading the entire table at once. This will allow
the daily cron run to process stale tokens without memory issues while
maintaining the same purge logic.
views/assets/src/components/google-workspace/GoogleDriveAttach.jsx (1)

17-17: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Route notifications through the shared useToast hook.

Using the wrapper keeps toast behavior and integration consistent across screens.

As per coding guidelines, "Use custom useToast hook for showing notifications".

Also applies to: 82-82

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@views/assets/src/components/google-workspace/GoogleDriveAttach.jsx` at line
17, The GoogleDriveAttach component is directly importing toast from 'sonner'
instead of using the custom useToast hook. Replace the direct import of toast
from 'sonner' with an import of the useToast hook, then replace all direct toast
calls (including the one around line 82) with the useToast hook invocation to
maintain consistency with the codebase's notification patterns and ensure all
toast behavior is properly integrated through the shared wrapper.

Source: Coding guidelines

views/assets/src/components/google-workspace/DrivePickerModal.jsx (1)

13-13: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Use the shared useToast hook instead of importing sonner directly.

This keeps notification behavior consistent with the rest of the app and project conventions.

As per coding guidelines, "Use custom useToast hook for showing notifications".

Suggested refactor
-import { toast } from 'sonner'
+import { useToast } from '`@hooks/useToast`'

 export default function DrivePickerModal({ projectId, attachableType, attachableId, attachedIds = [], onClose, onPicked }) {
   const dispatch = useAppDispatch()
+  const toast = useToast()
   const launchedRef = useRef(false)

Also applies to: 60-60, 96-97, 143-143

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@views/assets/src/components/google-workspace/DrivePickerModal.jsx` at line
13, Replace the direct import of toast from 'sonner' with the custom useToast
hook to maintain consistency with project conventions. In the DrivePickerModal
component, replace the sonner import statement with an import of the useToast
hook, then call this hook inside the component to get the toast function, and
update all toast method calls throughout the file (including the instances at
lines 60, 96-97, and 143) to use the toast function returned by the hook instead
of the imported one.

Source: Coding guidelines

views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx (2)

16-18: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Use useConfirm for the disconnect confirmation flow.

The custom AlertDialog here should be routed through the shared confirm hook to stay consistent with the project’s confirmation contract.

As per coding guidelines, "Use custom useConfirm hook for confirmation dialogs."

Also applies to: 204-225

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx` around
lines 16 - 18, Replace the direct usage of AlertDialog components (AlertDialog,
AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription,
AlertDialogFooter, AlertDialogCancel, AlertDialogAction) with the shared
useConfirm hook for the disconnect confirmation flow in the GoogleWorkspacePage
component. Remove the AlertDialog import statement and instead import and use
the useConfirm hook to handle the confirmation dialog, ensuring consistency with
the project's confirmation contract and coding guidelines.

Source: Coding guidelines


22-23: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Use the shared toast abstraction for notifications.

Line 22 and the toast calls in this file bypass the project’s useToast hook.

As per coding guidelines, "Use custom useToast hook for showing notifications."

Also applies to: 93-93, 106-113

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx` around
lines 22 - 23, Remove the direct import of toast from 'sonner' on line 22 and
replace it with an import of the custom useToast hook from the project's hooks.
Then, update all toast function calls throughout the GoogleWorkspacePage
component (including those at lines 93 and 106-113) to use the custom useToast
hook instead of calling toast directly from sonner, ensuring consistency with
the project's notification abstraction pattern.

Source: Coding guidelines

views/assets/src/components/admin-settings/SettingsPage.jsx (1)

48-48: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Use an alias import for the new tab component.

Line 48 introduces a relative import, but this project’s React guidelines require alias-based paths.

Suggested change
-const GoogleWorkspaceSettingsTab = lazy(() => import('./tabs/GoogleWorkspaceSettingsTab'))
+const GoogleWorkspaceSettingsTab = lazy(() => import('`@components/admin-settings/tabs/GoogleWorkspaceSettingsTab`'))

As per coding guidelines, "Use route aliases in webpack: @src/, @components, @store, @hooks, @lib."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@views/assets/src/components/admin-settings/SettingsPage.jsx` at line 48, The
import statement for GoogleWorkspaceSettingsTab uses a relative path
(`./tabs/GoogleWorkspaceSettingsTab`) instead of an alias-based import as
required by the project's React guidelines. Replace the relative import path in
the lazy import for GoogleWorkspaceSettingsTab with the appropriate alias path
using `@components` (or the correct alias mapping for where this tab component
is located) to follow the project's webpack alias conventions that map `@` to
`src/` and provide scoped aliases like `@components`, `@store`, `@hooks`, and
`@lib`.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@routes/google-workspace.php`:
- Around line 12-62: All 15 routes in this file are missing input validation and
sanitization. For each route definition (including those calling
Settings_Controller@get, Settings_Controller@save, OAuth_Controller methods,
Drive_Controller methods, etc.), add both ->validator() and ->sanitizer() method
chains after the ->permission() call. This applies to all route types—GET, POST,
and DELETE—ensuring proper input handling and security across the entire Google
Workspace API surface.

In
`@views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx`:
- Around line 40-43: Replace the clickable div element (the one with onClick={()
=> setOpen(true)} and the border-gray-200 styling) with a shadcn/ui button
component instead. Transfer the className styling and onClick handler to the
button component. Ensure the button maintains the same visual appearance and
hover behavior as the current div, and verify that keyboard users can now
trigger the interaction using Enter or Space keys.

In `@views/assets/src/components/google-workspace/GoogleDriveAttach.jsx`:
- Around line 156-158: The remove action buttons in GoogleDriveAttach.jsx are
missing an explicit type attribute, causing them to default to type="submit" and
inadvertently submit enclosing forms when users click to remove attachments. Add
type="button" to both button elements - the one in the diff around line 156 that
calls onDetach(file.id) and the other one around line 196 (also mentioned in
"Also applies to") to ensure these buttons function as regular buttons without
form submission behavior.

In `@views/assets/src/components/layout/AppSidebar.jsx`:
- Around line 250-254: The AppSidebar component adds a new Google Workspace
navigation item with key 'google-workspace' and route '/google-workspace', but
the activeKey logic does not include handling for this new route. Find where
activeKey is computed or determined in the AppSidebar component and add a
condition to map the '/google-workspace' route to the 'google-workspace' key so
that the navigation item appears active when the user is viewing that route.
Ensure the activeKey logic includes the same route-to-key mapping pattern used
for other navigation items in the component.

In `@views/assets/src/components/projects/DiscussionsPage/index.jsx`:
- Line 74: The stagedDrive state initialized in the component is being reset
after successful discussion creation at line 138, but not when the form is
canceled or closed, allowing previously selected Drive files to persist in the
next draft. Find all cancel and close handler paths in the component and add
code to clear stagedDrive by calling setStagedDrive([]) in each of those paths,
similar to what is done after successful creation. The comment indicates this
issue also applies to another state variable at line 247, so apply the same fix
pattern there as well.

In `@views/assets/src/store/googleWorkspaceSlice.js`:
- Line 189: In the saveSettings.fulfilled case reducer, the drive_comments_on
status field is being assigned a.payload.drive_comments directly without
normalizing it to a boolean. This causes strict equality checks to fail when the
backend returns numeric values (0 or 1) instead of actual booleans. Convert
a.payload.drive_comments to a boolean value before assigning it to
s.status.drive_comments_on using a boolean coercion method like double negation
or the Boolean constructor to ensure reliable boolean comparisons throughout the
component.

---

Nitpick comments:
In `@src/Google_Workspace/Google_Service.php`:
- Around line 265-285: The purge_stale method loads all Google_Token records
into memory at once using Google_Token::all(), which can cause memory pressure
on sites with many users. Replace the all() call with a chunked iteration
approach that processes tokens in smaller batches instead of loading the entire
table at once. This will allow the daily cron run to process stale tokens
without memory issues while maintaining the same purge logic.

In `@views/assets/src/components/admin-settings/SettingsPage.jsx`:
- Line 48: The import statement for GoogleWorkspaceSettingsTab uses a relative
path (`./tabs/GoogleWorkspaceSettingsTab`) instead of an alias-based import as
required by the project's React guidelines. Replace the relative import path in
the lazy import for GoogleWorkspaceSettingsTab with the appropriate alias path
using `@components` (or the correct alias mapping for where this tab component
is located) to follow the project's webpack alias conventions that map `@` to
`src/` and provide scoped aliases like `@components`, `@store`, `@hooks`, and
`@lib`.

In `@views/assets/src/components/google-workspace/DrivePickerModal.jsx`:
- Line 13: Replace the direct import of toast from 'sonner' with the custom
useToast hook to maintain consistency with project conventions. In the
DrivePickerModal component, replace the sonner import statement with an import
of the useToast hook, then call this hook inside the component to get the toast
function, and update all toast method calls throughout the file (including the
instances at lines 60, 96-97, and 143) to use the toast function returned by the
hook instead of the imported one.

In `@views/assets/src/components/google-workspace/GoogleDriveAttach.jsx`:
- Line 17: The GoogleDriveAttach component is directly importing toast from
'sonner' instead of using the custom useToast hook. Replace the direct import of
toast from 'sonner' with an import of the useToast hook, then replace all direct
toast calls (including the one around line 82) with the useToast hook invocation
to maintain consistency with the codebase's notification patterns and ensure all
toast behavior is properly integrated through the shared wrapper.

In `@views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx`:
- Around line 16-18: Replace the direct usage of AlertDialog components
(AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction)
with the shared useConfirm hook for the disconnect confirmation flow in the
GoogleWorkspacePage component. Remove the AlertDialog import statement and
instead import and use the useConfirm hook to handle the confirmation dialog,
ensuring consistency with the project's confirmation contract and coding
guidelines.
- Around line 22-23: Remove the direct import of toast from 'sonner' on line 22
and replace it with an import of the custom useToast hook from the project's
hooks. Then, update all toast function calls throughout the GoogleWorkspacePage
component (including those at lines 93 and 106-113) to use the custom useToast
hook instead of calling toast directly from sonner, ensuring consistency with
the project's notification abstraction pattern.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 88ab8c16-d1b9-4f82-a5cd-7861ae7a3350

📥 Commits

Reviewing files that changed from the base of the PR and between fd1a3af and ac13e54.

📒 Files selected for processing (28)
  • bootstrap/start.php
  • db/Create_Table.php
  • routes/google-workspace.php
  • src/Google_Workspace/Controllers/Drive_Controller.php
  • src/Google_Workspace/Controllers/OAuth_Controller.php
  • src/Google_Workspace/Controllers/Settings_Controller.php
  • src/Google_Workspace/Google_Client.php
  • src/Google_Workspace/Google_Service.php
  • src/Google_Workspace/Loader.php
  • src/Google_Workspace/Models/Google_Drive_File.php
  • src/Google_Workspace/Models/Google_Token.php
  • uninstall.php
  • views/assets/src/components/admin-settings/SettingsPage.jsx
  • views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx
  • views/assets/src/components/google-workspace/DrivePickerModal.jsx
  • views/assets/src/components/google-workspace/GoogleDriveAttach.jsx
  • views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx
  • views/assets/src/components/google-workspace/GoogleDriveStage.jsx
  • views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx
  • views/assets/src/components/google-workspace/GoogleIcons.jsx
  • views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx
  • views/assets/src/components/layout/AppSidebar.jsx
  • views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx
  • views/assets/src/components/projects/DiscussionsPage/index.jsx
  • views/assets/src/components/tasks/TaskDetailSheet/index.jsx
  • views/assets/src/index.jsx
  • views/assets/src/store/googleWorkspaceSlice.js
  • views/assets/src/store/index.js

Comment on lines +12 to +62
$wedevs_pm_router->get( 'google-workspace/settings', $gw_base . 'Settings_Controller@get' )
->permission( [ $gw_admin ] );

$wedevs_pm_router->post( 'google-workspace/settings', $gw_base . 'Settings_Controller@save' )
->permission( [ $gw_admin ] );

// ── Per-user connection ──────────────────────────────────────────────
$wedevs_pm_router->get( 'google-workspace/status', $gw_base . 'OAuth_Controller@status' )
->permission( [ $gw_auth ] );

$wedevs_pm_router->get( 'google-workspace/auth-url', $gw_base . 'OAuth_Controller@auth_url' )
->permission( [ $gw_auth ] );

$wedevs_pm_router->post( 'google-workspace/disconnect', $gw_base . 'OAuth_Controller@disconnect' )
->permission( [ $gw_auth ] );

$wedevs_pm_router->post( 'google-workspace/my-prefs', $gw_base . 'OAuth_Controller@save_my_prefs' )
->permission( [ $gw_auth ] );

// ── Drive Picker config (vends caller's own access token + Picker keys) ──
$wedevs_pm_router->get( 'google-workspace/drive/picker-config', $gw_base . 'Drive_Controller@picker_config' )
->permission( [ $gw_auth ] );

// ── Per-project Drive role access (manager configures; members query) ──
$gw_manager = 'WeDevs\PM\Core\Permissions\Project_Manage_Capability';

$wedevs_pm_router->get( 'projects/{project_id}/google-workspace/access', $gw_base . 'Drive_Controller@get_access' )
->permission( [ $gw_manager ] );

$wedevs_pm_router->post( 'projects/{project_id}/google-workspace/access', $gw_base . 'Drive_Controller@save_access' )
->permission( [ $gw_manager ] );

$wedevs_pm_router->get( 'projects/{project_id}/google-workspace/can-use', $gw_base . 'Drive_Controller@can_use' )
->permission( [ $gw_access ] );

// ── Drive attachments (polymorphic: task, comment, discussion, project) ──
$wedevs_pm_router->get( 'projects/{project_id}/google-drive', $gw_base . 'Drive_Controller@index' )
->permission( [ $gw_access ] );

$wedevs_pm_router->post( 'projects/{project_id}/google-drive', $gw_base . 'Drive_Controller@attach' )
->permission( [ $gw_access ] );

$wedevs_pm_router->delete( 'projects/{project_id}/google-drive/{id}', $gw_base . 'Drive_Controller@destroy' )
->permission( [ $gw_access ] );

// Legacy task-scoped routes (kept for back-compat).
$wedevs_pm_router->get( 'projects/{project_id}/tasks/{task_id}/google-drive', $gw_base . 'Drive_Controller@index' )
->permission( [ $gw_access ] );

$wedevs_pm_router->post( 'projects/{project_id}/tasks/{task_id}/google-drive', $gw_base . 'Drive_Controller@attach' )
->permission( [ $gw_access ] );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if existing route files use validator/sanitizer methods
rg -n '\->validator\(' routes/ --type php | head -20
rg -n '\->sanitizer\(' routes/ --type php | head -20

Repository: weDevsOfficial/wp-project-manager

Length of output: 3407


🏁 Script executed:

cat -n routes/google-workspace.php

Repository: weDevsOfficial/wp-project-manager

Length of output: 3442


Missing ->validator() and ->sanitizer() on all endpoints.

Every REST endpoint must include both validator and sanitizer methods. All 15 routes in this file are missing these—including POST operations (settings, disconnect, my-prefs, access, attach) and DELETE (destroy), which especially require input validation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@routes/google-workspace.php` around lines 12 - 62, All 15 routes in this file
are missing input validation and sanitization. For each route definition
(including those calling Settings_Controller@get, Settings_Controller@save,
OAuth_Controller methods, Drive_Controller methods, etc.), add both
->validator() and ->sanitizer() method chains after the ->permission() call.
This applies to all route types—GET, POST, and DELETE—ensuring proper input
handling and security across the entire Google Workspace API surface.

Source: Coding guidelines

Comment on lines +40 to +43
<div
className="mb-4 flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4 cursor-pointer hover:border-gray-300"
onClick={() => setOpen(true)}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make the locked card trigger keyboard-accessible.

Line 40 uses a clickable div; keyboard users can’t reliably trigger it. Use a semantic button (or shadcn button wrapper) for this interaction.

Suggested change
-    <div
+    <button
+      type="button"
       className="mb-4 flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4 cursor-pointer hover:border-gray-300"
       onClick={() => setOpen(true)}
     >
@@
-    </div>
+    </button>

As per coding guidelines, "Use shadcn/ui components (Radix primitives) for UI elements."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
className="mb-4 flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4 cursor-pointer hover:border-gray-300"
onClick={() => setOpen(true)}
>
<button
type="button"
className="mb-4 flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4 cursor-pointer hover:border-gray-300"
onClick={() => setOpen(true)}
>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx`
around lines 40 - 43, Replace the clickable div element (the one with
onClick={() => setOpen(true)} and the border-gray-200 styling) with a shadcn/ui
button component instead. Transfer the className styling and onClick handler to
the button component. Ensure the button maintains the same visual appearance and
hover behavior as the current div, and verify that keyboard users can now
trigger the interaction using Enter or Space keys.

Source: Coding guidelines

Comment on lines +156 to +158
<button onClick={() => onDetach(file.id)} className="text-gray-400 hover:text-red-600" title={__('Remove', 'wedevs-project-manager')}>
<X className="h-3 w-3" />
</button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Set explicit type="button" on remove actions.

Line 156 and Line 196 currently use default button type, which can submit enclosing forms when users try to remove an attachment.

Suggested fix
-              <button onClick={() => onDetach(file.id)} className="text-gray-400 hover:text-red-600" title={__('Remove', 'wedevs-project-manager')}>
+              <button type="button" onClick={() => onDetach(file.id)} className="text-gray-400 hover:text-red-600" title={__('Remove', 'wedevs-project-manager')}>
                 <X className="h-3 w-3" />
               </button>
...
-                  <button onClick={() => onDetach(file.id)} className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600" title={__('Remove', 'wedevs-project-manager')}>
+                  <button type="button" onClick={() => onDetach(file.id)} className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600" title={__('Remove', 'wedevs-project-manager')}>
                     <Trash2 className="h-3.5 w-3.5" />
                   </button>

Also applies to: 196-198

🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 156-156: A list component should have a key to prevent re-rendering
Context:
Note: [CWE-710] Improper Adherence to Coding Standards. Security best practice.

(list-component-needs-key)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@views/assets/src/components/google-workspace/GoogleDriveAttach.jsx` around
lines 156 - 158, The remove action buttons in GoogleDriveAttach.jsx are missing
an explicit type attribute, causing them to default to type="submit" and
inadvertently submit enclosing forms when users click to remove attachments. Add
type="button" to both button elements - the one in the diff around line 156 that
calls onDetach(file.id) and the other one around line 196 (also mentioned in
"Also applies to") to ensure these buttons function as regular buttons without
form submission behavior.

Comment thread views/assets/src/components/layout/AppSidebar.jsx
Comment thread views/assets/src/components/projects/DiscussionsPage/index.jsx
.addCase(fetchSettings.rejected, (s) => { s.settingsLoading = false })

.addCase(saveSettings.pending, (s) => { s.saving = true })
.addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready; s.status.drive_enabled = a.payload.drive_enabled; s.status.drive_comments_on = a.payload.drive_comments })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize drive_comments_on to a boolean when saving settings.

Line 189 stores a.payload.drive_comments verbatim. If this is 0/1, strict checks like status.drive_comments_on === false will misbehave and show comment-drive UI when disabled.

Suggested fix
-      .addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready; s.status.drive_enabled = a.payload.drive_enabled; s.status.drive_comments_on = a.payload.drive_comments })
+      .addCase(saveSettings.fulfilled, (s, a) => {
+        s.saving = false
+        s.settings = a.payload
+        s.status.configured = !!a.payload.configured
+        s.status.picker_ready = !!a.payload.picker_ready
+        s.status.drive_enabled = !!a.payload.drive_enabled
+        s.status.drive_comments_on = !!a.payload.drive_comments
+      })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready; s.status.drive_enabled = a.payload.drive_enabled; s.status.drive_comments_on = a.payload.drive_comments })
.addCase(saveSettings.fulfilled, (s, a) => {
s.saving = false
s.settings = a.payload
s.status.configured = !!a.payload.configured
s.status.picker_ready = !!a.payload.picker_ready
s.status.drive_enabled = !!a.payload.drive_enabled
s.status.drive_comments_on = !!a.payload.drive_comments
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@views/assets/src/store/googleWorkspaceSlice.js` at line 189, In the
saveSettings.fulfilled case reducer, the drive_comments_on status field is being
assigned a.payload.drive_comments directly without normalizing it to a boolean.
This causes strict equality checks to fail when the backend returns numeric
values (0 or 1) instead of actual booleans. Convert a.payload.drive_comments to
a boolean value before assigning it to s.status.drive_comments_on using a
boolean coercion method like double negation or the Boolean constructor to
ensure reliable boolean comparisons throughout the component.

…d) + meet_connected status; getAuthUrl supports withMeet
…composers (new + edit); drop chips + per-comment attach button (CommentLinkActions)
…le-type icons + Meet card); expose decorateGoogleLinks on window.PM

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (1)
views/assets/src/store/googleWorkspaceSlice.js (1)

191-191: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalize boolean fields when applying saveSettings payload.

s.status.drive_comments_on = a.payload.drive_comments (and the adjacent configured/picker_ready/drive_enabled assignments) store the raw payload values. If the backend returns 0/1, strict checks like === false will misbehave. Coerce with !! before assigning.

Suggested fix
-      .addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.settings = a.payload; s.status.configured = a.payload.configured; s.status.picker_ready = a.payload.picker_ready; s.status.drive_enabled = a.payload.drive_enabled; s.status.drive_comments_on = a.payload.drive_comments })
+      .addCase(saveSettings.fulfilled, (s, a) => {
+        s.saving = false
+        s.settings = a.payload
+        s.status.configured = !!a.payload.configured
+        s.status.picker_ready = !!a.payload.picker_ready
+        s.status.drive_enabled = !!a.payload.drive_enabled
+        s.status.drive_comments_on = !!a.payload.drive_comments
+      })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@views/assets/src/store/googleWorkspaceSlice.js` at line 191, The
saveSettings.fulfilled handler in googleWorkspaceSlice is assigning raw payload
values into s.status fields, so boolean flags like configured, picker_ready,
drive_enabled, and drive_comments_on can end up as 0/1 instead of true/false.
Update the reducer case to coerce each of those payload properties to booleans
before storing them, keeping the behavior consistent with strict boolean checks
elsewhere.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/Activity/Transformers/Activity_Transformer.php`:
- Around line 155-157: In parse_meta_for_comment() within Activity_Transformer,
the early type check is causing the method to return immediately when it
receives an Activity object, so the has_drive and has_meet flags are never
added. Update the guard to handle the actual Activity instance (or its meta
payload) instead of requiring an array, and make sure the existing Google
Workspace flag logic for $activity->meta['has_drive'] and
$activity->meta['has_meet'] can run before returning the meta response.

In `@src/Comment/Observers/Comment_Observer.php`:
- Around line 76-93: The new Drive/Meet link metadata is only being added in
comment_on_task(), so other comment activity paths never receive has_drive /
has_meet. Update the shared comment activity flow in Comment_Observer so
gw_meta() is merged into the meta for all non-delete comment actions, including
discussions, milestones, projects, files, and task lists, while still skipping
deleted comments. Use the existing gw_meta() helper and the comment_on_task()
pattern to locate the right place to centralize this.

In `@src/Google_Workspace/Controllers/OAuth_Controller.php`:
- Line 27: The meet_connected value is using the calendar check, so the Meet
connection state will always mirror Calendar. Update the OAuth_Controller
response building logic to call Google_Service::user_has_meet() for
meet_connected instead of Google_Service::user_has_calendar(), keeping the
surrounding user status fields unchanged.

In `@views/assets/src/components/google-workspace/DriveCommentInsert.jsx`:
- Around line 22-26: The linkHtml function is building HTML with unescaped Drive
metadata, which can allow injected markup or attributes. Update linkHtml in
DriveCommentInsert.jsx to escape/sanitize both the href value from f.webViewLink
or f.url and the displayed label from f.name before interpolating them into the
anchor string. Keep the existing fallback to __('Drive file',
'wedevs-project-manager') and ensure any helper used for escaping is applied
consistently in this component.

In `@views/assets/src/components/projects/DiscussionsPage/index.jsx`:
- Line 247: The attachment staging flow in DiscussionsPage is broken because
CommentLinkActions only passes HTML through onInsert, so stagedDrive never gets
populated and the attachFileFor loop never runs. Update the DiscussionsPage
comment composer to also receive selected files from CommentLinkActions and wire
that data into setStagedDrive alongside setFormDesc, ensuring the existing
attachFileFor handling can persist Drive attachments.

In `@views/assets/src/lib/google-links.js`:
- Around line 64-71: The Google Meet card in decorateGoogleLinks() is being
rebuilt with innerHTML, which can reintroduce decoded markup from a.textContent
and create XSS risk. Replace the innerHTML construction for the meet card with
DOM-based creation using doc.createElement, textContent, append, and
setAttribute, and keep the existing meet link replacement behavior in the same
meet.google.com branch.

---

Duplicate comments:
In `@views/assets/src/store/googleWorkspaceSlice.js`:
- Line 191: The saveSettings.fulfilled handler in googleWorkspaceSlice is
assigning raw payload values into s.status fields, so boolean flags like
configured, picker_ready, drive_enabled, and drive_comments_on can end up as 0/1
instead of true/false. Update the reducer case to coerce each of those payload
properties to booleans before storing them, keeping the behavior consistent with
strict boolean checks elsewhere.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b04787a5-550e-43eb-a5dc-0596630aaa51

📥 Commits

Reviewing files that changed from the base of the PR and between ac13e54 and 4e3238c.

📒 Files selected for processing (22)
  • src/Activity/Transformers/Activity_Transformer.php
  • src/Comment/Observers/Comment_Observer.php
  • src/Google_Workspace/Controllers/Drive_Controller.php
  • src/Google_Workspace/Controllers/OAuth_Controller.php
  • src/Google_Workspace/Google_Client.php
  • src/Google_Workspace/Google_Service.php
  • src/Google_Workspace/Loader.php
  • views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx
  • views/assets/src/components/google-workspace/CommentLinkActions.jsx
  • views/assets/src/components/google-workspace/DriveCommentInsert.jsx
  • views/assets/src/components/google-workspace/GoogleIcons.jsx
  • views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx
  • views/assets/src/components/layout/AppSidebar.jsx
  • views/assets/src/components/projects/ActivitiesPage/constants.js
  • views/assets/src/components/projects/ActivitiesPage/parts/ActivityItem.jsx
  • views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx
  • views/assets/src/components/projects/DiscussionsPage/index.jsx
  • views/assets/src/components/tasks/SingleTaskListPage.jsx
  • views/assets/src/components/tasks/TaskDetailSheet/index.jsx
  • views/assets/src/index.jsx
  • views/assets/src/lib/google-links.js
  • views/assets/src/store/googleWorkspaceSlice.js
✅ Files skipped from review due to trivial changes (2)
  • views/assets/src/components/tasks/SingleTaskListPage.jsx
  • views/assets/src/components/google-workspace/CommentLinkActions.jsx
🚧 Files skipped from review as they are similar to previous changes (8)
  • views/assets/src/components/google-workspace/GoogleIcons.jsx
  • views/assets/src/components/layout/AppSidebar.jsx
  • views/assets/src/index.jsx
  • views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx
  • src/Google_Workspace/Google_Client.php
  • src/Google_Workspace/Controllers/Drive_Controller.php
  • src/Google_Workspace/Loader.php
  • src/Google_Workspace/Google_Service.php

Comment on lines +155 to +157
// Google Workspace: flag comments that contain a Drive/Meet link.
if ( ! empty( $activity->meta['has_drive'] ) ) { $meta['has_drive'] = true; }
if ( ! empty( $activity->meta['has_meet'] ) ) { $meta['has_meet'] = true; }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

parse_meta_for_comment() returns before these flags ever run.

Line 135 checks is_array( $activity ), but this method is passed an Activity object. That means the function exits immediately and the new has_drive / has_meet fields never reach the response.

Suggested fix
-        if ( ! is_array( $activity ) ) {
+        if ( ! is_array( $activity->meta ) ) {
             return $meta;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Activity/Transformers/Activity_Transformer.php` around lines 155 - 157,
In parse_meta_for_comment() within Activity_Transformer, the early type check is
causing the method to return immediately when it receives an Activity object, so
the has_drive and has_meet flags are never added. Update the guard to handle the
actual Activity instance (or its meta payload) instead of requiring an array,
and make sure the existing Google Workspace flag logic for
$activity->meta['has_drive'] and $activity->meta['has_meet'] can run before
returning the meta response.

Comment on lines +76 to +93
/** Flags whether a comment body contains a Drive / Meet link (for activity icons). */
private function gw_meta( Comment $comment ) {
$content = (string) $comment->content;
return [
'has_drive' => ( strpos( $content, 'drive.google.com' ) !== false || strpos( $content, 'docs.google.com' ) !== false ),
'has_meet' => ( strpos( $content, 'meet.google.com' ) !== false ),
];
}

private function comment_on_task( Comment $comment, Task $task, $action_type ) {
$meta = [
'comment_id' => $comment->id,
'task_title' => $task->title,
];
// Don't mark a deleted comment with Drive/Meet icons.
if ( $action_type !== 'delete' ) {
$meta = array_merge( $meta, $this->gw_meta( $comment ) );
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The new Drive/Meet metadata is only attached for task comments.

gw_meta() is introduced as a generic comment-link detector, but Lines 91-92 only merge it in comment_on_task(). Comment activities for discussions, milestones, projects, files, and task lists will never carry has_drive / has_meet, so the new badges/icons stay inconsistent across comment types.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Comment/Observers/Comment_Observer.php` around lines 76 - 93, The new
Drive/Meet link metadata is only being added in comment_on_task(), so other
comment activity paths never receive has_drive / has_meet. Update the shared
comment activity flow in Comment_Observer so gw_meta() is merged into the meta
for all non-delete comment actions, including discussions, milestones, projects,
files, and task lists, while still skipping deleted comments. Use the existing
gw_meta() helper and the comment_on_task() pattern to locate the right place to
centralize this.

'account_email' => $conn['account_email'],
'expired' => $conn['expired'],
'calendar_connected'=> Google_Service::user_has_calendar( get_current_user_id() ),
'meet_connected' => Google_Service::user_has_calendar( get_current_user_id() ),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP 'function\s+user_has_meet' src/Google_Workspace/Google_Service.php -C2

Repository: weDevsOfficial/wp-project-manager

Length of output: 404


🏁 Script executed:

#!/bin/bash
sed -n '1,120p' src/Google_Workspace/Controllers/OAuth_Controller.php

Repository: weDevsOfficial/wp-project-manager

Length of output: 3220


🏁 Script executed:

#!/bin/bash
sed -n '130,155p' src/Google_Workspace/Google_Service.php

Repository: weDevsOfficial/wp-project-manager

Length of output: 1157


meet_connected should use the Meet helper. src/Google_Workspace/Controllers/OAuth_Controller.php:27 currently calls Google_Service::user_has_calendar() here, so the Meet status will mirror Calendar. Use Google_Service::user_has_meet() instead.

Suggested change
-                'meet_connected'    => Google_Service::user_has_calendar( get_current_user_id() ),
+                'meet_connected'    => Google_Service::user_has_meet( get_current_user_id() ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'meet_connected' => Google_Service::user_has_calendar( get_current_user_id() ),
'meet_connected' => Google_Service::user_has_meet( get_current_user_id() ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Google_Workspace/Controllers/OAuth_Controller.php` at line 27, The
meet_connected value is using the calendar check, so the Meet connection state
will always mirror Calendar. Update the OAuth_Controller response building logic
to call Google_Service::user_has_meet() for meet_connected instead of
Google_Service::user_has_calendar(), keeping the surrounding user status fields
unchanged.

Comment thread views/assets/src/components/google-workspace/DriveCommentInsert.jsx
</SelectContent>
</Select>
<FileUploadArea files={formFiles} onFilesChange={setFormFiles} />
<CommentLinkActions projectId={projectId} onInsert={(html) => setFormDesc(prev => (prev || '') + html)} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

stagedDrive never gets populated after this swap.

CommentLinkActions only calls onInsert(html). It has no way to feed selected files into setStagedDrive, so the attachFileFor(...) loop at Lines 129-132 never runs and new discussions won't persist Drive attachments as attachment records.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@views/assets/src/components/projects/DiscussionsPage/index.jsx` at line 247,
The attachment staging flow in DiscussionsPage is broken because
CommentLinkActions only passes HTML through onInsert, so stagedDrive never gets
populated and the attachFileFor loop never runs. Update the DiscussionsPage
comment composer to also receive selected files from CommentLinkActions and wire
that data into setStagedDrive alongside setFormDesc, ensuring the existing
attachFileFor handling can persist Drive attachments.

Comment thread views/assets/src/lib/google-links.js
…r_has_meet/with_meet) — Meet uses calendar.events
…endering — DOM-build Meet card with textContent, escape + http(s)-validate inserted Drive/Meet links

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
views/assets/src/lib/google-links.js (1)

104-108: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Drive/Docs anchors keep an unvalidated href.

Unlike the Meet branch (which routes the CTA through safeHttpUrl), the Drive/Docs branch only adds classes and an icon while leaving the original href untouched. The match relies on a substring regex on href, so a target like javascript:'drive.google.com' still passes Line 104 and survives decoration. The href is pre-existing input (not introduced here), so this isn't a new injection vector, but applying safeHttpUrl here would make link validation consistent with the stated PR intent.

🛡️ Optional consistency fix
     if (/drive\.google\.com|docs\.google\.com/.test(href)) {
+      const safeHref = safeHttpUrl(href)
+      if (safeHref) a.setAttribute('href', safeHref)
       const kind = driveKind(href, text)
       a.classList.add('pm-gdrive-link', 'inline-flex', 'items-center', 'gap-1', 'align-middle')
       a.insertAdjacentHTML('afterbegin', iconHtml(kind))
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@views/assets/src/lib/google-links.js` around lines 104 - 108, The Drive/Docs
branch in google-links.js leaves the existing anchor href unchanged after
matching on /drive\.google\.com|docs\.google\.com/, so unsafe URLs can survive
decoration. Update the google links handling so the Drive/Docs path also routes
the target through safeHttpUrl, similar to the Meet branch, and only keep the
decorated anchor when the sanitized URL is valid. Use the driveKind helper and
the existing anchor mutation logic as the place to apply this consistency fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@views/assets/src/lib/google-links.js`:
- Around line 104-108: The Drive/Docs branch in google-links.js leaves the
existing anchor href unchanged after matching on
/drive\.google\.com|docs\.google\.com/, so unsafe URLs can survive decoration.
Update the google links handling so the Drive/Docs path also routes the target
through safeHttpUrl, similar to the Meet branch, and only keep the decorated
anchor when the sanitized URL is valid. Use the driveKind helper and the
existing anchor mutation logic as the place to apply this consistency fix.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c9ae6edc-fa73-44db-97b0-7344194740ef

📥 Commits

Reviewing files that changed from the base of the PR and between 4e3238c and 0d23756.

📒 Files selected for processing (6)
  • src/Google_Workspace/Controllers/OAuth_Controller.php
  • src/Google_Workspace/Google_Client.php
  • src/Google_Workspace/Google_Service.php
  • views/assets/src/components/google-workspace/DriveCommentInsert.jsx
  • views/assets/src/lib/google-links.js
  • views/assets/src/store/googleWorkspaceSlice.js
💤 Files with no reviewable changes (4)
  • src/Google_Workspace/Controllers/OAuth_Controller.php
  • src/Google_Workspace/Google_Client.php
  • views/assets/src/store/googleWorkspaceSlice.js
  • src/Google_Workspace/Google_Service.php
🚧 Files skipped from review as they are similar to previous changes (1)
  • views/assets/src/components/google-workspace/DriveCommentInsert.jsx

…; sidebar any-feature gate + reactive; active-route highlight for /google-workspace
…ard off-state; align access route perms to Project_Settings_Page_Access; localize calendar/meet master flags; drop dead Meet scope
@arifulhoque7

Copy link
Copy Markdown
Contributor

🔒 Security fix — Google Drive cross-project IDOR (High)

This branch carries the same Drive_Controller IDOR as #624 (index() ~L86, attach() ~L124). Same defect: (attachable_type, attachable_id) from the request is never bound to the route project_id, and index() was unscoped → cross-project read/write of Drive attachments. CWE-639 / OWASP A01.

Fix: attachable_project_id() resolver + 403 on cross-project in index() and attach(), and index() scoped by project_id — matched to this branch's line numbers (ownership check placed after the target-validity check, before the drive_comments_enabled() gate). OAuth/token/crypto handling here is otherwise solid.

(I can't push to your fork, so the fix is attached as a patch below — apply with git apply on this branch. Verified php -l clean.)

diff --git a/src/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php
index 620464b18..360cd6db7 100644
--- a/src/Google_Workspace/Controllers/Drive_Controller.php
+++ b/src/Google_Workspace/Controllers/Drive_Controller.php
@@ -4,6 +4,10 @@ namespace WeDevs\PM\Google_Workspace\Controllers;
 use WP_REST_Request;
 use WeDevs\PM\Google_Workspace\Google_Service;
 use WeDevs\PM\Google_Workspace\Models\Google_Drive_File;
+use WeDevs\PM\Task\Models\Task;
+use WeDevs\PM\Comment\Models\Comment;
+use WeDevs\PM\Discussion_Board\Models\Discussion_Board;
+use WeDevs\PM\File\Models\File;
 use Carbon\Carbon;
 
 if ( ! defined( 'ABSPATH' ) ) exit;
@@ -61,6 +65,42 @@ class Drive_Controller {
         return [ $type, $id ];
     }
 
+    /**
+     * Resolve the real project that owns a polymorphic attachable target.
+     * Returns 0 when the target does not exist or the type is not verifiable.
+     */
+    private function attachable_project_id( $type, $id ) {
+        $id = (int) $id;
+
+        if ( ! $id ) {
+            return 0;
+        }
+
+        switch ( $type ) {
+            case 'task':
+                $model = Task::find( $id );
+                return $model ? (int) $model->project_id : 0;
+
+            case 'comment':
+                $model = Comment::find( $id );
+                return $model ? (int) $model->project_id : 0;
+
+            case 'discussion':
+                $model = Discussion_Board::find( $id );
+                return $model ? (int) $model->project_id : 0;
+
+            case 'file':
+                $model = File::find( $id );
+                return $model ? (int) $model->project_id : 0;
+
+            case 'project':
+                return $id;
+
+            default:
+                return 0;
+        }
+    }
+
     /**
      * For comment attachments, restrict add/remove to the comment author or a
      * project manager (mirrors who can edit the comment). Other entity types
@@ -84,14 +124,21 @@ class Drive_Controller {
     }
 
     public function index( WP_REST_Request $request ) {
+        $project_id        = (int) $request->get_param( 'project_id' );
         list( $type, $id ) = $this->resolve_attachable( $request );
 
         if ( $type === '' ) {
             return [ 'data' => [] ];
         }
 
+        $owner_project_id = $this->attachable_project_id( $type, $id );
+        if ( ! $owner_project_id || $owner_project_id !== $project_id ) {
+            return new \WP_Error( 'pm_google_forbidden', __( 'This attachment target does not belong to the current project.', 'wedevs-project-manager' ), [ 'status' => 403 ] );
+        }
+
         $files = Google_Drive_File::where( 'attachable_type', $type )
             ->where( 'attachable_id', $id )
+            ->where( 'project_id', $project_id )
             ->orderBy( 'created_at', 'desc' )
             ->get();
 
@@ -134,6 +181,11 @@ class Drive_Controller {
             return new \WP_Error( 'pm_google_bad_request', __( 'Invalid attachment target.', 'wedevs-project-manager' ), [ 'status' => 422 ] );
         }
 
+        $owner_project_id = $this->attachable_project_id( $type, $id );
+        if ( ! $owner_project_id || $owner_project_id !== $project_id ) {
+            return new \WP_Error( 'pm_google_forbidden', __( 'This attachment target does not belong to the current project.', 'wedevs-project-manager' ), [ 'status' => 403 ] );
+        }
+
         if ( $type === 'comment' && ! Google_Service::drive_comments_enabled() ) {
             return new \WP_Error( 'pm_google_forbidden', __( 'Google Drive in comments is turned off.', 'wedevs-project-manager' ), [ 'status' => 403 ] );
         }

@arifulhoque7

Copy link
Copy Markdown
Contributor

Superseded by #634 — full commit history preserved, plus the Drive IDOR fix on top. Closing in favor of #634.

arifulhoque7 added a commit that referenced this pull request Jul 3, 2026
…625) (#634)

* feat(google-workspace): add Google Drive integration (OAuth + Picker)

Free feature, always on. Lets each user connect their own Google account
and attach Drive files to tasks by reference (drive.file scope).

- OAuth2 connect/disconnect via WP HTTP API (no SDK); per-user tokens
  encrypted at rest (AES-256-CBC, key from WP salts), auto-refresh.
- Admin sets site-level OAuth credentials + Picker keys (Client ID/Secret,
  API key, App ID). Settings_Page_Access gated.
- Google Picker (client-side) for browsing under least-privilege drive.file;
  setOrigin + z-index/pointer-events handling for in-admin embedding.
- Task detail "Google Drive" section: attach/list/detach file references.
- DB tables wp_pm_google_tokens, wp_pm_google_drive_files (idempotent install
  + last_used_at self-heal).
- 60-day stale-token purge via daily cron.
- uninstall.php removes Google Workspace tables/options/cron only.
- Graceful reconnect when site salts rotate (undecryptable tokens purged).
- Browser guidance for third-party-cookie blocking (Chrome/Brave/Safari).

Files: src/Google_Workspace/* (Loader, Google_Client, Google_Service,
Models, Controllers), routes/google-workspace.php, React store slice +
components/google-workspace/*, registrations in index.jsx, sidebar nav,
TaskDetailSheet picker-aware outside-close guard, Create_Table + start.php
bootstrap, uninstall.php.

* refactor(google-workspace): split admin setup from per-user connection

- Move site-level OAuth credentials into Settings → Google Workspace tab
  (new GoogleWorkspaceSettingsTab; admin-only via the Settings page gate).
- Google Workspace page is now per-user account connection only
  (connect/disconnect/status) + a features overview (Drive available;
  Calendar/Meet coming soon) so future features slot in cleanly.
- One account connection powers all Google Workspace features.
- Sidebar nav uses the Google Drive logo instead of the disk icon.

No backend changes; reuses existing settings/status/auth endpoints.

* style(google-workspace): monochrome nav/settings logo; sidebar label Google Drive (free) / Google Workspace (pro)

* feat(google-workspace): admin toggle to enable/disable Google Drive (hidden everywhere when off)

* style(google-workspace): literal G Drive/G Workspace labels + outlined nav icons

* style(google-workspace): refine monochrome Drive nav/settings glyph

* style(google-workspace): adopt 2026 Drive icon — monochrome in nav/settings, full color in task/connection

* style(google-workspace): outline the 2026 Drive icon in nav/settings

* feat(google-workspace): per-project Drive role access + move G Workspace settings tab to top

* feat(google-workspace): clearer 'View only' message in task Drive section when role can't attach

* fix(google-workspace): strict role gating on frontend (show Attach only when allowed) + enforce detach permission

* fix(google-workspace): no Connect prompt / hide section for roles without Drive access (admin + frontend)

* refactor(google-workspace): polymorphic attachments (task/comment/discussion/project) + shared GoogleDriveAttach component

* feat(google-workspace): attach Drive files to task comments (+ orphan cleanup on comment/task delete)

* fix(google-workspace): comment Drive attach restricted to comment author or manager (UI + API)

* style(google-workspace): section button label 'Attach'; compact comment button = + with mono Drive icon

* feat(google-workspace): Drive attach in discussions (body + comments) + discussion orphan cleanup

* feat(google-workspace): attach Drive files while creating a discussion (staged, attached on create)

* feat(google-workspace): expose Drive components via window.PM for pro

* feat(google-workspace): allow 'file' attachable type for Drive attachments

* feat(google-workspace): clamp list to 2 + Show all; adder avatar; outlined hover-reveal comment button

* fix(google-workspace): comment Drive button reveal via opacity (was hidden + ungenerated variant)

* feat(google-workspace): comment Drive add as icon-only button beside edit/delete (order: drive, edit, delete); chips render below via showAdd=false

* feat(google-workspace): show 'Google Drive' label only in task section; discussion/file headers show icon + count only

* feat(google-workspace): drop comment Drive chips; icon button beside edit/delete is the only comment surface

* feat(google-workspace): add Pro extension slots + upgrade covers for Calendar & Meet

- G Workspace page: Calendar/Meet rows are now Slots (google.workspace.feature.{calendar,meet}); free shows a clickable Pro upgrade teaser, Pro fills them.
- Settings tab: Calendar sync + Meet groups shown as locked Pro teasers (google.workspace.settings.{calendar,meet} slots) behind the upgrade modal.
- Drive remains the free feature; Calendar/Meet reserved for Pro.

* feat(google-workspace): incremental Calendar-scope consent infra (free)

- Google_Client: add CALENDAR_SCOPE; get_auth_url() accepts a scope override.
- OAuth_Controller: auth-url honors with_calendar (requests calendar.events alongside drive.file via include_granted_scopes); status returns calendar_connected.
- Google_Service: user_has_scope()/user_has_calendar(); Loader localizes calendar_connected.
- Slice getAuthUrl({withCalendar}); expose GW thunks (fetchStatus/getAuthUrl/disconnect) on window.PM for Pro.

* feat(google-workspace): move Calendar config to the sidebar G Workspace page

- Calendar is now a card section on the G Workspace page (slot google.workspace.feature.calendar), Pro fills it with sync settings + connect; free shows a Pro cover card.
- Drop the Calendar/Meet teasers from the admin settings tab (creds-only again).

* feat(google-workspace): Workspace page = per-feature connection cards; sidebar label 'Google Workspace' (free+pro)

- Sidebar nav label unified to 'Google Workspace' for free and pro.
- Workspace page now shows Connected services cards: Google Drive (free, status) + Calendar/Meet slots (Pro connect cards, free covers).
- Calendar config (enable + sync options) moved back to Settings -> Google Workspace (admin) via the settings.calendar slot.

* feat(google-workspace): per-user service prefs on Workspace page

- Drive card gets a per-user on/off (user meta pm_gws_drive_on); user_can_use_drive respects it; status/localize expose drive_user_on; new POST google-workspace/my-prefs; saveDrivePref thunk.

* feat(google-workspace): note that all features use one account; reconnecting a different account replaces it

* feat(google-workspace): fire pm_google_before_disconnect (token still valid) for feature cleanup

* feat(google-workspace): disconnect confirm modal (plugin AlertDialog, wider) explaining consequences

* feat(google-workspace): G Workspace sidebar label; smaller settings desc + setup docs link; admin toggle to disable Drive in comments (auto-save+toast, enforced UI+API); branded Calendar/Meet icons in free teasers

* fix(google-workspace): use plugin ProBadge for teasers; neutral calendar connect prompt; docs link icon-only top-right

* fix(google-workspace): settings tab — G Workspace heading, shorter desc, credentials heading, icon-less feature toggles; trim account note 2nd line

* fix(google-workspace): settings — Google glyph + 'Google Workspace' heading, larger credentials heading, grouped Drive enable + Drive-in-comments card with Drive logo

* fix(google-workspace): settings header glyph violet (plugin accent), not multicolor Google

* fix(google-workspace): settings header glyph inline + w-5 (match Pusher); remove Drive icon from Enable Google Drive toggle

* feat(google-workspace): incremental Meet scope (meetings.space.created) + meet_connected status; getAuthUrl supports withMeet

* fix(google-workspace): reset calendar/meet connected state on disconnect

* fix(google-workspace): Drive toggle off when account disconnected

* feat(google-workspace): comment.composer.action slot in task + discussion comment composers

* fix(comment): guard null parent_comment/commentable in activity logging (was fatal -> 500 on add comment)

* fix(google-workspace): show Drive attachments under task + discussion comments (was attached but not rendered)

* feat(google-workspace): unified Drive+Meet link insertion in comment composers (new + edit); drop chips + per-comment attach button (CommentLinkActions)

* fix(google-workspace): drop emoji from inserted Drive link (4-byte emoji broke save on utf8mb3 comment column)

* feat(google-workspace): icon-only Drive/Meet composer buttons; add Meeting to discussion create form

* feat(google-workspace): unify Drive icon in discussion create (CommentLinkActions); allowMeet flag

* fix(google-workspace): normalize sidebar Drive nav glyph size/centering (viewBox padding) to align with label

* revert: sidebar Drive nav glyph viewBox back to 0 0 24 24 (no resize)

* fix(google-workspace): top-align sidebar Google icon with the label (no resize)

* style(google-meet): update Meet brand icon (colored places); monochrome composer unchanged

* feat(google-workspace): log Drive attach/detach + Meet activity, monochrome marks in activity feeds

* feat(google-workspace): decorate Drive/Meet links in comment view (file-type icons + Meet card); expose decorateGoogleLinks on window.PM

* copy(google-workspace): use 'team members' wording in Drive comments setting

* chore(google-workspace): remove dead Meet scope chain (MEET_SCOPE/user_has_meet/with_meet) — Meet uses calendar.events

* security(google-workspace): prevent injected markup in comment link rendering — DOM-build Meet card with textContent, escape + http(s)-validate inserted Drive/Meet links

* fix(google-workspace): add G Workspace to admin submenu under Sprints; sidebar any-feature gate + reactive; active-route highlight for /google-workspace

* fix(google-workspace): enforce Drive master toggle on usage + Drive card off-state; align access route perms to Project_Settings_Page_Access; localize calendar/meet master flags; drop dead Meet scope

* fix(drive): bind Drive attachments to the route project (IDOR)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden(google): safe token-key fallback + gate picker_config to Drive-enabled

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(google): consolidate inline Google/Drive/Meet SVGs into GoogleIcons.jsx

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(google): hide Drive/Meet comment links from members without project access

Google links pasted into comments were shown to every project member. Now the
comment transformer runs content through a wedevs_pm_comment_content_visibility
filter; Google_Workspace strips Drive/Docs/Meet anchors (replacing them with
plain text) when the requesting user fails the existing per-project role
permission Google_Service::user_can_use_drive() — managers/admins pass,
co_worker/client gated by the project access map. Stripped server-side so the
URL never reaches the response. No-op unless the content actually holds a
google.com link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(uninstall): preserve user data — stop dropping tables/options on delete

uninstall.php dropped pm_google_tokens + pm_google_drive_files and deleted the
settings options, destroying user data on plugin delete/reinstall (this is the
free wp.org core plugin). Now it only clears the scheduled cleanup cron; no
tables dropped, no options deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: anik-fahmid <fahmid.cse.cou@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants