Skip to content

Commit d22f629

Browse files
arifulhoque7anik-fahmidclaude
authored
Google Calendar/Meet + Drive (free side) — IDOR-hardened (supersedes #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>
1 parent c0ac273 commit d22f629

38 files changed

Lines changed: 2999 additions & 24 deletions

bootstrap/start.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
wedevs_pm_register_routes();
2323
wedevs_pm_clean_svg();
2424

25+
( new \WeDevs\PM\Google_Workspace\Loader() )->boot();
2526
new WeDevs\PM\Kanban\Kanban();
2627

2728
do_action( 'wedevs_pm_loaded' );

core/WP/Menu.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,36 @@ public static function admin_menu() {
3737

3838
do_action( 'wedevs_cpm_admin_menu', self::$capability, $home );
3939

40+
// 3b. G Workspace — placed directly under Sprints (or in the views group
41+
// when Sprints isn't present). Shown when any Google feature is on.
42+
if ( class_exists( '\WeDevs\PM\Google_Workspace\Google_Service' ) ) {
43+
$gw = '\WeDevs\PM\Google_Workspace\Google_Service';
44+
$gw_on = $gw::drive_enabled() || $gw::calendar_master_enabled() || $gw::meet_master_enabled();
45+
if ( $gw_on ) {
46+
$gw_item = [ __( 'G Workspace', 'wedevs-project-manager' ), self::$capability, "admin.php?page={$slug}#/google-workspace" ];
47+
$list = isset( $submenu[ $slug ] ) ? $submenu[ $slug ] : [];
48+
49+
// Find the integer POSITION of the Sprints entry (its array key may
50+
// be a string, so never do arithmetic on the key itself).
51+
$pos = -1; $i = 0;
52+
foreach ( $list as $it ) {
53+
if ( isset( $it[2] ) && strpos( $it[2], '#/sprints' ) !== false ) { $pos = $i; break; }
54+
$i++;
55+
}
56+
57+
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally adding custom submenu items to WordPress admin menu
58+
if ( $pos >= 0 ) {
59+
$submenu[ $slug ] = array_merge(
60+
array_slice( $list, 0, $pos + 1 ),
61+
[ $gw_item ],
62+
array_slice( $list, $pos + 1 )
63+
);
64+
} else {
65+
$submenu[ $slug ][] = $gw_item;
66+
}
67+
}
68+
}
69+
4070
// 4. Upgrade to Pro (free only)
4171
if ( ! $wedevs_pm_pro ) {
4272
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally adding custom submenu items to WordPress admin menu

db/Create_Table.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public function __construct() {
2626
$this->crate_role_project_users_table();
2727
$this->update_version();
2828
$this->task_types();
29+
\WeDevs\PM\Google_Workspace\Loader::install();
2930
}
3031

3132
private function prefix() {

routes/google-workspace.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
use WeDevs\PM\Core\Router\Router;
3+
4+
$wedevs_pm_router = Router::singleton();
5+
6+
$gw_base = 'WeDevs/PM/Google_Workspace/Controllers/';
7+
$gw_admin = 'WeDevs\PM\Core\Permissions\Settings_Page_Access';
8+
$gw_auth = 'WeDevs\PM\Core\Permissions\Authentic';
9+
$gw_access = 'WeDevs\PM\Core\Permissions\Access_Project';
10+
11+
// ── Admin: site-level OAuth credentials + Picker keys ────────────────
12+
$wedevs_pm_router->get( 'google-workspace/settings', $gw_base . 'Settings_Controller@get' )
13+
->permission( [ $gw_admin ] );
14+
15+
$wedevs_pm_router->post( 'google-workspace/settings', $gw_base . 'Settings_Controller@save' )
16+
->permission( [ $gw_admin ] );
17+
18+
// ── Per-user connection ──────────────────────────────────────────────
19+
$wedevs_pm_router->get( 'google-workspace/status', $gw_base . 'OAuth_Controller@status' )
20+
->permission( [ $gw_auth ] );
21+
22+
$wedevs_pm_router->get( 'google-workspace/auth-url', $gw_base . 'OAuth_Controller@auth_url' )
23+
->permission( [ $gw_auth ] );
24+
25+
$wedevs_pm_router->post( 'google-workspace/disconnect', $gw_base . 'OAuth_Controller@disconnect' )
26+
->permission( [ $gw_auth ] );
27+
28+
$wedevs_pm_router->post( 'google-workspace/my-prefs', $gw_base . 'OAuth_Controller@save_my_prefs' )
29+
->permission( [ $gw_auth ] );
30+
31+
// ── Drive Picker config (vends caller's own access token + Picker keys) ──
32+
$wedevs_pm_router->get( 'google-workspace/drive/picker-config', $gw_base . 'Drive_Controller@picker_config' )
33+
->permission( [ $gw_auth ] );
34+
35+
// ── Per-project Drive role access (manager configures; members query) ──
36+
// Same gate as the project Capabilities/Settings tab where these toggles live.
37+
$gw_manager = 'WeDevs\PM\Core\Permissions\Project_Settings_Page_Access';
38+
39+
$wedevs_pm_router->get( 'projects/{project_id}/google-workspace/access', $gw_base . 'Drive_Controller@get_access' )
40+
->permission( [ $gw_manager ] );
41+
42+
$wedevs_pm_router->post( 'projects/{project_id}/google-workspace/access', $gw_base . 'Drive_Controller@save_access' )
43+
->permission( [ $gw_manager ] );
44+
45+
$wedevs_pm_router->get( 'projects/{project_id}/google-workspace/can-use', $gw_base . 'Drive_Controller@can_use' )
46+
->permission( [ $gw_access ] );
47+
48+
// ── Drive attachments (polymorphic: task, comment, discussion, project) ──
49+
$wedevs_pm_router->get( 'projects/{project_id}/google-drive', $gw_base . 'Drive_Controller@index' )
50+
->permission( [ $gw_access ] );
51+
52+
$wedevs_pm_router->post( 'projects/{project_id}/google-drive', $gw_base . 'Drive_Controller@attach' )
53+
->permission( [ $gw_access ] );
54+
55+
$wedevs_pm_router->delete( 'projects/{project_id}/google-drive/{id}', $gw_base . 'Drive_Controller@destroy' )
56+
->permission( [ $gw_access ] );
57+
58+
// Legacy task-scoped routes (kept for back-compat).
59+
$wedevs_pm_router->get( 'projects/{project_id}/tasks/{task_id}/google-drive', $gw_base . 'Drive_Controller@index' )
60+
->permission( [ $gw_access ] );
61+
62+
$wedevs_pm_router->post( 'projects/{project_id}/tasks/{task_id}/google-drive', $gw_base . 'Drive_Controller@attach' )
63+
->permission( [ $gw_access ] );

src/Activity/Transformers/Activity_Transformer.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ private function parse_meta_for_comment( Activity $activity ) {
152152
}
153153
}
154154

155+
// Google Workspace: flag comments that contain a Drive/Meet link.
156+
if ( ! empty( $activity->meta['has_drive'] ) ) { $meta['has_drive'] = true; }
157+
if ( ! empty( $activity->meta['has_meet'] ) ) { $meta['has_meet'] = true; }
158+
155159
return $meta;
156160
}
157161

@@ -228,6 +232,16 @@ private function get_nested_value( $data, $path ) {
228232
*/
229233
private function get_activity_message_by_action( $action ) {
230234
switch ( $action ) {
235+
// Google Workspace activities
236+
case 'attach_drive_file':
237+
return __( '{{actor.data.display_name}} attached a Google Drive file to {{meta.task_title}}.', 'wedevs-project-manager' );
238+
239+
case 'detach_drive_file':
240+
return __( '{{actor.data.display_name}} removed a Google Drive file from {{meta.task_title}}.', 'wedevs-project-manager' );
241+
242+
case 'create_meet':
243+
return __( '{{actor.data.display_name}} started a Google Meet meeting.', 'wedevs-project-manager' );
244+
231245
// Project activities
232246
case 'create_project':
233247
/* translators: 1: User display name, 2: Project title */

src/Comment/Observers/Comment_Observer.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,14 @@ protected function content( Comment $comment, $old_value ) {
3737

3838
private function log_activity( Comment $comment, $action_type ) {
3939
$parent_comment = Comment::parent_comment( $comment->id );
40+
if ( ! $parent_comment ) {
41+
return; // can't resolve the thread root — skip activity logging
42+
}
4043
$commentable_type = $parent_comment->commentable_type;
4144
$commentable = $this->get_commentable( $parent_comment );
45+
if ( ! $commentable ) {
46+
return; // commentable (task/discussion/file) gone — skip logging
47+
}
4248

4349
switch ( $commentable_type ) {
4450
case 'task':
@@ -67,11 +73,24 @@ private function log_activity( Comment $comment, $action_type ) {
6773
}
6874
}
6975

76+
/** Flags whether a comment body contains a Drive / Meet link (for activity icons). */
77+
private function gw_meta( Comment $comment ) {
78+
$content = (string) $comment->content;
79+
return [
80+
'has_drive' => ( strpos( $content, 'drive.google.com' ) !== false || strpos( $content, 'docs.google.com' ) !== false ),
81+
'has_meet' => ( strpos( $content, 'meet.google.com' ) !== false ),
82+
];
83+
}
84+
7085
private function comment_on_task( Comment $comment, Task $task, $action_type ) {
7186
$meta = [
7287
'comment_id' => $comment->id,
7388
'task_title' => $task->title,
7489
];
90+
// Don't mark a deleted comment with Drive/Meet icons.
91+
if ( $action_type !== 'delete' ) {
92+
$meta = array_merge( $meta, $this->gw_meta( $comment ) );
93+
}
7594

7695
if ( $action_type == 'create' && $comment->commentable_type == 'comment' ) {
7796
$action = 'reply_comment_on_task';

src/Comment/Transformers/Comment_Transformer.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class Comment_Transformer extends TransformerAbstract {
3030
public function transform( Comment $item ) {
3131
return [
3232
'id' => (int) $item->id,
33-
'content' => wedevs_pm_get_content( $item->content ),
33+
'content' => apply_filters( 'wedevs_pm_comment_content_visibility', wedevs_pm_get_content( $item->content ), (int) $item->project_id ),
3434
'commentable_type' => $item->commentable_type,
3535
'commentable_id' => $item->commentable_id,
3636
'created_at' => wedevs_pm_format_date( $item->created_at ),

0 commit comments

Comments
 (0)