diff --git a/bootstrap/start.php b/bootstrap/start.php
index 37f0d9f74..2bc8b2482 100644
--- a/bootstrap/start.php
+++ b/bootstrap/start.php
@@ -22,6 +22,7 @@
wedevs_pm_register_routes();
wedevs_pm_clean_svg();
+( new \WeDevs\PM\Google_Workspace\Loader() )->boot();
new WeDevs\PM\Kanban\Kanban();
do_action( 'wedevs_pm_loaded' );
diff --git a/core/WP/Menu.php b/core/WP/Menu.php
index c82d0a9fe..16367d2ee 100644
--- a/core/WP/Menu.php
+++ b/core/WP/Menu.php
@@ -37,6 +37,36 @@ public static function admin_menu() {
do_action( 'wedevs_cpm_admin_menu', self::$capability, $home );
+ // 3b. G Workspace — placed directly under Sprints (or in the views group
+ // when Sprints isn't present). Shown when any Google feature is on.
+ if ( class_exists( '\WeDevs\PM\Google_Workspace\Google_Service' ) ) {
+ $gw = '\WeDevs\PM\Google_Workspace\Google_Service';
+ $gw_on = $gw::drive_enabled() || $gw::calendar_master_enabled() || $gw::meet_master_enabled();
+ if ( $gw_on ) {
+ $gw_item = [ __( 'G Workspace', 'wedevs-project-manager' ), self::$capability, "admin.php?page={$slug}#/google-workspace" ];
+ $list = isset( $submenu[ $slug ] ) ? $submenu[ $slug ] : [];
+
+ // Find the integer POSITION of the Sprints entry (its array key may
+ // be a string, so never do arithmetic on the key itself).
+ $pos = -1; $i = 0;
+ foreach ( $list as $it ) {
+ if ( isset( $it[2] ) && strpos( $it[2], '#/sprints' ) !== false ) { $pos = $i; break; }
+ $i++;
+ }
+
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally adding custom submenu items to WordPress admin menu
+ if ( $pos >= 0 ) {
+ $submenu[ $slug ] = array_merge(
+ array_slice( $list, 0, $pos + 1 ),
+ [ $gw_item ],
+ array_slice( $list, $pos + 1 )
+ );
+ } else {
+ $submenu[ $slug ][] = $gw_item;
+ }
+ }
+ }
+
// 4. Upgrade to Pro (free only)
if ( ! $wedevs_pm_pro ) {
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Intentionally adding custom submenu items to WordPress admin menu
diff --git a/db/Create_Table.php b/db/Create_Table.php
index ade6ab43d..c44a80681 100644
--- a/db/Create_Table.php
+++ b/db/Create_Table.php
@@ -26,6 +26,7 @@ public function __construct() {
$this->crate_role_project_users_table();
$this->update_version();
$this->task_types();
+ \WeDevs\PM\Google_Workspace\Loader::install();
}
private function prefix() {
diff --git a/routes/google-workspace.php b/routes/google-workspace.php
new file mode 100644
index 000000000..4d2f808e1
--- /dev/null
+++ b/routes/google-workspace.php
@@ -0,0 +1,63 @@
+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) ──
+// Same gate as the project Capabilities/Settings tab where these toggles live.
+$gw_manager = 'WeDevs\PM\Core\Permissions\Project_Settings_Page_Access';
+
+$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 ] );
diff --git a/src/Activity/Transformers/Activity_Transformer.php b/src/Activity/Transformers/Activity_Transformer.php
index 9af660ad3..f89c0911f 100644
--- a/src/Activity/Transformers/Activity_Transformer.php
+++ b/src/Activity/Transformers/Activity_Transformer.php
@@ -152,6 +152,10 @@ private function parse_meta_for_comment( Activity $activity ) {
}
}
+ // 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; }
+
return $meta;
}
@@ -228,6 +232,16 @@ private function get_nested_value( $data, $path ) {
*/
private function get_activity_message_by_action( $action ) {
switch ( $action ) {
+ // Google Workspace activities
+ case 'attach_drive_file':
+ return __( '{{actor.data.display_name}} attached a Google Drive file to {{meta.task_title}}.', 'wedevs-project-manager' );
+
+ case 'detach_drive_file':
+ return __( '{{actor.data.display_name}} removed a Google Drive file from {{meta.task_title}}.', 'wedevs-project-manager' );
+
+ case 'create_meet':
+ return __( '{{actor.data.display_name}} started a Google Meet meeting.', 'wedevs-project-manager' );
+
// Project activities
case 'create_project':
/* translators: 1: User display name, 2: Project title */
diff --git a/src/Comment/Observers/Comment_Observer.php b/src/Comment/Observers/Comment_Observer.php
index ee7aae966..dca9cf1fb 100644
--- a/src/Comment/Observers/Comment_Observer.php
+++ b/src/Comment/Observers/Comment_Observer.php
@@ -37,8 +37,14 @@ protected function content( Comment $comment, $old_value ) {
private function log_activity( Comment $comment, $action_type ) {
$parent_comment = Comment::parent_comment( $comment->id );
+ if ( ! $parent_comment ) {
+ return; // can't resolve the thread root — skip activity logging
+ }
$commentable_type = $parent_comment->commentable_type;
$commentable = $this->get_commentable( $parent_comment );
+ if ( ! $commentable ) {
+ return; // commentable (task/discussion/file) gone — skip logging
+ }
switch ( $commentable_type ) {
case 'task':
@@ -67,11 +73,24 @@ private function log_activity( Comment $comment, $action_type ) {
}
}
+ /** 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 ) );
+ }
if ( $action_type == 'create' && $comment->commentable_type == 'comment' ) {
$action = 'reply_comment_on_task';
diff --git a/src/Comment/Transformers/Comment_Transformer.php b/src/Comment/Transformers/Comment_Transformer.php
index 4e47bd41b..e2a2b064c 100644
--- a/src/Comment/Transformers/Comment_Transformer.php
+++ b/src/Comment/Transformers/Comment_Transformer.php
@@ -30,7 +30,7 @@ class Comment_Transformer extends TransformerAbstract {
public function transform( Comment $item ) {
return [
'id' => (int) $item->id,
- 'content' => wedevs_pm_get_content( $item->content ),
+ 'content' => apply_filters( 'wedevs_pm_comment_content_visibility', wedevs_pm_get_content( $item->content ), (int) $item->project_id ),
'commentable_type' => $item->commentable_type,
'commentable_id' => $item->commentable_id,
'created_at' => wedevs_pm_format_date( $item->created_at ),
diff --git a/src/Google_Workspace/Controllers/Drive_Controller.php b/src/Google_Workspace/Controllers/Drive_Controller.php
new file mode 100644
index 000000000..9569be56c
--- /dev/null
+++ b/src/Google_Workspace/Controllers/Drive_Controller.php
@@ -0,0 +1,281 @@
+ 403 ] );
+ }
+
+ if ( ! Google_Service::picker_ready() ) {
+ return new \WP_Error( 'pm_google_not_ready', __( 'Google Picker is not fully configured. An administrator must add the API key and App ID.', 'wedevs-project-manager' ), [ 'status' => 400 ] );
+ }
+
+ $access_token = Google_Service::get_access_token( get_current_user_id() );
+ if ( empty( $access_token ) ) {
+ return new \WP_Error( 'pm_google_not_connected', __( 'Connect your Google account first.', 'wedevs-project-manager' ), [ 'status' => 401 ] );
+ }
+
+ return [
+ 'data' => [
+ 'access_token' => $access_token,
+ 'api_key' => Google_Service::get_api_key(),
+ 'app_id' => Google_Service::get_app_id(),
+ ],
+ ];
+ }
+
+ /** GET projects/{project_id}/tasks/{task_id}/google-drive */
+ /**
+ * Resolve the polymorphic target from the request. Prefers
+ * attachable_type/attachable_id; falls back to task_id (legacy task route).
+ *
+ * @return array{0:string,1:int}
+ */
+ private function resolve_attachable( WP_REST_Request $request ) {
+ $type = sanitize_text_field( (string) $request->get_param( 'attachable_type' ) );
+ $id = (int) $request->get_param( 'attachable_id' );
+
+ if ( $type === '' ) {
+ $task_id = (int) $request->get_param( 'task_id' );
+ if ( $task_id ) {
+ return [ 'task', $task_id ];
+ }
+ }
+
+ // Whitelist supported entity types.
+ $allowed = [ 'task', 'comment', 'discussion', 'project', 'file' ];
+ if ( ! in_array( $type, $allowed, true ) ) {
+ $type = '';
+ }
+
+ 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
+ * are gated by project-level Drive access only.
+ */
+ private function can_manage_attachable( $type, $id, $project_id ) {
+ if ( $type !== 'comment' ) {
+ return true;
+ }
+
+ global $wpdb;
+ $created_by = (int) $wpdb->get_var( $wpdb->prepare(
+ "SELECT created_by FROM {$wpdb->prefix}pm_comments WHERE id = %d", (int) $id
+ ) );
+
+ $uid = get_current_user_id();
+ if ( $created_by && $created_by === $uid ) {
+ return true;
+ }
+ return wedevs_pm_has_manage_capability( $uid ) || wedevs_pm_is_manager( $project_id, $uid );
+ }
+
+ 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();
+
+ return [ 'data' => $this->transform_collection( $files ) ];
+ }
+
+ /** GET projects/{project_id}/google-workspace/access — manager: read role access */
+ public function get_access( WP_REST_Request $request ) {
+ $project_id = (int) $request->get_param( 'project_id' );
+ return [ 'data' => Google_Service::project_drive_access( $project_id ) ];
+ }
+
+ /** POST projects/{project_id}/google-workspace/access — manager: save role access */
+ public function save_access( WP_REST_Request $request ) {
+ $project_id = (int) $request->get_param( 'project_id' );
+ Google_Service::save_project_drive_access(
+ $project_id,
+ filter_var( $request->get_param( 'co_worker' ), FILTER_VALIDATE_BOOLEAN ),
+ filter_var( $request->get_param( 'client' ), FILTER_VALIDATE_BOOLEAN )
+ );
+ return [ 'data' => Google_Service::project_drive_access( $project_id ) ];
+ }
+
+ /** GET projects/{project_id}/google-workspace/can-use — current user gate */
+ public function can_use( WP_REST_Request $request ) {
+ $project_id = (int) $request->get_param( 'project_id' );
+ return [ 'data' => [ 'can_use' => Google_Service::user_can_use_drive( $project_id ) ] ];
+ }
+
+ public function attach( WP_REST_Request $request ) {
+ $project_id = (int) $request->get_param( 'project_id' );
+ $file = $request->get_param( 'file' );
+ list( $type, $id ) = $this->resolve_attachable( $request );
+
+ if ( ! Google_Service::user_can_use_drive( $project_id ) ) {
+ return new \WP_Error( 'pm_google_forbidden', __( 'You are not allowed to attach Google Drive files in this project.', 'wedevs-project-manager' ), [ 'status' => 403 ] );
+ }
+
+ if ( $type === '' || ! $id ) {
+ 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 ] );
+ }
+
+ if ( ! $this->can_manage_attachable( $type, $id, $project_id ) ) {
+ return new \WP_Error( 'pm_google_forbidden', __( 'You can only attach Drive files to your own comment.', 'wedevs-project-manager' ), [ 'status' => 403 ] );
+ }
+
+ if ( empty( $file ) || empty( $file['id'] ) ) {
+ return new \WP_Error( 'pm_google_bad_request', __( 'No file provided.', 'wedevs-project-manager' ), [ 'status' => 422 ] );
+ }
+
+ $existing = Google_Drive_File::where( 'attachable_type', $type )
+ ->where( 'attachable_id', $id )
+ ->where( 'file_id', $file['id'] )
+ ->first();
+ if ( $existing ) {
+ return [ 'data' => $this->transform( $existing ) ];
+ }
+
+ $row = Google_Drive_File::create( [
+ 'project_id' => $project_id,
+ 'task_id' => $type === 'task' ? $id : null,
+ 'attachable_type' => $type,
+ 'attachable_id' => $id,
+ 'user_id' => get_current_user_id(),
+ 'file_id' => sanitize_text_field( $file['id'] ),
+ 'name' => isset( $file['name'] ) ? sanitize_text_field( $file['name'] ) : '',
+ 'mime_type' => isset( $file['mimeType'] ) ? sanitize_text_field( $file['mimeType'] ) : '',
+ 'icon_link' => isset( $file['iconLink'] ) ? esc_url_raw( $file['iconLink'] ) : '',
+ 'thumbnail_link' => isset( $file['thumbnailLink'] ) ? esc_url_raw( $file['thumbnailLink'] ) : '',
+ 'web_view_link' => isset( $file['webViewLink'] ) ? esc_url_raw( $file['webViewLink'] ) : '',
+ 'modified_time' => isset( $file['modifiedTime'] ) ? sanitize_text_field( $file['modifiedTime'] ) : '',
+ 'created_at' => Carbon::now(),
+ 'updated_at' => Carbon::now(),
+ ] );
+
+ do_action( 'pm_google_drive_file_attached', $row, $type, $id, $project_id );
+
+ return [ 'data' => $this->transform( $row ) ];
+ }
+
+ /** DELETE projects/{project_id}/google-drive/{id} */
+ public function destroy( WP_REST_Request $request ) {
+ $project_id = (int) $request->get_param( 'project_id' );
+ $id = (int) $request->get_param( 'id' );
+
+ if ( ! Google_Service::user_can_use_drive( $project_id ) ) {
+ return new \WP_Error( 'pm_google_forbidden', __( 'You are not allowed to remove Google Drive files in this project.', 'wedevs-project-manager' ), [ 'status' => 403 ] );
+ }
+
+ $row = Google_Drive_File::where( 'id', $id )->where( 'project_id', $project_id )->first();
+ if ( $row ) {
+ if ( ! $this->can_manage_attachable( $row->attachable_type, $row->attachable_id, $project_id ) ) {
+ return new \WP_Error( 'pm_google_forbidden', __( 'You can only remove Drive files from your own comment.', 'wedevs-project-manager' ), [ 'status' => 403 ] );
+ }
+ do_action( 'pm_google_drive_file_detached', $row );
+ $row->delete();
+ }
+
+ return [ 'data' => [ 'id' => $id, 'deleted' => true ] ];
+ }
+
+ private function transform_collection( $rows ) {
+ $out = [];
+ foreach ( $rows as $row ) {
+ $out[] = $this->transform( $row );
+ }
+ return $out;
+ }
+
+ private function transform( $row ) {
+ $user = $row->user_id ? get_userdata( $row->user_id ) : null;
+
+ return [
+ 'id' => (int) $row->id,
+ 'file_id' => $row->file_id,
+ 'name' => $row->name,
+ 'mime_type' => $row->mime_type,
+ 'icon_link' => $row->icon_link,
+ 'thumbnail_link' => $row->thumbnail_link,
+ 'web_view_link' => $row->web_view_link,
+ 'modified_time' => $row->modified_time,
+ 'user_id' => (int) $row->user_id,
+ 'added_by_name' => $user ? $user->display_name : '',
+ 'added_by_avatar'=> $row->user_id ? get_avatar_url( $row->user_id, [ 'size' => 32 ] ) : '',
+ ];
+ }
+}
diff --git a/src/Google_Workspace/Controllers/OAuth_Controller.php b/src/Google_Workspace/Controllers/OAuth_Controller.php
new file mode 100644
index 000000000..53d11a5c6
--- /dev/null
+++ b/src/Google_Workspace/Controllers/OAuth_Controller.php
@@ -0,0 +1,73 @@
+ [
+ 'configured' => Google_Service::is_configured(),
+ 'picker_ready' => Google_Service::picker_ready(),
+ 'drive_enabled' => Google_Service::drive_enabled(),
+ 'calendar_enabled' => Google_Service::calendar_master_enabled(),
+ 'meet_enabled' => Google_Service::meet_master_enabled(),
+ 'connected' => $conn['connected'],
+ '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() ),
+ 'drive_user_on' => Google_Service::user_drive_enabled( get_current_user_id() ),
+ 'drive_comments_on' => Google_Service::drive_comments_enabled(),
+ ],
+ ];
+ }
+
+ /** Per-user Workspace preferences (currently: Drive on/off for me). */
+ public function save_my_prefs( WP_REST_Request $request ) {
+ $user_id = get_current_user_id();
+ if ( $request->get_param( 'drive_on' ) !== null ) {
+ Google_Service::set_user_drive_enabled( $user_id, (bool) $request->get_param( 'drive_on' ) );
+ }
+ return [ 'data' => [ 'drive_user_on' => Google_Service::user_drive_enabled( $user_id ) ] ];
+ }
+
+ public function auth_url( WP_REST_Request $request ) {
+ if ( ! Google_Service::is_configured() ) {
+ return new \WP_Error( 'pm_google_not_configured', __( 'Google credentials are not configured. Ask an administrator to add the Client ID and Secret.', 'wedevs-project-manager' ), [ 'status' => 400 ] );
+ }
+
+ $user_id = get_current_user_id();
+ $state = $user_id . '|' . wp_create_nonce( 'pm_google_oauth_' . $user_id );
+
+ // Incremental consent: when a Pro feature asks for Calendar, request the
+ // Calendar scope alongside the base scopes (include_granted_scopes keeps
+ // the previously granted Drive scope).
+ $scope = Google_Client::SCOPES;
+ if ( $request->get_param( 'with_calendar' ) ) {
+ $scope .= ' ' . Google_Client::CALENDAR_SCOPE;
+ }
+
+ return [
+ 'data' => [
+ 'auth_url' => Google_Service::client()->get_auth_url( $state, $scope ),
+ ],
+ ];
+ }
+
+ public function disconnect( WP_REST_Request $request ) {
+ Google_Service::disconnect_user( get_current_user_id() );
+
+ return [ 'data' => [ 'connected' => false ] ];
+ }
+}
diff --git a/src/Google_Workspace/Controllers/Settings_Controller.php b/src/Google_Workspace/Controllers/Settings_Controller.php
new file mode 100644
index 000000000..77a6cefb9
--- /dev/null
+++ b/src/Google_Workspace/Controllers/Settings_Controller.php
@@ -0,0 +1,61 @@
+ [
+ 'client_id' => isset( $settings['client_id'] ) ? $settings['client_id'] : '',
+ 'has_secret' => ! empty( $settings['client_secret'] ),
+ 'api_key' => isset( $settings['api_key'] ) ? $settings['api_key'] : '',
+ 'app_id' => isset( $settings['app_id'] ) ? $settings['app_id'] : '',
+ 'drive_enabled' => Google_Service::drive_enabled(),
+ 'calendar_enabled' => Google_Service::calendar_master_enabled(),
+ 'meet_enabled' => Google_Service::meet_master_enabled(),
+ 'drive_comments'=> Google_Service::drive_comments_enabled(),
+ 'configured' => Google_Service::is_configured(),
+ 'picker_ready' => Google_Service::picker_ready(),
+ 'redirect_uri' => Loader::redirect_uri(),
+ ],
+ ];
+ }
+
+ public function save( WP_REST_Request $request ) {
+ $client_id = trim( (string) $request->get_param( 'client_id' ) );
+ $client_secret = trim( (string) $request->get_param( 'client_secret' ) );
+
+ $settings = get_option( 'pm_google_workspace_settings', [] );
+
+ $settings['client_id'] = sanitize_text_field( $client_id );
+ $settings['api_key'] = sanitize_text_field( (string) $request->get_param( 'api_key' ) );
+ $settings['app_id'] = sanitize_text_field( (string) $request->get_param( 'app_id' ) );
+ $settings['drive_enabled'] = filter_var( $request->get_param( 'drive_enabled' ), FILTER_VALIDATE_BOOLEAN );
+
+ // Preserve drive_comments unless explicitly sent (its own toggle).
+ if ( $request->get_param( 'drive_comments' ) !== null ) {
+ $settings['drive_comments'] = filter_var( $request->get_param( 'drive_comments' ), FILTER_VALIDATE_BOOLEAN );
+ }
+
+ // Only overwrite the secret when a fresh value is sent (UI sends blank to keep).
+ // Stored encrypted at rest.
+ if ( $client_secret !== '' ) {
+ $settings['client_secret'] = Google_Service::encrypt( $client_secret );
+ }
+
+ update_option( 'pm_google_workspace_settings', $settings );
+
+ return $this->get( $request );
+ }
+}
diff --git a/src/Google_Workspace/Google_Client.php b/src/Google_Workspace/Google_Client.php
new file mode 100644
index 000000000..dd48f3bb4
--- /dev/null
+++ b/src/Google_Workspace/Google_Client.php
@@ -0,0 +1,109 @@
+client_id = $client_id;
+ $this->client_secret = $client_secret;
+ $this->redirect_uri = $redirect_uri;
+ }
+
+ public function get_auth_url( $state, $scope = self::SCOPES ) {
+ return add_query_arg( [
+ 'client_id' => rawurlencode( $this->client_id ),
+ 'redirect_uri' => rawurlencode( $this->redirect_uri ),
+ 'response_type' => 'code',
+ 'scope' => rawurlencode( $scope ),
+ 'access_type' => 'offline',
+ 'include_granted_scopes' => 'true',
+ 'prompt' => 'consent',
+ 'state' => rawurlencode( $state ),
+ ], self::AUTH_URL );
+ }
+
+ public function exchange_code( $code ) {
+ return $this->token_request( [
+ 'code' => $code,
+ 'client_id' => $this->client_id,
+ 'client_secret' => $this->client_secret,
+ 'redirect_uri' => $this->redirect_uri,
+ 'grant_type' => 'authorization_code',
+ ] );
+ }
+
+ public function refresh_token( $refresh_token ) {
+ return $this->token_request( [
+ 'client_id' => $this->client_id,
+ 'client_secret' => $this->client_secret,
+ 'refresh_token' => $refresh_token,
+ 'grant_type' => 'refresh_token',
+ ] );
+ }
+
+ private function token_request( $body ) {
+ return $this->parse( wp_remote_post( self::TOKEN_URL, [
+ 'timeout' => 20,
+ 'body' => $body,
+ ] ) );
+ }
+
+ public function revoke( $token ) {
+ wp_remote_post( self::REVOKE_URL, [
+ 'timeout' => 15,
+ 'body' => [ 'token' => $token ],
+ ] );
+ }
+
+ public function get_userinfo( $access_token ) {
+ return $this->parse( wp_remote_get( self::USERINFO, [
+ 'timeout' => 15,
+ 'headers' => [ 'Authorization' => 'Bearer ' . $access_token ],
+ ] ) );
+ }
+
+ private function parse( $res ) {
+ if ( is_wp_error( $res ) ) {
+ return [ 'ok' => false, 'status' => 0, 'error' => $res->get_error_message() ];
+ }
+
+ $status = (int) wp_remote_retrieve_response_code( $res );
+ $body = json_decode( wp_remote_retrieve_body( $res ), true );
+
+ if ( $status < 200 || $status >= 300 ) {
+ $message = 'Google API error';
+ if ( isset( $body['error_description'] ) ) {
+ $message = $body['error_description'];
+ } elseif ( isset( $body['error']['message'] ) ) {
+ $message = $body['error']['message'];
+ } elseif ( isset( $body['error'] ) && is_string( $body['error'] ) ) {
+ $message = $body['error'];
+ }
+ return [ 'ok' => false, 'status' => $status, 'error' => $message ];
+ }
+
+ return [ 'ok' => true, 'status' => $status, 'data' => $body ];
+ }
+}
diff --git a/src/Google_Workspace/Google_Service.php b/src/Google_Workspace/Google_Service.php
new file mode 100644
index 000000000..66bcf7903
--- /dev/null
+++ b/src/Google_Workspace/Google_Service.php
@@ -0,0 +1,383 @@
+ true, 'client' => false ];
+ }
+ return [
+ 'co_worker' => ! empty( $saved['co_worker'] ),
+ 'client' => ! empty( $saved['client'] ),
+ ];
+ }
+
+ public static function save_project_drive_access( $project_id, $co_worker, $client ) {
+ update_option( 'pm_gdrive_access_' . (int) $project_id, [
+ 'co_worker' => (bool) $co_worker,
+ 'client' => (bool) $client,
+ ] );
+ }
+
+ /** Per-user opt-out of Drive (toggled on the Google Workspace page). Default on. */
+ public static function user_drive_enabled( $user_id ) {
+ $v = get_user_meta( (int) $user_id, 'pm_gws_drive_on', true );
+ return $v === '' ? true : (bool) $v;
+ }
+
+ public static function set_user_drive_enabled( $user_id, $on ) {
+ update_user_meta( (int) $user_id, 'pm_gws_drive_on', $on ? 1 : 0 );
+ }
+
+ /**
+ * Whether a user may use Drive in a project. Managers/admins always may;
+ * co_worker/client gated by the per-project access map.
+ */
+ public static function user_can_use_drive( $project_id, $user_id = null ) {
+ $user_id = $user_id ? $user_id : get_current_user_id();
+
+ // Admin master switch off -> Drive disabled for everyone.
+ if ( ! self::drive_enabled() ) {
+ return false;
+ }
+
+ // Respect the user's own Drive on/off choice first.
+ if ( ! self::user_drive_enabled( $user_id ) ) {
+ return false;
+ }
+
+ if ( wedevs_pm_has_manage_capability( $user_id ) || wedevs_pm_is_manager( $project_id, $user_id ) ) {
+ return true;
+ }
+
+ $role = wedevs_pm_get_role( $project_id, $user_id ); // co_worker | client | null
+ $access = self::project_drive_access( $project_id );
+
+ return ! empty( $access[ $role ] );
+ }
+
+ /** Global on/off switch for the Drive feature (admin-controlled). */
+ public static function drive_enabled() {
+ $settings = get_option( 'pm_google_workspace_settings', [] );
+ return ! empty( $settings['drive_enabled'] );
+ }
+
+ /** Pro Calendar master switch — read directly (plain option, no Pro class needed). */
+ public static function calendar_master_enabled() {
+ $s = get_option( 'pm_google_calendar_settings', [] );
+ return is_array( $s ) && ! empty( $s['enabled'] );
+ }
+
+ /** Pro Meet master switch — read directly (plain option, no Pro class needed). */
+ public static function meet_master_enabled() {
+ $s = get_option( 'pm_google_meet_settings', [] );
+ return is_array( $s ) && ! empty( $s['enabled'] );
+ }
+
+ /** Whether Drive attach is allowed inside comments (admin-controlled, default on). */
+ public static function drive_comments_enabled() {
+ $settings = get_option( 'pm_google_workspace_settings', [] );
+ return ! array_key_exists( 'drive_comments', $settings ) ? true : ! empty( $settings['drive_comments'] );
+ }
+
+ /** Picker also needs an API key + App ID (project number). */
+ public static function picker_ready() {
+ $settings = get_option( 'pm_google_workspace_settings', [] );
+ return self::is_configured() && ! empty( $settings['api_key'] ) && ! empty( $settings['app_id'] );
+ }
+
+ public static function get_api_key() {
+ $settings = get_option( 'pm_google_workspace_settings', [] );
+ return isset( $settings['api_key'] ) ? $settings['api_key'] : '';
+ }
+
+ public static function get_app_id() {
+ $settings = get_option( 'pm_google_workspace_settings', [] );
+ return isset( $settings['app_id'] ) ? $settings['app_id'] : '';
+ }
+
+ /** Whether the user's stored grant includes a given OAuth scope. */
+ public static function user_has_scope( $user_id, $needle ) {
+ $token = self::get_user_token( $user_id );
+ if ( ! $token || empty( $token->scope ) ) {
+ return false;
+ }
+ return strpos( $token->scope, $needle ) !== false;
+ }
+
+ /** Whether the user has granted the Calendar scope (Pro Calendar feature). */
+ public static function user_has_calendar( $user_id ) {
+ return self::user_has_scope( $user_id, Google_Client::CALENDAR_SCOPE );
+ }
+
+ public static function get_user_token( $user_id ) {
+ if ( empty( $user_id ) ) {
+ return null;
+ }
+ return Google_Token::where( 'user_id', $user_id )->first();
+ }
+
+ /**
+ * @return array{connected:bool, account_email:string, expired:bool}
+ */
+ public static function user_connection( $user_id ) {
+ $token = self::get_user_token( $user_id );
+
+ if ( ! $token ) {
+ Loader::log( 'Status: no token row for user ' . $user_id );
+ return [ 'connected' => false, 'account_email' => '', 'expired' => false ];
+ }
+
+ if ( self::token_undecryptable( $token ) ) {
+ Loader::log( 'Status: token undecryptable for user ' . $user_id . ' — purging' );
+ $token->delete();
+ return [ 'connected' => false, 'account_email' => '', 'expired' => true ];
+ }
+
+ return [ 'connected' => true, 'account_email' => $token->google_email, 'expired' => false ];
+ }
+
+ private static function token_undecryptable( $token ) {
+ $has_refresh = ! empty( $token->refresh_token );
+ $has_access = ! empty( $token->access_token );
+
+ if ( ! $has_refresh && ! $has_access ) {
+ return false;
+ }
+
+ $refresh_ok = ! $has_refresh || self::decrypt( $token->refresh_token ) !== null;
+ $access_ok = ! $has_access || self::decrypt( $token->access_token ) !== null;
+
+ return ! $refresh_ok && ! $access_ok;
+ }
+
+ public static function connect_user( $user_id, $code ) {
+ $client = self::client();
+ $token = $client->exchange_code( $code );
+
+ if ( empty( $token['ok'] ) ) {
+ Loader::log( 'Token exchange failed for user ' . $user_id . ': ' . ( isset( $token['error'] ) ? $token['error'] : 'unknown' ) );
+ return false;
+ }
+
+ $data = $token['data'];
+ $email = '';
+ $profile = $client->get_userinfo( $data['access_token'] );
+ if ( ! empty( $profile['ok'] ) && isset( $profile['data']['email'] ) ) {
+ $email = $profile['data']['email'];
+ }
+
+ $expires_at = Carbon::now()->addSeconds( isset( $data['expires_in'] ) ? (int) $data['expires_in'] : 3600 );
+
+ $existing = self::get_user_token( $user_id );
+
+ $refresh_plain = isset( $data['refresh_token'] ) ? $data['refresh_token'] : null;
+ if ( empty( $refresh_plain ) && $existing ) {
+ $refresh_enc = $existing->refresh_token;
+ } else {
+ $refresh_enc = $refresh_plain ? self::encrypt( $refresh_plain ) : null;
+ }
+
+ $attrs = [
+ 'user_id' => $user_id,
+ 'google_email' => $email,
+ 'access_token' => self::encrypt( $data['access_token'] ),
+ 'refresh_token' => $refresh_enc,
+ 'scope' => isset( $data['scope'] ) ? $data['scope'] : Google_Client::SCOPES,
+ 'expires_at' => $expires_at,
+ 'last_used_at' => Carbon::now(),
+ 'updated_at' => Carbon::now(),
+ ];
+
+ try {
+ if ( $existing ) {
+ $existing->update( $attrs );
+ } else {
+ $attrs['created_at'] = Carbon::now();
+ Google_Token::create( $attrs );
+ }
+ } catch ( \Exception $e ) {
+ // Surface the real DB error (e.g. missing column) instead of a
+ // false "success" — the caller will report an error to the user.
+ Loader::log( 'Token store FAILED for user ' . $user_id . ': ' . $e->getMessage() );
+ return false;
+ }
+
+ Loader::log( 'Token stored for user ' . $user_id . ' (' . $email . ')' );
+ return true;
+ }
+
+ public static function disconnect_user( $user_id ) {
+ $token = self::get_user_token( $user_id );
+ if ( ! $token ) {
+ return;
+ }
+
+ // Let features (e.g. Pro Calendar) clean up their Google data while the
+ // token is still valid — fired before revoke so API calls still work.
+ do_action( 'pm_google_before_disconnect', (int) $user_id );
+
+ $refresh = self::decrypt( $token->refresh_token );
+ if ( $refresh ) {
+ self::client()->revoke( $refresh );
+ }
+
+ $token->delete();
+ }
+
+ /**
+ * Revoke + delete tokens not used for $days days (default 60). Runs daily
+ * via cron so abandoned grants don't linger. Applies to every user.
+ *
+ * @return int number purged
+ */
+ public static function purge_stale( $days = 60 ) {
+ $cutoff = Carbon::now()->subDays( $days );
+ $purged = 0;
+
+ $tokens = Google_Token::all();
+ 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;
+ }
+
+ /**
+ * Fresh access token for a user, refreshing if needed. Purges tokens that
+ * can no longer be decrypted (salts rotated) so the UI prompts reconnect.
+ *
+ * @return string|null
+ */
+ public static function get_access_token( $user_id ) {
+ $token = self::get_user_token( $user_id );
+ if ( ! $token ) {
+ return null;
+ }
+
+ if ( self::token_undecryptable( $token ) ) {
+ $token->delete();
+ return null;
+ }
+
+ // Track activity for the 60-day stale-token purge.
+ $token->update( [ 'last_used_at' => Carbon::now() ] );
+
+ $expires_at = $token->expires_at ? Carbon::parse( $token->expires_at ) : null;
+
+ if ( $expires_at && $expires_at->isFuture() && $expires_at->diffInSeconds( Carbon::now() ) > 60 ) {
+ return self::decrypt( $token->access_token );
+ }
+
+ $refresh = self::decrypt( $token->refresh_token );
+ if ( empty( $refresh ) ) {
+ return self::decrypt( $token->access_token );
+ }
+
+ $res = self::client()->refresh_token( $refresh );
+ if ( empty( $res['ok'] ) ) {
+ return null;
+ }
+
+ $data = $res['data'];
+ $expires = Carbon::now()->addSeconds( isset( $data['expires_in'] ) ? (int) $data['expires_in'] : 3600 );
+ $access = $data['access_token'];
+
+ $token->update( [
+ 'access_token' => self::encrypt( $access ),
+ 'expires_at' => $expires,
+ 'updated_at' => Carbon::now(),
+ ] );
+
+ return $access;
+ }
+
+ // ── Encryption (AES-256-CBC, key from WP salts) ──────────────────────
+
+ private static function key() {
+ $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : wp_salt( 'auth' );
+ $salt .= defined( 'AUTH_SALT' ) ? AUTH_SALT : wp_salt( 'auth' );
+ return hash( 'sha256', $salt, true );
+ }
+
+ public static function encrypt( $plain ) {
+ if ( $plain === null || $plain === '' || ! function_exists( 'openssl_encrypt' ) ) {
+ return $plain;
+ }
+ $iv = openssl_random_pseudo_bytes( 16 );
+ $cipher = openssl_encrypt( $plain, 'aes-256-cbc', self::key(), OPENSSL_RAW_DATA, $iv );
+ return base64_encode( $iv . $cipher );
+ }
+
+ public static function decrypt( $stored ) {
+ if ( empty( $stored ) || ! function_exists( 'openssl_decrypt' ) ) {
+ return $stored;
+ }
+ $raw = base64_decode( $stored, true );
+ if ( $raw === false || strlen( $raw ) <= 16 ) {
+ return null;
+ }
+ $iv = substr( $raw, 0, 16 );
+ $cipher = substr( $raw, 16 );
+ $plain = openssl_decrypt( $cipher, 'aes-256-cbc', self::key(), OPENSSL_RAW_DATA, $iv );
+ return $plain === false ? null : $plain;
+ }
+}
diff --git a/src/Google_Workspace/Loader.php b/src/Google_Workspace/Loader.php
new file mode 100644
index 000000000..4db9ea817
--- /dev/null
+++ b/src/Google_Workspace/Loader.php
@@ -0,0 +1,255 @@
+]*href=(["\'])https?://[^"\']*(?:drive\.google\.com|docs\.google\.com|meet\.google\.com)[^"\']*\1[^>]*>(.*?)#is',
+ function ( $m ) {
+ $text = trim( wp_strip_all_tags( $m[2] ) );
+ return $text === '' ? '' : esc_html( $text );
+ },
+ $content
+ );
+ }
+
+ public function run_cleanup() {
+ Google_Service::purge_stale( 60 );
+ }
+
+ private static function delete_attachments( $type, $id ) {
+ global $wpdb;
+ if ( empty( $id ) ) {
+ return;
+ }
+ $wpdb->delete( $wpdb->prefix . 'pm_google_drive_files', [
+ 'attachable_type' => $type,
+ 'attachable_id' => (int) $id,
+ ] );
+ }
+
+ public function cleanup_comment_attachments( $comment ) {
+ $id = is_object( $comment ) ? ( isset( $comment->id ) ? $comment->id : 0 ) : (int) $comment;
+ self::delete_attachments( 'comment', $id );
+ }
+
+ public function cleanup_task_attachments( $task_id ) {
+ self::delete_attachments( 'task', $task_id );
+ }
+
+ public function cleanup_discussion_attachments( $discussion_id ) {
+ self::delete_attachments( 'discussion', (int) $discussion_id );
+ }
+
+ public static function redirect_uri() {
+ return admin_url( 'admin-post.php?action=pm_google_oauth_callback' );
+ }
+
+ public function localize( $localize ) {
+ $conn = Google_Service::user_connection( get_current_user_id() );
+
+ $localize['google_workspace'] = [
+ 'configured' => Google_Service::is_configured(),
+ 'picker_ready' => Google_Service::picker_ready(),
+ 'drive_enabled' => Google_Service::drive_enabled(),
+ 'calendar_enabled' => Google_Service::calendar_master_enabled(),
+ 'meet_enabled' => Google_Service::meet_master_enabled(),
+ 'connected' => $conn['connected'],
+ '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() ),
+ 'drive_user_on' => Google_Service::user_drive_enabled( get_current_user_id() ),
+ 'drive_comments_on' => Google_Service::drive_comments_enabled(),
+ 'redirect_uri' => self::redirect_uri(),
+ ];
+
+ return $localize;
+ }
+
+ public function handle_oauth_callback() {
+ if ( ! is_user_logged_in() ) {
+ wp_die( esc_html__( 'You must be logged in to connect Google.', 'wedevs-project-manager' ) );
+ }
+
+ $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : '';
+ $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : '';
+
+ $return = admin_url( 'admin.php?page=pm_projects#/google-workspace' );
+
+ $parts = explode( '|', $state );
+ if ( count( $parts ) !== 2 || (int) $parts[0] !== get_current_user_id()
+ || ! wp_verify_nonce( $parts[1], 'pm_google_oauth_' . get_current_user_id() ) ) {
+ wp_safe_redirect( add_query_arg( 'google_connected', 'invalid_state', $return ) );
+ exit;
+ }
+
+ if ( empty( $code ) ) {
+ wp_safe_redirect( add_query_arg( 'google_connected', 'denied', $return ) );
+ exit;
+ }
+
+ $ok = Google_Service::connect_user( get_current_user_id(), $code );
+
+ wp_safe_redirect( add_query_arg( 'google_connected', $ok ? 'success' : 'error', $return ) );
+ exit;
+ }
+
+ /** Gated debug logger — writes when WP_DEBUG or PM_GOOGLE_DEBUG is on. */
+ public static function log( $message ) {
+ if ( ( defined( 'WP_DEBUG' ) && WP_DEBUG ) || ( defined( 'PM_GOOGLE_DEBUG' ) && PM_GOOGLE_DEBUG ) ) {
+ error_log( '[PM Google Workspace] ' . $message );
+ }
+ }
+
+ public function maybe_install() {
+ // Always self-heal columns first (cheap) so upgrades from an older
+ // schema — e.g. a table left by a previous install — get last_used_at
+ // without depending on the version gate.
+ self::ensure_columns();
+
+ if ( get_option( 'pm_google_workspace_db_version' ) === '1.3' ) {
+ return;
+ }
+ self::install();
+ }
+
+ private static function column_missing( $table, $column ) {
+ global $wpdb;
+ $has = $wpdb->get_results( $wpdb->prepare(
+ "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s",
+ DB_NAME, $table, $column
+ ) );
+ return empty( $has );
+ }
+
+ /**
+ * Idempotently add columns missing from existing tables (covers upgrades
+ * from an older schema). MySQL + MariaDB safe (no ADD COLUMN IF NOT EXISTS).
+ */
+ public static function ensure_columns() {
+ global $wpdb;
+ $tokens = $wpdb->prefix . 'pm_google_tokens';
+ $files = $wpdb->prefix . 'pm_google_drive_files';
+
+ if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $tokens ) ) && self::column_missing( $tokens, 'last_used_at' ) ) {
+ $wpdb->query( "ALTER TABLE {$tokens} ADD COLUMN `last_used_at` datetime DEFAULT NULL AFTER `expires_at`" );
+ self::log( 'Added last_used_at to ' . $tokens );
+ }
+
+ if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $files ) ) ) {
+ if ( self::column_missing( $files, 'attachable_type' ) ) {
+ $wpdb->query( "ALTER TABLE {$files} ADD COLUMN `attachable_type` varchar(40) DEFAULT NULL AFTER `task_id`" );
+ $wpdb->query( "ALTER TABLE {$files} ADD COLUMN `attachable_id` bigint(20) UNSIGNED DEFAULT NULL AFTER `attachable_type`" );
+ $wpdb->query( "ALTER TABLE {$files} ADD KEY `attachable` (`attachable_type`,`attachable_id`)" );
+ // Migrate existing task attachments to the polymorphic columns.
+ $wpdb->query( "UPDATE {$files} SET `attachable_type` = 'task', `attachable_id` = `task_id` WHERE `task_id` IS NOT NULL AND `attachable_type` IS NULL" );
+ self::log( 'Added polymorphic columns + migrated task rows on ' . $files );
+ }
+ }
+ }
+
+ public static function install() {
+ global $wpdb;
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+ $tokens = $wpdb->prefix . 'pm_google_tokens';
+ $files = $wpdb->prefix . 'pm_google_drive_files';
+
+ dbDelta( "CREATE TABLE IF NOT EXISTS {$tokens} (
+ `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
+ `user_id` bigint(20) UNSIGNED NOT NULL,
+ `google_email` varchar(191) DEFAULT NULL,
+ `access_token` text,
+ `refresh_token` text,
+ `scope` text,
+ `expires_at` datetime DEFAULT NULL,
+ `last_used_at` datetime DEFAULT NULL,
+ `created_at` timestamp NULL DEFAULT NULL,
+ `updated_at` timestamp NULL DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `user_id` (`user_id`)
+ ) DEFAULT CHARSET=utf8" );
+
+ dbDelta( "CREATE TABLE IF NOT EXISTS {$files} (
+ `id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
+ `project_id` bigint(20) UNSIGNED DEFAULT NULL,
+ `task_id` bigint(20) UNSIGNED DEFAULT NULL,
+ `attachable_type` varchar(40) DEFAULT NULL,
+ `attachable_id` bigint(20) UNSIGNED DEFAULT NULL,
+ `user_id` bigint(20) UNSIGNED NOT NULL,
+ `file_id` varchar(191) NOT NULL,
+ `name` varchar(255) DEFAULT NULL,
+ `mime_type` varchar(191) DEFAULT NULL,
+ `icon_link` text,
+ `thumbnail_link` text,
+ `web_view_link` text,
+ `modified_time` varchar(64) DEFAULT NULL,
+ `created_at` timestamp NULL DEFAULT NULL,
+ `updated_at` timestamp NULL DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `task_id` (`task_id`),
+ KEY `project_id` (`project_id`),
+ KEY `attachable` (`attachable_type`,`attachable_id`)
+ ) DEFAULT CHARSET=utf8" );
+
+ update_option( 'pm_google_workspace_db_version', '1.3' );
+ }
+}
diff --git a/src/Google_Workspace/Models/Google_Drive_File.php b/src/Google_Workspace/Models/Google_Drive_File.php
new file mode 100644
index 000000000..ae74a4fed
--- /dev/null
+++ b/src/Google_Workspace/Models/Google_Drive_File.php
@@ -0,0 +1,24 @@
+ (
@@ -38,6 +39,7 @@ const NotionSettingsTab = lazy(() => import('./tabs/NotionSettingsTab'))
const LoomSettingsTab = lazy(() => import('./tabs/LoomSettingsTab'))
const InvoiceSettingsTab = lazy(() => import('./tabs/InvoiceSettingsTab'))
const PagesSettingsTab = lazy(() => import('./tabs/PagesSettingsTab'))
+const GoogleWorkspaceSettingsTab = lazy(() => import('./tabs/GoogleWorkspaceSettingsTab'))
// ── Tab → Component map ──────────────────────────────────────
const tabComponents = {
@@ -49,6 +51,7 @@ const tabComponents = {
'github': GitHubSettingsTab,
'notion': NotionSettingsTab,
'loom': LoomSettingsTab,
+ 'google-workspace': GoogleWorkspaceSettingsTab,
'invoices': InvoiceSettingsTab,
'pages': PagesSettingsTab,
'woo-project': null, // injected by pm-pro via filter
@@ -94,6 +97,7 @@ const SettingsPage = () => {
const showWooTab = !isPro || isWooModuleActive
const integrationTabs = [
+ { key: 'google-workspace', label: __('G Workspace', 'wedevs-project-manager'), icon: GoogleWorkspaceNavIcon },
{ key: 'pusher', label: __('Pusher', 'wedevs-project-manager'), icon: Radio },
{ key: 'github', label: __('GitHub', 'wedevs-project-manager'), icon: GitHubNavIcon },
{ key: 'notion', label: __('Notion', 'wedevs-project-manager'), icon: NotionNavIcon },
diff --git a/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx
new file mode 100644
index 000000000..831eb4715
--- /dev/null
+++ b/views/assets/src/components/admin-settings/tabs/GoogleWorkspaceSettingsTab.jsx
@@ -0,0 +1,226 @@
+import { __ } from '@wordpress/i18n'
+/**
+ * GoogleWorkspaceSettingsTab — admin, one-time setup of the site-level Google
+ * Cloud OAuth credentials + Picker keys. Shared by all Google Workspace
+ * features (Drive now; Calendar/Meet later). Per-user account connection lives
+ * on the separate Google Workspace page, not here.
+ */
+import React, { useEffect, useState } from 'react'
+import { useAppDispatch, useAppSelector } from '@store/index'
+import { fetchSettings, saveSettings } from '@store/googleWorkspaceSlice'
+import { Button } from '@components/ui/button'
+import { Input } from '@components/ui/input'
+import { Switch } from '@components/ui/switch'
+import { Skeleton } from '@components/ui/skeleton'
+import { Copy, Check, ExternalLink, ShieldCheck, Lock } from 'lucide-react'
+import { useToast } from '@hooks/useToast'
+import { Slot } from '@hooks/useSlot'
+import { useProModal } from '@components/common/ProUpgradeModal'
+import ProBadge from '@components/common/ProBadge'
+import { GoogleMonoGlyph } from '@components/google-workspace/GoogleIcons'
+
+const DOCS_URL = 'https://wedevs.com/docs/wp-project-manager/integrations/google-workspace/'
+
+/**
+ * Free, locked teaser for a Pro Google feature's settings (Calendar/Meet).
+ * Pro replaces it by filling the matching slot with real enable + sync toggles.
+ */
+const LockedSettingCard = ({ title, description }) => {
+ const { setOpen } = useProModal()
+ return (
+
+ )
+}
+
+export default function GoogleWorkspaceSettingsTab() {
+ const dispatch = useAppDispatch()
+ const toast = useToast()
+ const { settings, settingsLoading, saving } = useAppSelector(s => s.googleWorkspace)
+
+ const [clientId, setClientId] = useState('')
+ const [clientSecret, setClientSecret] = useState('')
+ const [apiKey, setApiKey] = useState('')
+ const [appId, setAppId] = useState('')
+ const [driveEnabled, setDriveEnabled] = useState(false)
+ const [driveComments, setDriveComments] = useState(true)
+ const [copied, setCopied] = useState(false)
+
+ useEffect(() => { dispatch(fetchSettings()) }, [dispatch])
+
+ useEffect(() => {
+ setClientId(settings.client_id || '')
+ setApiKey(settings.api_key || '')
+ setAppId(settings.app_id || '')
+ setDriveEnabled(!!settings.drive_enabled)
+ setDriveComments(settings.drive_comments === undefined ? true : !!settings.drive_comments)
+ }, [settings.client_id, settings.api_key, settings.app_id, settings.drive_enabled, settings.drive_comments])
+
+ const redirectUri = settings.redirect_uri || (window.PM_Vars?.google_workspace?.redirect_uri ?? '')
+
+ function copyRedirect() {
+ navigator.clipboard?.writeText(redirectUri)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ }
+
+ async function toggleDrive(v) {
+ setDriveEnabled(v)
+ const res = await dispatch(saveSettings({ client_id: clientId, client_secret: '', api_key: apiKey, app_id: appId, drive_enabled: v }))
+ if (saveSettings.fulfilled.match(res)) {
+ toast.success(v ? __('Google Drive enabled.', 'wedevs-project-manager') : __('Google Drive disabled.', 'wedevs-project-manager'))
+ } else {
+ setDriveEnabled(!v)
+ toast.error(res.payload || __('Failed to update.', 'wedevs-project-manager'))
+ }
+ }
+
+ async function toggleComments(v) {
+ setDriveComments(v)
+ const res = await dispatch(saveSettings({ client_id: clientId, client_secret: '', api_key: apiKey, app_id: appId, drive_enabled: driveEnabled, drive_comments: v }))
+ if (saveSettings.fulfilled.match(res)) {
+ toast.success(v ? __('Drive in comments enabled.', 'wedevs-project-manager') : __('Drive in comments disabled.', 'wedevs-project-manager'))
+ } else {
+ setDriveComments(!v)
+ toast.error(res.payload || __('Failed to update.', 'wedevs-project-manager'))
+ }
+ }
+
+ async function onSave(e) {
+ e.preventDefault()
+ const res = await dispatch(saveSettings({ client_id: clientId, client_secret: clientSecret, api_key: apiKey, app_id: appId, drive_enabled: driveEnabled }))
+ if (saveSettings.fulfilled.match(res)) {
+ setClientSecret('')
+ toast.success(__('Google Workspace settings saved.', 'wedevs-project-manager'))
+ } else {
+ toast.error(res.payload || __('Failed to save settings.', 'wedevs-project-manager'))
+ }
+ }
+
+ return (
+
+
+
+
+
+ {__('Google Workspace', 'wedevs-project-manager')}
+
+
+ {__('Set up your Google Cloud project once. Users then connect their own account.', 'wedevs-project-manager')}
+
+
+
+
+
+
+
+
+
+
+
{__('Enable Google Drive', 'wedevs-project-manager')}
+
{__('Show Google Drive in the sidebar and on tasks. Turn off to hide it everywhere.', 'wedevs-project-manager')}
+
+
+
+
+ {driveEnabled && (
+
+
+
{__('Allow Drive in comments', 'wedevs-project-manager')}
+
{__('Let team members attach Drive files inside comments. Turn off to hide the Drive button in comments only.', 'wedevs-project-manager')}
+
+
+
+ )}
+
+
+
}
+ />
+
}
+ />
+
+ {settings.picker_ready && driveEnabled && (
+
+ {__('Google Workspace is configured. Users can now connect their accounts.', 'wedevs-project-manager')}
+
+ )}
+
+
{__('API credentials & keys', 'wedevs-project-manager')}
+
+
+
+
{__('Authorized redirect URI', 'wedevs-project-manager')}
+
+ {redirectUri}
+
+ {copied ? : }
+
+
+
{__('Add this exact URI to your Google OAuth client (Web application).', 'wedevs-project-manager')}
+
+
+ {settingsLoading ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/views/assets/src/components/google-workspace/CommentLinkActions.jsx b/views/assets/src/components/google-workspace/CommentLinkActions.jsx
new file mode 100644
index 000000000..0c4459081
--- /dev/null
+++ b/views/assets/src/components/google-workspace/CommentLinkActions.jsx
@@ -0,0 +1,19 @@
+import React from 'react'
+/**
+ * CommentLinkActions — the Drive + Meet "insert link" buttons for a comment
+ * composer (new or edit). Drive is free (DriveCommentInsert); Meet is Pro, which
+ * fills the comment.composer.action slot. Both insert a link into the comment
+ * via onInsert(html).
+ */
+import DriveCommentInsert from './DriveCommentInsert'
+import { Slot } from '@hooks/useSlot'
+
+export default function CommentLinkActions({ projectId, onInsert, allowMeet = true }) {
+ if (!projectId || typeof onInsert !== 'function') return null
+ return (
+
+
+ {allowMeet && }
+
+ )
+}
diff --git a/views/assets/src/components/google-workspace/DriveCommentInsert.jsx b/views/assets/src/components/google-workspace/DriveCommentInsert.jsx
new file mode 100644
index 000000000..656ce7f42
--- /dev/null
+++ b/views/assets/src/components/google-workspace/DriveCommentInsert.jsx
@@ -0,0 +1,71 @@
+import { __ } from '@wordpress/i18n'
+/**
+ * DriveCommentInsert — Drive button for a comment composer (new or edit). Opens
+ * the Picker and inserts a link to the chosen file(s) into the comment text
+ * (no separate attachment record). Gated by Drive enabled + connection + the
+ * admin "Drive in comments" toggle + per-project role.
+ */
+import React, { useEffect, useState } from 'react'
+import { useAppDispatch, useAppSelector } from '@store/index'
+import { fetchStatus, fetchCanUse } from '@store/googleWorkspaceSlice'
+import { Button } from '@components/ui/button'
+import { DriveMonoGlyph } from '@components/google-workspace/GoogleIcons'
+import DrivePickerModal from './DrivePickerModal'
+
+const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => (
+ { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
+))
+const safeHttpUrl = (u) => {
+ try { const p = new URL(u, window.location.origin); return (p.protocol === 'https:' || p.protocol === 'http:') ? p.href : '' }
+ catch { return '' }
+}
+
+function linkHtml(f) {
+ const url = safeHttpUrl(f.webViewLink || f.url)
+ if (!url) return ''
+ return `${esc(f.name || __('Drive file', 'wedevs-project-manager'))}
`
+}
+
+export default function DriveCommentInsert({ projectId, onInsert }) {
+ const dispatch = useAppDispatch()
+ const status = useAppSelector(s => s.googleWorkspace.status)
+ const canUse = useAppSelector(s => s.googleWorkspace.canUseByProject[projectId])
+ const [pickerOpen, setPickerOpen] = useState(false)
+
+ useEffect(() => {
+ if (!status.configured && !status.connected) dispatch(fetchStatus())
+ }, [dispatch]) // eslint-disable-line react-hooks/exhaustive-deps
+ useEffect(() => {
+ if (projectId && canUse === undefined) dispatch(fetchCanUse({ projectId }))
+ }, [dispatch, projectId]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ async function openPicker() {
+ try {
+ if (document.requestStorageAccessFor) {
+ await Promise.allSettled([
+ document.requestStorageAccessFor('https://docs.google.com'),
+ document.requestStorageAccessFor('https://accounts.google.com'),
+ ])
+ }
+ } catch (e) { /* guidance shows elsewhere */ }
+ setPickerOpen(true)
+ }
+
+ if (status.drive_comments_on === false) return null
+ if (!(status.configured && status.connected && status.picker_ready && canUse === true)) return null
+
+ return (
+ <>
+
+
+
+ {pickerOpen && (
+ setPickerOpen(false)}
+ onPicked={(files) => { files.forEach(f => { const h = linkHtml(f); if (h) onInsert(h) }) }}
+ />
+ )}
+ >
+ )
+}
diff --git a/views/assets/src/components/google-workspace/DrivePickerModal.jsx b/views/assets/src/components/google-workspace/DrivePickerModal.jsx
new file mode 100644
index 000000000..748e7209a
--- /dev/null
+++ b/views/assets/src/components/google-workspace/DrivePickerModal.jsx
@@ -0,0 +1,149 @@
+import { __ } from '@wordpress/i18n'
+/**
+ * DrivePickerModal — launches the Google Picker (Google-hosted overlay) under
+ * the drive.file scope and attaches picked files by reference.
+ *
+ * The Picker is Google's own iframe overlay (appended to ), so it can't
+ * live inside our task sheet. We render nothing ourselves — just open the
+ * Picker on top of everything — to avoid a spinner dialog covering it.
+ */
+import { useEffect, useRef } from 'react'
+import { useAppDispatch } from '@store/index'
+import { fetchPickerConfig, attachFileFor } from '@store/googleWorkspaceSlice'
+import { toast } from 'sonner'
+
+const GAPI_SRC = 'https://apis.google.com/js/api.js'
+const ZINDEX_STYLE_ID = 'pm-gpicker-zindex'
+
+// Force the Google Picker above the task sheet / WP admin bar.
+function ensureZIndexStyle() {
+ if (document.getElementById(ZINDEX_STYLE_ID)) return
+ const s = document.createElement('style')
+ s.id = ZINDEX_STYLE_ID
+ // z-index: above the task sheet / admin bar.
+ // pointer-events: the open Radix sheet sets body { pointer-events:none },
+ // which freezes the Picker (it lives on ) — re-enable it here.
+ s.textContent =
+ '.picker-dialog-bg{z-index:2147483646 !important;pointer-events:auto !important}' +
+ '.picker-dialog{z-index:2147483647 !important;pointer-events:auto !important}' +
+ '.picker-dialog iframe{pointer-events:auto !important}'
+ document.head.appendChild(s)
+}
+
+function loadPickerApi() {
+ return new Promise((resolve, reject) => {
+ if (window.google?.picker) return resolve()
+ const ready = () => window.gapi.load('picker', { callback: resolve, onerror: reject })
+ if (window.gapi) return ready()
+ const existing = document.querySelector(`script[src="${GAPI_SRC}"]`)
+ if (existing) {
+ existing.addEventListener('load', ready)
+ existing.addEventListener('error', reject)
+ return
+ }
+ const el = document.createElement('script')
+ el.src = GAPI_SRC; el.async = true; el.defer = true
+ el.onload = ready; el.onerror = reject
+ document.body.appendChild(el)
+ })
+}
+
+export default function DrivePickerModal({ projectId, attachableType, attachableId, attachedIds = [], onClose, onPicked }) {
+ const dispatch = useAppDispatch()
+ const launchedRef = useRef(false)
+
+ useEffect(() => {
+ if (launchedRef.current) return
+ launchedRef.current = true
+
+ let cancelled = false
+ const toastId = toast.loading(__('Opening Google Drive…', 'wedevs-project-manager'))
+
+ ;(async () => {
+ try {
+ const cfgRes = await dispatch(fetchPickerConfig())
+ if (!fetchPickerConfig.fulfilled.match(cfgRes)) {
+ throw new Error(cfgRes.payload || __('Could not start Google Picker.', 'wedevs-project-manager'))
+ }
+ const { access_token, api_key, app_id } = cfgRes.payload
+
+ await loadPickerApi()
+ if (cancelled) return
+ ensureZIndexStyle()
+
+ const { google } = window
+ const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
+ .setIncludeFolders(true)
+ .setSelectFolderEnabled(false)
+ .setMode(google.picker.DocsViewMode.LIST)
+
+ const picker = new google.picker.PickerBuilder()
+ .enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
+ .setOrigin(window.location.protocol + '//' + window.location.host)
+ .setOAuthToken(access_token)
+ .setDeveloperKey(api_key)
+ .setAppId(app_id)
+ .addView(view)
+ .setCallback((data) => handlePickerEvent(data, google))
+ .build()
+
+ toast.dismiss(toastId)
+ // Flag the active Picker session so the task sheet won't close on the
+ // Picker's outside focus/pointer events.
+ window.__pmGooglePickerOpen = true
+ picker.setVisible(true)
+ } catch (e) {
+ toast.dismiss(toastId)
+ toast.error(e.message || __('Failed to load Google Picker.', 'wedevs-project-manager'))
+ onClose()
+ }
+ })()
+
+ return () => { cancelled = true; toast.dismiss(toastId); window.__pmGooglePickerOpen = false }
+ }, [dispatch]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ async function handlePickerEvent(data, google) {
+ const Action = google.picker.Action
+
+ // Only PICKED/CANCEL end the session. Other callbacks (LOADED, folder
+ // navigation) must NOT clear the flag, or the sheet would close mid-pick.
+ if (data.action !== Action.PICKED && data.action !== Action.CANCEL) return
+
+ // Session ending — clear the flag a tick later so the close event Radix
+ // sees is still suppressed, then normal behavior resumes.
+ setTimeout(() => { window.__pmGooglePickerOpen = false }, 300)
+
+ if (data.action === Action.CANCEL) { onClose(); return }
+
+ const docs = data.docs || []
+ const files = docs
+ .filter(doc => !attachedIds.includes(doc.id))
+ .map(doc => ({
+ id: doc.id,
+ name: doc.name,
+ mimeType: doc.mimeType,
+ iconLink: doc.iconUrl,
+ webViewLink: doc.url,
+ modifiedTime: doc.lastEditedUtc ? new Date(doc.lastEditedUtc).toISOString() : '',
+ }))
+
+ // Stage mode: hand picked files back to the caller (e.g. a create form
+ // whose entity doesn't exist yet) instead of attaching now.
+ if (typeof onPicked === 'function') {
+ if (files.length) onPicked(files)
+ onClose()
+ return
+ }
+
+ let attached = 0
+ for (const file of files) {
+ const res = await dispatch(attachFileFor({ projectId, attachableType, attachableId, file }))
+ if (attachFileFor.fulfilled.match(res)) attached++
+ }
+ if (attached > 0) toast.success(__('File attached.', 'wedevs-project-manager'))
+ onClose()
+ }
+
+ // Picker renders its own overlay; we render nothing.
+ return null
+}
diff --git a/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx
new file mode 100644
index 000000000..a7b164aa7
--- /dev/null
+++ b/views/assets/src/components/google-workspace/GoogleDriveAttach.jsx
@@ -0,0 +1,203 @@
+import { __ } from '@wordpress/i18n'
+/**
+ * GoogleDriveAttach — reusable Drive attach + list for any entity
+ * (task, comment, discussion, project). Respects per-project role access and
+ * the global on/off + connection state.
+ *
+ * Props:
+ * projectId, attachableType ('task'|'comment'|'discussion'|'project'), attachableId
+ * variant: 'section' (bordered block, default) | 'compact' (inline chips)
+ * title (default "Google Drive")
+ */
+import React, { useEffect, useState } from 'react'
+import { useAppDispatch, useAppSelector } from '@store/index'
+import { fetchStatus, fetchCanUse, fetchAttachmentsFor, detachFileFor } from '@store/googleWorkspaceSlice'
+import { Button } from '@components/ui/button'
+import { FileText, Plus, ExternalLink, Trash2, Link2, Lock, X } from 'lucide-react'
+import { toast } from 'sonner'
+import { GoogleDriveColorGlyph, DriveMonoGlyph } from '@components/google-workspace/GoogleIcons'
+import DrivePickerModal from './DrivePickerModal'
+
+const safeHttpUrl = (u) => {
+ try { const p = new URL(u, window.location.origin); return (p.protocol === 'https:' || p.protocol === 'http:') ? p.href : '' }
+ catch { return '' }
+}
+
+// Tiny "added by" avatar with tooltip — non-intrusive attribution.
+const AdderAvatar = ({ file }) => {
+ if (!file.added_by_name) return null
+ const title = file.added_by_name
+ return file.added_by_avatar
+ ?
+ : {title.charAt(0).toUpperCase()}
+}
+
+const FileIcon = ({ file, className = 'h-4 w-4 shrink-0' }) =>
+ file.icon_link
+ ?
+ :
+
+const MAX_VISIBLE = 2
+
+export default function GoogleDriveAttach({ projectId, attachableType, attachableId, variant = 'section', title, allowEdit = true, addRevealClass = '', showAdd = true }) {
+ const dispatch = useAppDispatch()
+ const key = `${attachableType}:${attachableId}`
+ const status = useAppSelector(s => s.googleWorkspace.status)
+ const canUse = useAppSelector(s => s.googleWorkspace.canUseByProject[projectId])
+ const canEdit = canUse === true && allowEdit
+ const attachments = useAppSelector(s => s.googleWorkspace.attachmentsByKey[key] || [])
+ const [pickerOpen, setPickerOpen] = useState(false)
+ const [expanded, setExpanded] = useState(false)
+
+ useEffect(() => {
+ if (!status.configured && !status.connected) dispatch(fetchStatus())
+ }, [dispatch]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ if (projectId && canUse === undefined) dispatch(fetchCanUse({ projectId }))
+ }, [dispatch, projectId]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ if (projectId && attachableId) dispatch(fetchAttachmentsFor({ projectId, attachableType, attachableId }))
+ }, [dispatch, projectId, attachableType, attachableId])
+
+ async function onDetach(id) {
+ const res = await dispatch(detachFileFor({ projectId, attachableType, attachableId, id }))
+ if (detachFileFor.fulfilled.match(res)) toast.success(__('File removed.', 'wedevs-project-manager'))
+ }
+
+ async function openPicker() {
+ try {
+ if (document.requestStorageAccessFor) {
+ await Promise.allSettled([
+ document.requestStorageAccessFor('https://docs.google.com'),
+ document.requestStorageAccessFor('https://accounts.google.com'),
+ ])
+ } else if (document.hasStorageAccess && document.requestStorageAccess) {
+ const has = await document.hasStorageAccess()
+ if (!has) await document.requestStorageAccess()
+ }
+ } catch (e) { /* browser will prompt or guidance shows */ }
+ setPickerOpen(true)
+ }
+
+ if (!status.configured) return null
+ // Nothing to show and can't add → hide entirely.
+ if ((canUse === false || !allowEdit) && attachments.length === 0) return null
+ // Chips-only mode (add button lives elsewhere) with nothing attached → hide.
+ if (!showAdd && attachments.length === 0) return null
+
+ const action = status.connected && canEdit ? (
+ variant === 'compact' ? (
+
+
+
+ ) : (
+
+ {__('Attach', 'wedevs-project-manager')}
+
+ )
+ ) : canUse === false ? (
+
+ {__('View only', 'wedevs-project-manager')}
+
+ ) : canEdit && !status.connected ? (
+
+ {__('Connect Google', 'wedevs-project-manager')}
+
+ ) : null
+
+ const picker = pickerOpen && (
+ f.file_id)}
+ onClose={() => setPickerOpen(false)}
+ />
+ )
+
+ // Compact: a small button + chips (for comment/discussion composers).
+ if (variant === 'compact') {
+ return (
+
+ {attachments.map(file => (
+
+
+ {file.name}
+
+ {canEdit && (
+ onDetach(file.id)} className="text-gray-400 hover:text-red-600" title={__('Remove', 'wedevs-project-manager')}>
+
+
+ )}
+
+ ))}
+ {action}
+ {picker}
+
+ )
+ }
+
+ // Section: bordered block with header (task detail / files).
+ return (
+
+
+
+
+ {variant === 'section' && (title || __('Google Drive', 'wedevs-project-manager'))}
+ {attachments.length > 0 && ({attachments.length}) }
+
+ {action}
+
+
+ {attachments.length === 0 ? (
+
{__('No Drive files attached.', 'wedevs-project-manager')}
+ ) : (
+ <>
+
+ {(expanded ? attachments : attachments.slice(0, MAX_VISIBLE)).map(file => (
+
+
+
+ {file.name}
+
+
+
+
+
+ {canEdit && (
+ onDetach(file.id)} className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600" title={__('Remove', 'wedevs-project-manager')}>
+
+
+ )}
+
+ ))}
+
+ {attachments.length > MAX_VISIBLE && (
+
setExpanded(v => !v)}
+ className="mt-1 text-xs font-medium text-blue-600 hover:underline"
+ >
+ {expanded
+ ? __('Show less', 'wedevs-project-manager')
+ : `${__('Show all', 'wedevs-project-manager')} (${attachments.length})`}
+
+ )}
+ >
+ )}
+ {picker}
+
+ )
+}
diff --git a/views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx b/views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx
new file mode 100644
index 000000000..9711f5fe0
--- /dev/null
+++ b/views/assets/src/components/google-workspace/GoogleDriveCommentButton.jsx
@@ -0,0 +1,62 @@
+import { __ } from '@wordpress/i18n'
+/**
+ * GoogleDriveCommentButton — icon-only Drive add button for a comment header
+ * (sits beside Edit/Delete). Opens the Picker; attachments render separately
+ * via under the comment body.
+ */
+import React, { useEffect, useState } from 'react'
+import { useAppDispatch, useAppSelector } from '@store/index'
+import { fetchStatus, fetchCanUse, fetchAttachmentsFor } from '@store/googleWorkspaceSlice'
+import { DriveMonoGlyph } from '@components/google-workspace/GoogleIcons'
+import DrivePickerModal from './DrivePickerModal'
+
+export default function GoogleDriveCommentButton({ projectId, attachableType, attachableId, allowEdit = true, className = '' }) {
+ const dispatch = useAppDispatch()
+ const status = useAppSelector(s => s.googleWorkspace.status)
+ const canUse = useAppSelector(s => s.googleWorkspace.canUseByProject[projectId])
+ const [pickerOpen, setPickerOpen] = useState(false)
+
+ useEffect(() => {
+ if (!status.configured && !status.connected) dispatch(fetchStatus())
+ }, [dispatch]) // eslint-disable-line react-hooks/exhaustive-deps
+ useEffect(() => {
+ if (projectId && canUse === undefined) dispatch(fetchCanUse({ projectId }))
+ }, [dispatch, projectId]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ async function openPicker() {
+ try {
+ if (document.requestStorageAccessFor) {
+ await Promise.allSettled([
+ document.requestStorageAccessFor('https://docs.google.com'),
+ document.requestStorageAccessFor('https://accounts.google.com'),
+ ])
+ }
+ } catch (e) { /* guidance shows elsewhere */ }
+ setPickerOpen(true)
+ }
+
+ // Only show when the user can actually add here.
+ if (status.drive_comments_on === false) return null
+ if (!(status.configured && status.connected && status.picker_ready && canUse === true && allowEdit)) return null
+
+ return (
+ <>
+
+
+
+ {pickerOpen && (
+ setPickerOpen(false)}
+ />
+ )}
+ >
+ )
+}
diff --git a/views/assets/src/components/google-workspace/GoogleDriveStage.jsx b/views/assets/src/components/google-workspace/GoogleDriveStage.jsx
new file mode 100644
index 000000000..1150071a1
--- /dev/null
+++ b/views/assets/src/components/google-workspace/GoogleDriveStage.jsx
@@ -0,0 +1,87 @@
+import { __ } from '@wordpress/i18n'
+/**
+ * GoogleDriveStage — pick Drive files BEFORE an entity exists (e.g. the
+ * new-discussion form). Controlled: holds picked files in `value`; the parent
+ * attaches them after the entity is created. No backend writes here.
+ */
+import React, { useEffect, useState } from 'react'
+import { useAppDispatch, useAppSelector } from '@store/index'
+import { fetchStatus, fetchCanUse } from '@store/googleWorkspaceSlice'
+import { Button } from '@components/ui/button'
+import { Plus, X, FileText, Link2 } from 'lucide-react'
+import { DriveSolidGlyph } from '@components/google-workspace/GoogleIcons'
+import DrivePickerModal from './DrivePickerModal'
+
+export default function GoogleDriveStage({ projectId, value = [], onChange }) {
+ const dispatch = useAppDispatch()
+ const status = useAppSelector(s => s.googleWorkspace.status)
+ const canUse = useAppSelector(s => s.googleWorkspace.canUseByProject[projectId])
+ const [pickerOpen, setPickerOpen] = useState(false)
+
+ useEffect(() => {
+ if (!status.configured && !status.connected) dispatch(fetchStatus())
+ }, [dispatch]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ if (projectId && canUse === undefined) dispatch(fetchCanUse({ projectId }))
+ }, [dispatch, projectId]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ async function openPicker() {
+ try {
+ if (document.requestStorageAccessFor) {
+ await Promise.allSettled([
+ document.requestStorageAccessFor('https://docs.google.com'),
+ document.requestStorageAccessFor('https://accounts.google.com'),
+ ])
+ }
+ } catch (e) { /* guidance shows */ }
+ setPickerOpen(true)
+ }
+
+ function onPicked(files) {
+ const existing = new Set(value.map(f => f.id))
+ onChange([...value, ...files.filter(f => !existing.has(f.id))])
+ }
+
+ function remove(id) {
+ onChange(value.filter(f => f.id !== id))
+ }
+
+ if (!status.configured || canUse === false) return null
+
+ return (
+
+ {value.map(file => (
+
+ {file.iconLink ? : }
+ {file.name}
+ remove(file.id)} className="text-gray-400 hover:text-red-600">
+
+ ))}
+
+ {status.connected ? (
+
+
+
+ ) : (
+
+ {__('Connect Google', 'wedevs-project-manager')}
+
+ )}
+
+ {pickerOpen && (
+
f.id)}
+ onPicked={onPicked}
+ onClose={() => setPickerOpen(false)}
+ />
+ )}
+
+ )
+}
diff --git a/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx
new file mode 100644
index 000000000..c765ae339
--- /dev/null
+++ b/views/assets/src/components/google-workspace/GoogleDriveTaskSection.jsx
@@ -0,0 +1,18 @@
+import React from 'react'
+import GoogleDriveAttach from './GoogleDriveAttach'
+
+/**
+ * Task detail Drive section — fills the task.detail.subtasks slot.
+ * Thin wrapper over the shared GoogleDriveAttach.
+ */
+export default function GoogleDriveTaskSection({ taskId, projectId }) {
+ if (!taskId || !projectId) return null
+ return (
+
+ )
+}
diff --git a/views/assets/src/components/google-workspace/GoogleIcons.jsx b/views/assets/src/components/google-workspace/GoogleIcons.jsx
new file mode 100644
index 000000000..b288accce
--- /dev/null
+++ b/views/assets/src/components/google-workspace/GoogleIcons.jsx
@@ -0,0 +1,83 @@
+import React from 'react'
+
+/** Google Calendar brand glyph. Size via className (e.g. h-5 w-5). */
+export const CalendarGlyph = (props) => (
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+/** Monochrome Google Drive glyph (currentColor outline). Size via className. */
+export const DriveMonoGlyph = (props) => (
+
+
+
+
+
+)
+
+/** Google Meet brand glyph. Size via className. */
+export const MeetGlyph = (props) => (
+
+
+
+
+
+
+
+
+
+
+)
+
+/** Solid monochrome Google Drive glyph (currentColor fill). Size via className. */
+export const DriveSolidGlyph = (props) => (
+
+
+
+
+
+)
+
+/** Full-color Google Drive glyph. Size via width/height or className. */
+export const GoogleDriveColorGlyph = (props) => (
+
+
+
+
+
+
+
+
+
+
+)
+
+/** Full-color Google "G" logo. Default 20×20; override via width/height or className. */
+export const GoogleColorGlyph = (props) => (
+
+
+
+
+
+
+)
+
+/** Monochrome Google "G" logo (currentColor fill). Size via className. */
+export const GoogleMonoGlyph = (props) => (
+
+
+
+
+
+
+)
diff --git a/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx
new file mode 100644
index 000000000..616feb808
--- /dev/null
+++ b/views/assets/src/components/google-workspace/GoogleWorkspacePage.jsx
@@ -0,0 +1,219 @@
+import { __ } from '@wordpress/i18n'
+/**
+ * GoogleWorkspacePage — per-user account connection (every user, incl. admin,
+ * connects their own Google account here). One connection powers all Google
+ * Workspace features (Drive now; Calendar/Meet later).
+ *
+ * Admin credential setup lives separately under Settings → Google Workspace.
+ */
+import React, { useEffect, useState } from 'react'
+import { useAppDispatch, useAppSelector } from '@store/index'
+import { fetchStatus, getAuthUrl, disconnect, saveDrivePref } from '@store/googleWorkspaceSlice'
+import { Button } from '@components/ui/button'
+import { Switch } from '@components/ui/switch'
+import { Skeleton } from '@components/ui/skeleton'
+import {
+ AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
+ AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
+} from '@components/ui/alert-dialog'
+import { ShieldCheck, Unlink, HardDrive, Settings as SettingsIcon, Lock, Info } from 'lucide-react'
+import { CalendarGlyph, MeetGlyph, GoogleColorGlyph, GoogleDriveColorGlyph } from '@components/google-workspace/GoogleIcons'
+import ProBadge from '@components/common/ProBadge'
+import { toast } from 'sonner'
+import { Slot } from '@hooks/useSlot'
+import { useProModal } from '@components/common/ProUpgradeModal'
+
+/**
+ * Free card cover for a Pro Google feature section (e.g. Calendar). Pro replaces
+ * it by filling the matching slot with the real settings.
+ */
+const ProFeatureCard = ({ icon: Icon, title, description }) => {
+ const { setOpen } = useProModal()
+ return (
+
+ )
+}
+
+export default function GoogleWorkspacePage() {
+ const dispatch = useAppDispatch()
+ const { status, statusLoading } = useAppSelector(s => s.googleWorkspace)
+ const [connecting, setConnecting] = useState(false)
+ const [disconnectOpen, setDisconnectOpen] = useState(false)
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search)
+ const result = params.get('google_connected')
+ if (!result) return
+ const messages = {
+ success: [__('Google account connected.', 'wedevs-project-manager'), 'success'],
+ error: [__('Could not connect Google account. Try again.', 'wedevs-project-manager'), 'error'],
+ denied: [__('Google connection was cancelled.', 'wedevs-project-manager'), 'error'],
+ invalid_state: [__('Security check failed. Please retry.', 'wedevs-project-manager'), 'error'],
+ }
+ const [msg, type] = messages[result] || []
+ if (msg) (type === 'success' ? toast.success : toast.error)(msg)
+ params.delete('google_connected')
+ const clean = window.location.pathname + (params.toString() ? `?${params}` : '') + window.location.hash
+ window.history.replaceState({}, '', clean)
+ }, [])
+
+ useEffect(() => { dispatch(fetchStatus()) }, [dispatch])
+
+ async function onConnect() {
+ setConnecting(true)
+ const res = await dispatch(getAuthUrl())
+ setConnecting(false)
+ if (getAuthUrl.fulfilled.match(res) && res.payload) window.location.href = res.payload
+ else toast.error(res.payload || __('Could not start Google connection.', 'wedevs-project-manager'))
+ }
+
+ async function onDisconnect() {
+ const res = await dispatch(disconnect())
+ if (disconnect.fulfilled.match(res)) toast.success(__('Google account disconnected.', 'wedevs-project-manager'))
+ else toast.error(res.payload || __('Failed to disconnect.', 'wedevs-project-manager'))
+ }
+
+ return (
+
+
+
+
+ {status.expired && (
+
+ {__('Your Google connection expired (site security keys changed). Please reconnect — your attached files are unaffected.', 'wedevs-project-manager')}
+
+ )}
+
+ {statusLoading ? (
+
+ ) : !status.configured ? (
+
+
+ {__('Google Workspace isn’t set up yet. An administrator needs to add the credentials under Settings → Google Workspace.', 'wedevs-project-manager')}
+
+ ) : status.connected ? (
+
+
+
+ {__('Connected as', 'wedevs-project-manager')} {status.account_email || __('Google account', 'wedevs-project-manager')}
+
+
setDisconnectOpen(true)}>
+ {__('Disconnect', 'wedevs-project-manager')}
+
+
+ ) : (
+
+
{__('Connect your Google account to browse and attach Drive files to tasks.', 'wedevs-project-manager')}
+
+ {connecting ? __('Redirecting…', 'wedevs-project-manager') : __('Connect Google', 'wedevs-project-manager')}
+
+
+ )}
+
+ {status.configured && (
+
+
+ {__('All Google features — Drive, Calendar and Meet — use this one account.', 'wedevs-project-manager')}
+
+ )}
+
+
+ {/* Connected services — one card per Google feature. */}
+
{__('Connected services', 'wedevs-project-manager')}
+
+ {/* Google Drive (free) — admin master gate + per-user on/off */}
+
+
+
+
+
+
{__('Google Drive', 'wedevs-project-manager')}
+
{__('Attach Drive files to tasks, comments, discussions and files.', 'wedevs-project-manager')}
+
+
+ {status.drive_enabled ? (
+
{
+ const res = await dispatch(saveDrivePref({ drive_on: v }))
+ if (saveDrivePref.fulfilled.match(res)) {
+ toast.success(v ? __('Google Drive enabled.', 'wedevs-project-manager') : __('Google Drive disabled.', 'wedevs-project-manager'))
+ } else {
+ toast.error(__('Failed to update Drive setting.', 'wedevs-project-manager'))
+ }
+ }}
+ />
+ ) : (
+ {__('Off', 'wedevs-project-manager')}
+ )}
+
+ {!status.drive_enabled ? (
+ {__('Drive is turned off. An administrator can enable it in Settings → Google Workspace.', 'wedevs-project-manager')}
+ ) : !status.connected ? (
+ {__('Connect your Google account above to use Drive.', 'wedevs-project-manager')}
+ ) : null}
+
+
+ {/* Google Calendar — Pro fills with connect + status; free shows a cover. */}
+
}
+ />
+
+ {/* Google Meet — Pro (coming soon); free shows a cover. */}
+
}
+ />
+
+
+
+
+ {__('Disconnect this Google account?', 'wedevs-project-manager')}
+
+ {__('This unlinks your Google account from Project Manager. Here’s what happens:', 'wedevs-project-manager')}
+
+
+
+ {__('Calendar events created from your tasks and milestones are removed from Google Calendar.', 'wedevs-project-manager')}
+ {__('Drive files you attached stay listed, but you can’t open or attach more until you reconnect.', 'wedevs-project-manager')}
+ {__('All Google features (Drive, Calendar, Meet) stop working for you until you reconnect.', 'wedevs-project-manager')}
+ {__('You can reconnect anytime — your data isn’t deleted.', 'wedevs-project-manager')}
+
+
+ {__('Cancel', 'wedevs-project-manager')}
+ { setDisconnectOpen(false); onDisconnect() }}>
+ {__('Disconnect', 'wedevs-project-manager')}
+
+
+
+
+
+ )
+}
diff --git a/views/assets/src/components/layout/AppSidebar.jsx b/views/assets/src/components/layout/AppSidebar.jsx
index d3135bc85..036a58a77 100644
--- a/views/assets/src/components/layout/AppSidebar.jsx
+++ b/views/assets/src/components/layout/AppSidebar.jsx
@@ -1,6 +1,7 @@
import { __ } from '@wordpress/i18n';
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
import { useLocation, useNavigate, Link } from 'react-router-dom'
+import { useSelector } from 'react-redux'
import { useApi } from '@hooks/useApi'
import { usePermissions } from '@hooks/usePermissions'
import { useActiveProModules, isProModuleActive, isProPluginInstalled } from '@hooks/useActiveProModules'
@@ -14,6 +15,7 @@ import {
LayoutTemplate,
} from 'lucide-react'
import { cn } from '@lib/utils'
+import { DriveMonoGlyph as GoogleDriveNavIcon } from '@components/google-workspace/GoogleIcons'
function statusColor(p) {
const s = p.status
@@ -224,6 +226,15 @@ export function AppSidebar() {
// Pro plugin installed (pm-pro.js loaded) — may or may not be licensed
const isProInstalled = isProPluginInstalled()
+ // G Workspace nav visibility: show when the admin has enabled ANY feature
+ // (Drive, Calendar, or Meet). Prefer the live store (reacts to toggles this
+ // session); fall back to PM_Vars until status is first fetched.
+ const gwStatusFetched = useSelector(s => s.googleWorkspace?.statusFetched)
+ const gwStatus = useSelector(s => s.googleWorkspace?.status)
+ const gwVars = (typeof PM_Vars !== 'undefined' && PM_Vars.google_workspace) || {}
+ const anyFeature = (src) => !!(src && (src.drive_enabled || src.calendar_enabled || src.meet_enabled))
+ const workspaceNavEnabled = gwStatusFetched ? anyFeature(gwStatus) : anyFeature(gwVars)
+
const viewNavItems = useMemo(() => {
const isModuleActive = (dir) => isProModuleActive(activeModulePaths, dir)
const items = [
@@ -238,8 +249,13 @@ export function AppSidebar() {
items.push({ key: 'sprints', label: __('Sprints', 'wedevs-project-manager'), short: __('Sprint', 'wedevs-project-manager'), icon: Timer, route: '/sprints', pro: !isPro })
}
}
+ // Google Workspace — free feature, shown to everyone when the admin has
+ // enabled Google Drive in Settings → Google Workspace.
+ if (workspaceNavEnabled) {
+ items.push({ key: 'google-workspace', label: __('G Workspace', 'wedevs-project-manager'), short: __('Workspace', 'wedevs-project-manager'), icon: GoogleDriveNavIcon, route: '/google-workspace' })
+ }
return items
- }, [__, isPro, activeModulePaths, canManage, isManagerAnywhere])
+ }, [__, isPro, activeModulePaths, canManage, isManagerAnywhere, workspaceNavEnabled])
// Auto-collapse sidebar on full-width pages (reports, calendar, progress, sprints)
const autoCollapsedRef = useRef(false)
@@ -284,6 +300,7 @@ export function AppSidebar() {
if (path.startsWith('/progress')) return 'progress'
if (path.startsWith('/reports')) return 'reports'
if (path.startsWith('/sprints')) return 'sprints'
+ if (path.startsWith('/google-workspace')) return 'google-workspace'
if (path.startsWith('/templates')) return 'templates'
if (path.startsWith('/importtools')) return 'importtools'
if (path.startsWith('/license')) return 'license'
@@ -315,7 +332,7 @@ export function AppSidebar() {
)}
title={item.label}
>
-
+
{collapsed
? {item.short ?? item.label}
: {item.label}
diff --git a/views/assets/src/components/projects/ActivitiesPage/constants.js b/views/assets/src/components/projects/ActivitiesPage/constants.js
index 7da75ae1c..a1a2ca598 100644
--- a/views/assets/src/components/projects/ActivitiesPage/constants.js
+++ b/views/assets/src/components/projects/ActivitiesPage/constants.js
@@ -2,7 +2,9 @@ import { __ } from '@wordpress/i18n';
import {
Activity, CheckSquare, FolderKanban, MessageSquare,
FileText, Milestone, Trash2, Edit3, ArrowUpDown, Plus,
+ Video,
} from 'lucide-react';
+import { DriveMonoGlyph } from '@components/google-workspace/GoogleIcons';
export const ACTION_ICON_MAP = {
create_project: FolderKanban,
@@ -40,6 +42,9 @@ export const ACTION_ICON_MAP = {
upload_file: FileText,
create_file: FileText,
delete_file: Trash2,
+ attach_drive_file: DriveMonoGlyph,
+ detach_drive_file: Trash2,
+ create_meet: Video,
};
export const ACTION_COLOR_MAP = {
@@ -77,6 +82,9 @@ export const ACTION_FALLBACKS = {
comment_on_discussion_board: __('commented on a discussion', 'wedevs-project-manager'),
comment_on_project: __('commented on a project', 'wedevs-project-manager'),
reply_comment_on_task: __('replied to a comment', 'wedevs-project-manager'),
+ attach_drive_file: __('attached a Google Drive file', 'wedevs-project-manager'),
+ detach_drive_file: __('removed a Google Drive file', 'wedevs-project-manager'),
+ create_meet: __('started a Google Meet meeting', 'wedevs-project-manager'),
};
-export { Activity };
+export { Activity, DriveMonoGlyph, Video };
diff --git a/views/assets/src/components/projects/ActivitiesPage/parts/ActivityItem.jsx b/views/assets/src/components/projects/ActivitiesPage/parts/ActivityItem.jsx
index 3026ba97a..cffcf339b 100644
--- a/views/assets/src/components/projects/ActivitiesPage/parts/ActivityItem.jsx
+++ b/views/assets/src/components/projects/ActivitiesPage/parts/ActivityItem.jsx
@@ -11,6 +11,8 @@ import {
ACTION_COLOR_MAP,
ACTION_LABELS,
Activity,
+ DriveMonoGlyph,
+ Video,
} from '../constants';
import { parseMessage, formatTime } from '../utils';
@@ -32,6 +34,29 @@ export default function ActivityItem({ act, projectId: fallbackProjectId }) {
const projectId = act.project?.data?.id || act.project?.id || act.meta?.project_id || fallbackProjectId;
const isTask = act.resource_type === 'task' && act.resource_id && projectId;
+ const meta = act.meta || {};
+ // Drive link only for the "attached" entry; deleted actions never link the file.
+ const driveUrl = act.action === 'attach_drive_file' ? meta.file_url : null;
+ const showDrive = act.action === 'attach_drive_file' || !!meta.has_drive;
+ const showMeet = !!meta.has_meet;
+
+ const marks = (showDrive || showMeet) ? (
+
+ {showDrive && (
+ driveUrl ? (
+
+
+
+ ) : (
+
+ )
+ )}
+ {showMeet && (
+
+ )}
+
+ ) : null;
+
const handleMessageClick = () => {
if (isTask) {
dispatch(openTaskSheet({ id: act.resource_id, project_id: projectId }));
@@ -54,19 +79,22 @@ export default function ActivityItem({ act, projectId: fallbackProjectId }) {
{badgeLabel}
- {isTask ? (
-
- {parseMessage(act)}
-
- ) : (
- {parseMessage(act)}
- )}
+
+ {isTask ? (
+
+ {parseMessage(act)}
+
+ ) : (
+
{parseMessage(act)}
+ )}
+ {marks}
+
{timeStr && (
- {timeStr}
+ {timeStr}
)}
diff --git a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx
index 32fac9350..090382ca1 100644
--- a/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx
+++ b/views/assets/src/components/projects/DiscussionsPage/DiscussionDetailPage.jsx
@@ -13,6 +13,7 @@ import NotionPreviewContainer from "@components/common/NotionPreviewContainer";
import LoomPreviewContainer from "@components/common/LoomPreviewContainer";
import { stripAllPreviewUrls } from "@/lib/url-strippers";
import { sanitizeHtml } from "@lib/sanitize";
+import { decorateGoogleLinks } from "@lib/google-links";
import { Skeleton } from "@components/ui/skeleton";
import { UserAvatar } from "@components/common/UserAvatar";
import { Separator } from "@components/ui/separator";
@@ -46,6 +47,8 @@ import ProBadge from "@components/common/ProBadge";
import CommentAttachment from "@components/common/CommentAttachment";
import { formatPmDateTime } from "@lib/pm-utils";
import { usePermissions } from "@hooks/usePermissions";
+import GoogleDriveAttach from "@components/google-workspace/GoogleDriveAttach";
+import CommentLinkActions from "@components/google-workspace/CommentLinkActions";
import { useCurrentProject } from "@hooks/useCurrentProject";
import DiscussionFiles from "./parts/DiscussionFiles";
@@ -455,7 +458,7 @@ export default function DiscussionDetailPage() {
@@ -466,6 +469,9 @@ export default function DiscussionDetailPage() {
})()}
+
+
+
>
)}
@@ -547,6 +553,7 @@ export default function DiscussionDetailPage() {
>
{__("Cancel", 'wedevs-project-manager')}
+ setEditCommentText(prev => (prev || '') + html)} />
) : (
@@ -554,7 +561,7 @@ export default function DiscussionDetailPage() {
@@ -599,6 +606,7 @@ export default function DiscussionDetailPage() {
value={commentNotifyUsers}
onChange={setCommentNotifyUsers}
/>
+ setNewComment(prev => (prev || '') + html)} />
diff --git a/views/assets/src/components/projects/DiscussionsPage/index.jsx b/views/assets/src/components/projects/DiscussionsPage/index.jsx
index 304ac1c95..811817188 100644
--- a/views/assets/src/components/projects/DiscussionsPage/index.jsx
+++ b/views/assets/src/components/projects/DiscussionsPage/index.jsx
@@ -28,6 +28,9 @@ import {
ChevronRight,
} from "lucide-react";
import FileUploadArea from "@components/common/FileUploadArea";
+import CommentLinkActions from "@components/google-workspace/CommentLinkActions";
+import { useAppDispatch } from "@store/index";
+import { attachFileFor } from "@store/googleWorkspaceSlice";
import NotifyUsers from "@components/common/NotifyUsers";
import {
DropdownMenu,
@@ -45,6 +48,7 @@ export default function DiscussionsPage() {
const { projectId } = useParams();
const navigate = useNavigate();
const api = useApi();
+ const dispatch = useAppDispatch();
const toast = useToast();
const [ConfirmDialog, confirm] = useConfirm();
const project = useCurrentProject(projectId);
@@ -67,6 +71,7 @@ export default function DiscussionsPage() {
const [formDesc, setFormDesc] = useState("");
const [formMilestone, setFormMilestone] = useState("-1");
const [formFiles, setFormFiles] = useState([]);
+ const [stagedDrive, setStagedDrive] = useState([]);
const [formNotifyUsers, setFormNotifyUsers] = useState([]);
const [creating, setCreating] = useState(false);
@@ -120,10 +125,17 @@ export default function DiscussionsPage() {
formFiles.forEach((f) => fd.append("files[]", f));
const res = await api.upload(`projects/${projectId}/discussion-boards`, fd);
const newDisc = res?.data ?? res;
+ // Attach any staged Drive files now that the discussion has an id.
+ if (newDisc?.id && stagedDrive.length) {
+ for (const file of stagedDrive) {
+ await dispatch(attachFileFor({ projectId, attachableType: 'discussion', attachableId: newDisc.id, file }));
+ }
+ }
setFormTitle("");
setFormDesc("");
setFormMilestone("-1");
setFormFiles([]);
+ setStagedDrive([]);
setFormNotifyUsers([]);
setShowForm(false);
toast.success(__("Discussion created", 'wedevs-project-manager'));
@@ -137,7 +149,7 @@ export default function DiscussionsPage() {
}
setCreating(false);
},
- [api, projectId, formTitle, formDesc, formMilestone, formFiles, formNotifyUsers, creating, toast, __, fetchDiscussions, navigate]
+ [api, projectId, formTitle, formDesc, formMilestone, formFiles, formNotifyUsers, creating, toast, __, fetchDiscussions, navigate, stagedDrive, dispatch]
);
const handleDelete = useCallback(
@@ -232,6 +244,7 @@ export default function DiscussionsPage() {
+ setFormDesc(prev => (prev || '') + html)} />
) : (
-
+
)}
{/* Comment files */}
{!isEditing && comment.files?.data?.length > 0 && (
diff --git a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx
index a2bbb7b98..8e29749d5 100644
--- a/views/assets/src/components/tasks/TaskDetailSheet/index.jsx
+++ b/views/assets/src/components/tasks/TaskDetailSheet/index.jsx
@@ -25,8 +25,10 @@ import NotionPreviewContainer from '@components/common/NotionPreviewContainer'
import LoomPreviewContainer from '@components/common/LoomPreviewContainer'
import { stripAllPreviewUrls } from '@/lib/url-strippers'
import { sanitizeHtml } from '@lib/sanitize'
+import { decorateGoogleLinks } from '@lib/google-links'
import FileUploadArea from '@components/common/FileUploadArea'
import CommentAttachment from '@components/common/CommentAttachment'
+import CommentLinkActions from '@components/google-workspace/CommentLinkActions'
import TaskStatusCircle from '@components/common/TaskStatusCircle'
import NotifyUsers from '@components/common/NotifyUsers'
import { UserAvatar } from '@components/common/UserAvatar'
@@ -62,7 +64,9 @@ import {
Pencil,
FileText,
Loader2,
+ Video,
} from 'lucide-react'
+import { DriveMonoGlyph } from '@components/google-workspace/GoogleIcons'
import {
isTaskComplete,
formatPmDate,
@@ -89,6 +93,19 @@ function extractMentionedUsers(html) {
return ids.join(',')
}
+// The Google Drive Picker renders its own overlay outside this sheet's DOM.
+// Treat clicks/focus on it as "inside" so the task sheet stays open while
+// the user picks a file (only the X / Esc should close the sheet).
+function isGooglePickerInteraction(e) {
+ // While the Picker session is active, never let an outside interaction close
+ // the sheet (the Picker overlay lives outside this DOM and its focus/pointer
+ // events would otherwise dismiss the task).
+ if (typeof window !== 'undefined' && window.__pmGooglePickerOpen) return true
+ const t = e?.detail?.originalEvent?.target || e?.target
+ if (!t || typeof t.closest !== 'function') return false
+ return !!t.closest('.picker-dialog, .picker-dialog-bg, .picker, .picker-dialog-content')
+}
+
export default function TaskDetailSheet() {
const dispatch = useAppDispatch()
const navigate = useNavigate()
@@ -486,6 +503,9 @@ export default function TaskDetailSheet() {
'overflow-y-auto p-0 transition-all duration-300',
fullscreen ? 'w-full sm:max-w-full' : 'w-full sm:max-w-[560px]',
)}
+ onPointerDownOutside={(e) => { if (isGooglePickerInteraction(e)) e.preventDefault() }}
+ onInteractOutside={(e) => { if (isGooglePickerInteraction(e)) e.preventDefault() }}
+ onFocusOutside={(e) => { if (isGooglePickerInteraction(e)) e.preventDefault() }}
>
{loading && !currentTask ? (
@@ -827,11 +847,12 @@ export default function TaskDetailSheet() {
{savingEditComment ? __('Saving...', 'wedevs-project-manager') : __('Save', 'wedevs-project-manager')}
{__('Cancel', 'wedevs-project-manager')}
+ setEditCommentText(prev => (prev || '') + html)} />
) : (
<>
-
+
@@ -865,9 +886,12 @@ export default function TaskDetailSheet() {
value={commentNotifyUsers}
onChange={setCommentNotifyUsers}
/>
-
- {submittingComment ? __('Sending...', 'wedevs-project-manager') : __('Add Comment', 'wedevs-project-manager')}
-
+
+
+ {submittingComment ? __('Sending...', 'wedevs-project-manager') : __('Add Comment', 'wedevs-project-manager')}
+
+
setNewComment(prev => (prev || '') + html)} />
+
@@ -935,6 +959,18 @@ export default function TaskDetailSheet() {
) : (
{parseActivityMessage(act) || act.action}
)}
+ {(act.action === 'attach_drive_file' || act.meta?.has_drive) && (
+ act.action === 'attach_drive_file' && act.meta?.file_url ? (
+
+
+
+ ) : (
+
+ )
+ )}
+ {act.meta?.has_meet && (
+
+ )}
{act.committed_at && · {formatPmDateTime(act.committed_at)} }
diff --git a/views/assets/src/index.jsx b/views/assets/src/index.jsx
index 47d6bbd52..f69daa96c 100644
--- a/views/assets/src/index.jsx
+++ b/views/assets/src/index.jsx
@@ -54,6 +54,14 @@ const MyTasksPage = React.lazy(() => import('@components/my-tasks/MyTasksPag
const ToolsPage = React.lazy(() => import('@components/projects/ToolsPage'))
const WelcomePage = React.lazy(() => import('@components/welcome/WelcomePage'))
const LicensePage = React.lazy(() => import('@components/projects/LicensePage'))
+const GoogleWorkspacePage = React.lazy(() => import('@components/google-workspace/GoogleWorkspacePage'))
+const GoogleDriveTaskSection = React.lazy(() => import('@components/google-workspace/GoogleDriveTaskSection'))
+
+// Google Drive attachments render in the task detail sheet (free feature),
+// only when an admin has enabled Drive in Settings → Google Workspace.
+if (typeof PM_Vars !== 'undefined' && PM_Vars.google_workspace?.drive_enabled) {
+ registerSlot('task.detail.subtasks', GoogleDriveTaskSection)
+}
// ── Free placeholder pages (shown when Pro does NOT replace them) ──
const CalendarPlaceholder = React.lazy(() => import('@components/projects/CalendarPage'))
@@ -125,6 +133,7 @@ function AppRoutes() {
{!isFrontend && } />}
{!isFrontend && } />}
} />
+ } />
{!isFrontend && isProInstalled && } />}
{/* ── Replaceable pages — Pro overrides via registerFilter() ── */}
@@ -242,6 +251,13 @@ window.PM = {
// Free store actions/thunks that pro may need to dispatch
thunks: { fetchTask, fetchTaskLists, fetchProjectAssignees },
+
+ // Google Workspace: free owns OAuth/token; pro (Calendar/Meet) dispatches these.
+ googleWorkspace: {
+ fetchStatus: require('@store/googleWorkspaceSlice').fetchStatus,
+ getAuthUrl: require('@store/googleWorkspaceSlice').getAuthUrl,
+ disconnect: require('@store/googleWorkspaceSlice').disconnect,
+ },
actions: { resetProjectState, openTaskSheet, closeTaskSheet, markTaskModified },
// Free hooks that pro may use (same Redux store context)
@@ -259,6 +275,11 @@ window.PM = {
BackButton: require('@components/common/BackButton'),
FileUploadArea: require('@components/common/FileUploadArea'),
CommentAttachment: require('@components/common/CommentAttachment'),
+ GoogleDriveAttach: require('@components/google-workspace/GoogleDriveAttach'),
+ GoogleDriveCommentButton: require('@components/google-workspace/GoogleDriveCommentButton'),
+ CommentLinkActions: require('@components/google-workspace/CommentLinkActions'),
+ GoogleDriveStage: require('@components/google-workspace/GoogleDriveStage'),
+ DrivePickerModal: require('@components/google-workspace/DrivePickerModal'),
TaskStatusCircle: require('@components/common/TaskStatusCircle'),
ProBadge: require('@components/common/ProBadge'),
ProUpgradeModal: require('@components/common/ProUpgradeModal'),
@@ -330,6 +351,7 @@ window.PM = {
urlStrippers: require('@/lib/url-strippers'),
sanitize: require('@lib/sanitize'),
pmUtils: require('@lib/pm-utils'),
+ googleLinks: require('@lib/google-links'),
},
// Re-export Radix UI primitives so pro uses the SAME context instances.
diff --git a/views/assets/src/lib/google-links.js b/views/assets/src/lib/google-links.js
new file mode 100644
index 000000000..ad2d2bb5b
--- /dev/null
+++ b/views/assets/src/lib/google-links.js
@@ -0,0 +1,112 @@
+/**
+ * Render-only decoration for Google Drive / Meet links inside comment HTML.
+ *
+ * IMPORTANT: nothing here is ever stored. The DB keeps a plain link
+ * (no emoji, no 4-byte chars) so the comment save flow stays safe on utf8mb3
+ * columns. We inject mono lucide-style SVGs at render time, AFTER sanitize.
+ */
+
+// lucide 24x24 outline paths, mono via currentColor. Rendered at 14px, light shade.
+const SW = 'width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"'
+// shared file-body path used by most lucide File* glyphs
+const BODY = ' '
+const ICONS = {
+ drive:
+ ` `,
+ doc: `${BODY} `,
+ pdf: `${BODY} `,
+ sheet: `${BODY} `,
+ slides: ` `,
+ archive:` `,
+ audio: ` `,
+ video: `${BODY} `,
+ image: `${BODY} `,
+ code: `${BODY} `,
+ meet: ` `,
+}
+
+function driveKind(href, text) {
+ if (/docs\.google\.com\/spreadsheets/.test(href)) return 'sheet'
+ if (/docs\.google\.com\/presentation/.test(href)) return 'slides'
+ if (/docs\.google\.com\/document/.test(href)) return 'doc'
+ const name = (text || '').toLowerCase()
+ if (/\.pdf\b/.test(name)) return 'pdf'
+ if (/\.(xls|xlsx|csv|ods)\b/.test(name)) return 'sheet'
+ if (/\.(ppt|pptx|key|odp)\b/.test(name)) return 'slides'
+ if (/\.(doc|docx|txt|rtf|pages|odt|md)\b/.test(name)) return 'doc'
+ if (/\.(zip|rar|7z|tar|gz|tgz|bz2)\b/.test(name)) return 'archive'
+ if (/\.(mp3|wav|flac|aac|ogg|m4a|wma)\b/.test(name)) return 'audio'
+ if (/\.(mp4|mov|avi|mkv|webm|flv|wmv|m4v)\b/.test(name)) return 'video'
+ if (/\.(jpg|jpeg|png|gif|svg|webp|heic|bmp|tiff?)\b/.test(name)) return 'image'
+ if (/\.(js|ts|jsx|tsx|json|html?|css|php|py|xml|java|rb|go|sh|sql|yml|yaml)\b/.test(name)) return 'code'
+ return 'drive'
+}
+
+function iconHtml(kind) {
+ return `${ICONS[kind]} `
+}
+
+/** Only allow http(s) URLs through (blocks javascript:/data: etc. in injected links). */
+function safeHttpUrl(u) {
+ try {
+ const parsed = new URL(u, window.location.origin)
+ return (parsed.protocol === 'https:' || parsed.protocol === 'http:') ? parsed.href : ''
+ } catch {
+ return ''
+ }
+}
+
+export function decorateGoogleLinks(html) {
+ if (typeof html !== 'string' || html === '') return html
+ if (!/drive\.google\.com|docs\.google\.com|meet\.google\.com/.test(html)) return html
+
+ let doc
+ try {
+ doc = new DOMParser().parseFromString(html, 'text/html')
+ } catch {
+ return html
+ }
+
+ doc.querySelectorAll('a[href]').forEach((a) => {
+ const href = a.getAttribute('href') || ''
+ const text = a.textContent || ''
+
+ if (/meet\.google\.com/.test(href)) {
+ // Build via DOM (textContent + setAttribute) so the comment-supplied
+ // title/href can never inject markup (stored XSS guard). The icon span is
+ // a trusted constant, so innerHTML is fine for it only.
+ const card = doc.createElement('span')
+ card.className =
+ 'pm-meet-card inline-flex flex-col gap-1 w-fit max-w-full rounded-md border border-pm-text-muted/20 px-3 py-2 my-1 text-sm align-middle'
+
+ const top = doc.createElement('span')
+ top.className = 'inline-flex items-center gap-2'
+ top.innerHTML = iconHtml('meet')
+ const titleEl = doc.createElement('span')
+ titleEl.className = 'text-pm-text'
+ titleEl.textContent = text || 'Google Meet'
+ top.appendChild(titleEl)
+
+ const join = doc.createElement('a')
+ join.className = 'text-pm-accent hover:underline shrink-0'
+ join.setAttribute('target', '_blank')
+ join.setAttribute('rel', 'noreferrer')
+ join.textContent = 'Join Google Meet'
+ const safe = safeHttpUrl(href)
+ if (safe) join.setAttribute('href', safe)
+
+ card.appendChild(top)
+ card.appendChild(join)
+ a.replaceWith(card)
+ return
+ }
+
+ if (/drive\.google\.com|docs\.google\.com/.test(href)) {
+ const kind = driveKind(href, text)
+ a.classList.add('pm-gdrive-link', 'inline-flex', 'items-center', 'gap-1', 'align-middle')
+ a.insertAdjacentHTML('afterbegin', iconHtml(kind))
+ }
+ })
+
+ return doc.body.innerHTML
+}
diff --git a/views/assets/src/store/googleWorkspaceSlice.js b/views/assets/src/store/googleWorkspaceSlice.js
new file mode 100644
index 000000000..c2cd3ac2c
--- /dev/null
+++ b/views/assets/src/store/googleWorkspaceSlice.js
@@ -0,0 +1,234 @@
+import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
+import { useApi } from '@hooks/useApi'
+
+const api = useApi()
+
+const initialState = {
+ status: { configured: false, picker_ready: false, drive_enabled: false, calendar_enabled: false, meet_enabled: false, connected: false, account_email: '', expired: false, calendar_connected: false, meet_connected: false, drive_user_on: true, drive_comments_on: true },
+ settings: { client_id: '', has_secret: false, api_key: '', app_id: '', drive_enabled: false, picker_ready: false, redirect_uri: '' },
+ statusLoading: false,
+ statusFetched: false, // true once status/settings resolved — lets UI prefer live store over stale PM_Vars
+ settingsLoading: false,
+ saving: false,
+ attachmentsByTask: {},
+ attachmentsByKey: {}, // { 'type:id': [files] }
+ attachLoading: false,
+
+ // Per-project Drive role access
+ canUseByProject: {}, // { [projectId]: bool }
+ accessByProject: {}, // { [projectId]: { co_worker, client } }
+}
+
+export const fetchStatus = createAsyncThunk(
+ 'googleWorkspace/fetchStatus',
+ async (_, { rejectWithValue }) => {
+ try { const res = await api.get('google-workspace/status'); return res.data ?? res }
+ catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const fetchSettings = createAsyncThunk(
+ 'googleWorkspace/fetchSettings',
+ async (_, { rejectWithValue }) => {
+ try { const res = await api.get('google-workspace/settings'); return res.data ?? res }
+ catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const saveSettings = createAsyncThunk(
+ 'googleWorkspace/saveSettings',
+ async ({ client_id, client_secret, api_key, app_id, drive_enabled, drive_comments }, { rejectWithValue }) => {
+ try {
+ const body = { client_id, client_secret, api_key, app_id, drive_enabled }
+ if (drive_comments !== undefined) body.drive_comments = drive_comments ? 1 : 0
+ const res = await api.post('google-workspace/settings', body)
+ return res.data ?? res
+ } catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const getAuthUrl = createAsyncThunk(
+ 'googleWorkspace/getAuthUrl',
+ async (arg, { rejectWithValue }) => {
+ const params = {}
+ if (arg && arg.withCalendar) params.with_calendar = 1
+ try { const res = await api.get('google-workspace/auth-url', Object.keys(params).length ? params : undefined); return (res.data ?? res).auth_url }
+ catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const saveDrivePref = createAsyncThunk(
+ 'googleWorkspace/saveDrivePref',
+ async ({ drive_on }, { rejectWithValue }) => {
+ try { const res = await api.post('google-workspace/my-prefs', { drive_on: drive_on ? 1 : 0 }); return (res.data ?? res).drive_user_on }
+ catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const disconnect = createAsyncThunk(
+ 'googleWorkspace/disconnect',
+ async (_, { rejectWithValue }) => {
+ try { await api.post('google-workspace/disconnect'); return true }
+ catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const fetchPickerConfig = createAsyncThunk(
+ 'googleWorkspace/fetchPickerConfig',
+ async (_, { rejectWithValue }) => {
+ try { const res = await api.get('google-workspace/drive/picker-config'); return res.data ?? res }
+ catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const fetchAttachments = createAsyncThunk(
+ 'googleWorkspace/fetchAttachments',
+ async ({ projectId, taskId }, { rejectWithValue }) => {
+ try {
+ const res = await api.get(`projects/${projectId}/tasks/${taskId}/google-drive`)
+ return { taskId, files: res.data ?? res }
+ } catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const attachFile = createAsyncThunk(
+ 'googleWorkspace/attachFile',
+ async ({ projectId, taskId, file }, { rejectWithValue }) => {
+ try {
+ const res = await api.post(`projects/${projectId}/tasks/${taskId}/google-drive`, { file })
+ return { taskId, file: res.data ?? res }
+ } catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+const akey = (type, id) => `${type}:${id}`
+
+export const fetchAttachmentsFor = createAsyncThunk(
+ 'googleWorkspace/fetchAttachmentsFor',
+ async ({ projectId, attachableType, attachableId }, { rejectWithValue }) => {
+ try {
+ const res = await api.get(`projects/${projectId}/google-drive`, { attachable_type: attachableType, attachable_id: attachableId })
+ return { key: akey(attachableType, attachableId), files: res.data ?? res }
+ } catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const attachFileFor = createAsyncThunk(
+ 'googleWorkspace/attachFileFor',
+ async ({ projectId, attachableType, attachableId, file }, { rejectWithValue }) => {
+ try {
+ const res = await api.post(`projects/${projectId}/google-drive`, { attachable_type: attachableType, attachable_id: attachableId, file })
+ return { key: akey(attachableType, attachableId), file: res.data ?? res }
+ } catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const detachFileFor = createAsyncThunk(
+ 'googleWorkspace/detachFileFor',
+ async ({ projectId, attachableType, attachableId, id }, { rejectWithValue }) => {
+ try {
+ await api.del(`projects/${projectId}/google-drive/${id}`)
+ return { key: akey(attachableType, attachableId), id }
+ } catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const fetchCanUse = createAsyncThunk(
+ 'googleWorkspace/fetchCanUse',
+ async ({ projectId }, { rejectWithValue }) => {
+ try {
+ const res = await api.get(`projects/${projectId}/google-workspace/can-use`)
+ return { projectId, canUse: !!(res.data ?? res).can_use }
+ } catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const fetchProjectAccess = createAsyncThunk(
+ 'googleWorkspace/fetchProjectAccess',
+ async ({ projectId }, { rejectWithValue }) => {
+ try {
+ const res = await api.get(`projects/${projectId}/google-workspace/access`)
+ return { projectId, access: res.data ?? res }
+ } catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const saveProjectAccess = createAsyncThunk(
+ 'googleWorkspace/saveProjectAccess',
+ async ({ projectId, co_worker, client }, { rejectWithValue }) => {
+ try {
+ const res = await api.post(`projects/${projectId}/google-workspace/access`, { co_worker, client })
+ return { projectId, access: res.data ?? res }
+ } catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+export const detachFile = createAsyncThunk(
+ 'googleWorkspace/detachFile',
+ async ({ projectId, taskId, id }, { rejectWithValue }) => {
+ try {
+ await api.del(`projects/${projectId}/tasks/${taskId}/google-drive/${id}`)
+ return { taskId, id }
+ } catch (e) { return rejectWithValue(e.message) }
+ },
+)
+
+const slice = createSlice({
+ name: 'googleWorkspace',
+ initialState,
+ reducers: {},
+ extraReducers: (builder) => {
+ builder
+ .addCase(fetchStatus.pending, (s) => { s.statusLoading = true })
+ .addCase(fetchStatus.fulfilled, (s, a) => { s.statusLoading = false; s.statusFetched = true; s.status = a.payload })
+ .addCase(fetchStatus.rejected, (s) => { s.statusLoading = false })
+
+ .addCase(fetchSettings.pending, (s) => { s.settingsLoading = true })
+ .addCase(fetchSettings.fulfilled, (s, a) => { s.settingsLoading = false; s.settings = a.payload })
+ .addCase(fetchSettings.rejected, (s) => { s.settingsLoading = false })
+
+ .addCase(saveSettings.pending, (s) => { s.saving = true })
+ .addCase(saveSettings.fulfilled, (s, a) => { s.saving = false; s.statusFetched = true; 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.calendar_enabled = a.payload.calendar_enabled; s.status.meet_enabled = a.payload.meet_enabled; s.status.drive_comments_on = a.payload.drive_comments })
+ .addCase(saveSettings.rejected, (s) => { s.saving = false })
+
+ .addCase(disconnect.fulfilled, (s) => { s.status = { ...s.status, connected: false, account_email: '', expired: false, calendar_connected: false, meet_connected: false } })
+ .addCase(saveDrivePref.fulfilled, (s, a) => { s.status.drive_user_on = a.payload })
+
+ .addCase(fetchAttachmentsFor.fulfilled, (s, a) => { s.attachmentsByKey[a.payload.key] = a.payload.files })
+ .addCase(attachFileFor.pending, (s) => { s.attachLoading = true })
+ .addCase(attachFileFor.fulfilled, (s, a) => {
+ s.attachLoading = false
+ const list = s.attachmentsByKey[a.payload.key] || []
+ if (!list.some(f => f.id === a.payload.file.id)) {
+ s.attachmentsByKey[a.payload.key] = [a.payload.file, ...list]
+ }
+ })
+ .addCase(attachFileFor.rejected, (s) => { s.attachLoading = false })
+ .addCase(detachFileFor.fulfilled, (s, a) => {
+ const list = s.attachmentsByKey[a.payload.key] || []
+ s.attachmentsByKey[a.payload.key] = list.filter(f => f.id !== a.payload.id)
+ })
+
+ .addCase(fetchCanUse.fulfilled, (s, a) => { s.canUseByProject[a.payload.projectId] = a.payload.canUse })
+ .addCase(fetchProjectAccess.fulfilled, (s, a) => { s.accessByProject[a.payload.projectId] = a.payload.access })
+ .addCase(saveProjectAccess.fulfilled, (s, a) => { s.accessByProject[a.payload.projectId] = a.payload.access })
+
+ .addCase(fetchAttachments.fulfilled, (s, a) => { s.attachmentsByTask[a.payload.taskId] = a.payload.files })
+ .addCase(attachFile.pending, (s) => { s.attachLoading = true })
+ .addCase(attachFile.fulfilled, (s, a) => {
+ s.attachLoading = false
+ const list = s.attachmentsByTask[a.payload.taskId] || []
+ if (!list.some(f => f.id === a.payload.file.id)) {
+ s.attachmentsByTask[a.payload.taskId] = [a.payload.file, ...list]
+ }
+ })
+ .addCase(attachFile.rejected, (s) => { s.attachLoading = false })
+
+ .addCase(detachFile.fulfilled, (s, a) => {
+ const list = s.attachmentsByTask[a.payload.taskId] || []
+ s.attachmentsByTask[a.payload.taskId] = list.filter(f => f.id !== a.payload.id)
+ })
+ },
+})
+
+export default slice.reducer
diff --git a/views/assets/src/store/index.js b/views/assets/src/store/index.js
index 98bc8abe0..04d1cd69e 100644
--- a/views/assets/src/store/index.js
+++ b/views/assets/src/store/index.js
@@ -5,6 +5,7 @@ import projectsReducer from './projectsSlice'
import taskListsReducer from './taskListsSlice'
import tasksReducer from './tasksSlice'
import milestonesReducer from './milestonesSlice'
+import googleWorkspaceReducer from './googleWorkspaceSlice'
import kanbanReducer from './kanbanSlice'
// Re-export for convenience
@@ -17,6 +18,7 @@ const staticReducers = {
taskLists: taskListsReducer,
tasks: tasksReducer,
milestones: milestonesReducer,
+ googleWorkspace: googleWorkspaceReducer,
kanban: kanbanReducer,
}